diff --git a/README.md b/README.md index 6976935f..7c533b6e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ VotingPlugin does not depend on it for startup, joins, routing, rewards, or shut Maintainers and coding agents should read [AGENTS.md](AGENTS.md). The complete management, inspection, limits, and threat model reference is [docs/control-management.md](docs/control-management.md). +The scope-first WebUI and legacy single-source editing boundary are documented in +[docs/webui-workspaces.md](docs/webui-workspaces.md). ## Trust and deployment boundary @@ -206,10 +208,19 @@ Control and VotingPlugin both enforce fixed quick-setup preset/option schemas; u than becoming arbitrary YAML writes. The WebUI settings catalog is a static versioned reference over these typed paths, not a generic setting API. -Opening Settings or changing the selected server automatically reads that server's current configuration. A failed read -clears the editor and exposes an inline retry; successful applies invalidate cached values and read the confirmed state -again. Read actions load only the primary server shown in the configuration header. Preview and apply still cover every server -explicitly included in configuration changes, so one slow secondary node does not delay opening the editor or guided form. +Opening Settings automatically reads current configuration; errors expose inline Retry. The Phase 2 General Settings visual +editor reads **each** selected workspace backend and previews only explicitly changed fields independently per target. +The Phase 3 [Vote Sites visual editor](docs/webui-vote-sites.md) likewise reads each selected backend's main +`VoteSites.yml`, distinguishes mixed/partial/error states, and uses source-preserving per-target add/edit/remove previews. +Successful applies invalidate cached values and read the confirmed state again. Legacy guided forms and raw YAML retain +their explicitly labeled source-server behavior; Vote Sites synchronization remains a separate source-copy operation. +The [Rewards workspace](docs/webui-rewards.md) reads reward scopes from each selected backend, shows mixed +command lists and advanced structure, and supports bounded source-preserving inline Vote Site reward operations. +Connectors with the separate `config.reward-files.v1` capability support bounded inventory and editing of +existing named `Rewards/*.yml` files. Older connectors remain usable but show named files as unsupported; complex +nested rewards and unrecognized metadata retain the Full YAML fallback. +The disposable running-connector and packaged-browser validation boundaries are recorded in +[WebUI integration validation](docs/webui-integration.md). `plugin.deploy.v1` is additive and exact: older nodes remain connected but are excluded from JAR staging. The WebUI uploads at most 64 MiB, Control validates the ZIP structure and root `plugin.yml`, and every node re-verifies the SHA-256 before @@ -318,3 +329,5 @@ support archive, cloud relay, or remote-support sessions. Current topology and a nodes automatically re-register after a Control restart and an interrupted change requires a new preview. Redacted operation history, audit metadata, and configuration snapshots persist without making an ambiguous write resumable. Manual installation remains supported; VotingPlugin may also opt in to verified download and child-process hosting. +The scope-aware [General Settings editor](docs/webui-general-settings.md) reads each selected backend independently, +previews only explicit boolean edits with per-target revision protection, and keeps Full YAML/Compare available for advanced settings. diff --git a/docs/control-management.md b/docs/control-management.md index 9d412e00..c4c592ed 100644 --- a/docs/control-management.md +++ b/docs/control-management.md @@ -41,6 +41,7 @@ Control accepts only the intersection with its own allow-list. | `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.reward-files.v1` | Bounded inventory and revisioned editing of existing direct `Rewards/.yml` files on a Bukkit node | | `config.file-comments.v1` | Preserves Control-managed comment metadata where supported | | `config.quick-setup.v1` | Typed guided settings and reward/site presets | | `config.quick-setup.v2` | Vote Party guided settings including revision-safe Enabled round trips | @@ -119,6 +120,27 @@ Public/history-only quick-setup selectors carry an internal non-serialized redac so they cannot become executable requests. The node's acknowledged result exposes only the derived target file, not proposal actions/messages. +### Named reward files + +`config.reward-files.v1` is an additive Bukkit capability for an existing direct +`Rewards/.yml` file, where `` is an ASCII letter or digit followed by at +most 99 ASCII letters, digits, `_`, or `-`. It is independent from ordinary managed +files and from inspections: an older connector remains connected and simply leaves +named files unavailable. The connector advertises it only when its Rewards directory +supports secure pinned-directory staging. Paths, `.yaml`, traversal, symlinks, +case-only aliases, files over 512 KiB, and inventories over 100 eligible files are +rejected; a missing Rewards directory has an empty inventory. + +The read-only `reward-file-inventory` inspection takes no filters and returns only +the bounded names list. It requires both accepted `data.inspect.v1` and +`config.reward-files.v1`. A named-file READ, PREVIEW, or APPLY uses the normal +`domain:"file"` envelope with `fileName:"Rewards/.yml"`, requires the exact +accepted capability, and keeps the usual revision-bound retained READ and one-time +approval. APPLY stages and atomically publishes through the pinned directory, +reloads VotingPlugin, verifies the file is active, and rolls back locally if reload +or verification fails. This capability never creates or deletes files and never +grants access outside `Rewards/`. + 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 diff --git a/docs/webui-general-settings.md b/docs/webui-general-settings.md new file mode 100644 index 00000000..f6eb94c1 --- /dev/null +++ b/docs/webui-general-settings.md @@ -0,0 +1,94 @@ +# General Settings: independent workspace proposals + +General Settings uses `selectedTargetIds`, not the inspected server or the legacy tool source. One target uses the same +state model as many targets. Global mode is separate and cannot open this backend editor. Other guided presets, Rewards, +Full YAML, Compare, snapshots and network workflows retain their existing boundaries. + +## Source-verified curated settings + +All entries are top-level booleans in VotingPlugin `Config.yml`. Source verification used VotingPlugin `origin/master` +at `11f20c39f551c40c55923c6a686b1b6c2a260f83`, its default Config.yml, configuration reader, backend connector and +`VotingPluginMain.reloadFromControl`. This is evidence, not a pinned development dependency. + +| YAML path | Documented default | Effect | Runtime action | +| --- | --- | --- | --- | +| `ProcessRewards` | true | Process vote rewards (offline processing still follows VotingPlugin behavior) | Configuration reload | +| `AutoCreateVoteSites` | true | Create sites for newly received services | Configuration reload | +| `ExtraAllSitesCheck` | false | Additional duplicate all-sites reward check | Configuration reload | +| `CountFakeVotes` | true | Include fake votes in points/totals | Configuration reload | +| `DisableNoServiceSiteMessage` | false | Suppress missing service-site and related site warnings | Configuration reload | +| `DisableUpdateChecking` | false | Disable update checks | Configuration reload plus backend restart to reconcile scheduler lifecycle | +| `UseVoteGUIMainCommand` | false | Open the vote GUI from the main vote command | Configuration reload | +| `CloseInventoryOnVote` | true | Close the vote inventory when voting | Configuration reload | +| `ExtraVoteShopCheck` | true | Additional vote shop purchase check | Configuration reload | + +The update-check timer is started by `CheckUpdate.startUp` at plugin startup, not by the configuration reload path. +The editor does not restart a backend. A confirmed READ proves persisted configuration, not completion of a restart. +Vote Logging and Vote Party remain accessible through their existing typed/capability-gated tools; they are deliberately +not added to this first visual group. Missing values are never synthesized from defaults or inserted automatically. + +## State and capability contract + +`configuration-state.js` provides per-target snapshots and explicit dirty overrides. Fields distinguish SAME, MIXED, +MISSING, UNSUPPORTED and ERROR, with supported-subset values and each excluded target visible separately. Mixed controls +have a placeholder, not an implicit true/false choice. Focusing a control is not an edit. A reset removes its override. + +The required connector contract is the existing `config.files.v1` on an online Bukkit node. Actual parsed file presence +and boolean type gate each field, instead of guessing support from a version string. Missing keys, wrong types and +anchored/aliased edited values are not writable. Malformed/ambiguous YAML fails closed with sanitized errors. Offline, +unsupported and failed targets remain visible. Applying only a supported subset requires explicit acknowledgement. + +## Additive Control API (no connector/protocol change) + +1. Existing `/api/v1/configuration/read` reads Config.yml from every eligible selected target in a bounded batch. +2. POST `/api/v1/configuration/general-settings/state` with `readOperationId` and `nodeId` returns nine typed fields, + that target's session and revision. It requires a retained successful READ of that node's Config.yml. +3. POST `/api/v1/configuration/general-settings/preview` adds `overrides` (one to nine allowlisted JSON booleans). + Control patches only changed scalar source spans in that target's retained redacted document and creates an ordinary + FILE PREVIEW bound to the READ revision. Comments, unknown keys, unrelated values and redacted placeholders are not + regenerated. Each target has its own proposal and approval token; no common document is distributed. +4. Existing `/api/v1/configuration/apply` consumes that exact preview/token once. The connector verifies its revision, + writes/reloads and retains its existing rollback semantics. No network-wide atomicity is claimed. + +The additive POST routes use existing admin authentication and session CSRF protection, request-size/JSON constraints, +and strict known request fields. The typed view does not expose document content or secrets. Operation history, audit +and journal continue to omit file proposal contents. Existing node file redaction/restoration remains unchanged. + +## Approval, retention and confirmation + +Target IDs/sessions, every relevant READ revision/state and explicit overrides form the preview signature. Edits, resets, +scope changes and changed revisions invalidate approval. Before APPLY a fresh scope READ checks that signature again; +the connector revision guard closes the remaining race. Navigation only reads and never previews, applies or reloads. + +At most eight changed targets can be previewed in one visual batch, within the existing bounded FILE-operation retention. +All workspace targets are still read/displayed. Larger changed sets must be reduced explicitly, not silently truncated. +Successful revision-bound pending previews are protected from ordinary FILE-retention eviction for fifteen minutes, +or until consumed/discarded. `/api/v1/configuration/general-settings/discard-preview` releases the exact abandoned +approval without scheduling a task or modifying a node. The browser disposes approvals on edits/scope changes/re-preview; +disposal is best-effort during connection loss, with bounded retention as the fallback. A stale visual PREVIEW cannot +be retried from Activity with its old document; it requires a fresh READ and dirty-field preview. +There is no unlimited retention or additional durable proposal store. Expired/evicted READ snapshots require a fresh READ. + +Per-target previews show exact changes, unchanged targets and exclusions. Apply results retain operation IDs, success, +structured failures, reload and rollback flags. Affected caches are invalidated and fresh confirmed READs rebuild mixed +state after the batch. Failed targets/confirmations remain visible; overrides clear only when confirmed resolved. Retry +is read-only, never an automatic write retry. Workspace/configuration cache is session-bound and not persisted to storage. + +## Verification + +```shell +node --check src/main/resources/web/app.js +node --check src/main/resources/web/workspace.js +node --check src/main/resources/web/configuration-state.js +node --check src/main/resources/web/general-settings.js +node --test src/test/web/*.test.cjs +mvn -B test +mvn -B clean verify +git diff --check +``` + +Deterministic tests cover source-preserving per-target proposals, allowlists/capabilities, stale revision/approval, +single-flight and stale-result guards, mixed/partial reads, dirty fields and confirmed partial apply results. Browser +smoke evidence uses actual static assets against controlled API fixtures. A disposable Paper 26.2 server with the current +VotingPlugin connector also exercised the real HTTP connector, Config.yml READ/PREVIEW/APPLY/reload/confirmed READ, +and stale revision rejection. That local gate never points at production configuration. diff --git a/docs/webui-integration.md b/docs/webui-integration.md new file mode 100644 index 00000000..04a6b15b --- /dev/null +++ b/docs/webui-integration.md @@ -0,0 +1,66 @@ +# Disposable connector and browser validation + +The WebUI visual editors use the existing authenticated Control HTTP API and +VotingPlugin connector. A local integration gate was run with one disposable +Paper 26.2 server (Java 25), current VotingPlugin 7.1.2-SNAPSHOT, and the +Control shaded JAR on loopback addresses. It did not use a production node or +configuration. The test server used offline mode solely for this isolated gate; +never expose that setup to a network. + +## Running connector path + +The external test harness drove real API requests through Control, the HTTP +connector, the Paper plugin, its configuration files, and VotingPlugin reload. +It exercised: + +- General Settings `Config.yml` READ → typed state → preview → approval/APPLY → + reload → confirmed READ. +- Vote Site add, edit, remove, and confirmed READ, including an inline reward + left intact by an unrelated site field edit. +- Inline reward create, command append, Money edit, and confirmed READ. +- Named reward-file names-only inventory, READ, command append, Money and item + Amount edits, reload and confirmed READ. The disposable file retained its + custom item metadata and unknown field. +- A named reward deliberately made inactive at reload was reported as + `RELOAD_FAILED`; Control restored the previous file and a fresh READ + confirmed the rollback. No player reward was executed. +- An old expected revision rejected after a later write; an old connector + session's retained READ rejected after a Paper restart (`TARGET_CHANGED`). +- A traversal filename rejected by Control before it could reach the node. + +The first running `Config.yml` READ revealed a null comment-metadata entry in +the Bukkit YAML loader. The connector now preserves that blank-line marker +while redacting comments and returns a sanitized structured read error if a genuine I/O failure remains. +The disposable test reproduced the failure before the fix and completed the +above read/apply path after the fix. + +This gate used disposable files and no real votes or player rewards. It did not +exercise a running two-backend partial apply, a full proxy, or a failure of the +general VotingPlugin reload routine itself. The named-reward post-reload failure +above and service-layer tests cover restoration; they are not evidence for every +possible plugin-reload failure. These gaps are not described as successful live +integration results. + +The local evidence, artifacts, and gate scripts were kept **outside** the Git +repositories and are not part of this change. Credential and admin-token files +were private (mode 0600); never copy their contents into logs, URLs, commits, +or bug reports. The scripts assumed a loopback disposable Paper/Control pair +and must not be pointed at a live server. + +## Browser and package gate + +The actual `web/*` assets extracted from the clean-verified Control shaded JAR +were served to Chromium behind controlled API fixtures. Separate browser gates +covered Home/server selection, single/multi/global scope, General Settings +mixed/partial preview and stale approval, Vote Sites add/edit/remove and +partial rollback presentation, Rewards mixed commands/append and named item +preview. Desktop widths 1440, 1280, 1024 and 768 px plus mobile 390 px were +checked for page errors, horizontal overflow, visible focus, and usable +navigation. A browser fixture is not substituted for the running connector +gate above. + +For this candidate, JavaScript syntax checks, the complete frontend suite, +`mvn -B test`, `mvn -B clean verify`, `git diff --check`, and JAR ZIP +integrity/content checks were run alongside the disposable connector/browser +gates. Keep Maven builds sequential. Visual editors must still require exact +preview and approval; an automatic READ or navigation action must never APPLY. diff --git a/docs/webui-rewards.md b/docs/webui-rewards.md new file mode 100644 index 00000000..b4e67150 --- /dev/null +++ b/docs/webui-rewards.md @@ -0,0 +1,118 @@ +# Rewards workspace + +This inventory is based on the current VotingPlugin `VoteSites.yml`, `Config.yml`, +`SpecialRewards.yml`, the current reward registrars, and AdvancedCore's reward +loader. The screenshots are visual references, not a schema. The companion connector PR adds +`config.reward-files.v1` for strictly named `.yml` files directly under `Rewards/`; +older connectors still support the top-level files and show named files as unsupported. + +| Source | Reward path or family | Control support | +|---|---|---| +| `VoteSites.yml` | `VoteSites..Rewards` | Visually readable; bounded command and scalar editing, explicit create/remove | +| `VoteSites.yml` | `EverySiteReward` | Read-only inventory / Full YAML | +| `VoteSites.yml` | `VoteSites..WaitUntilVoteDelayRewards`, `CoolDownEndRewards` | Advanced-only / Full YAML | +| `Config.yml` | `VoteReminders..Rewards` and reminder defaults | Read-only inventory / Full YAML | +| `SpecialRewards.yml` | `VoteParty.Rewards`, `VoteMilestones..Rewards` | Read-only inventory / Full YAML | +| `SpecialRewards.yml` | `AnySiteRewards` | Read-only inventory / Full YAML | +| `SpecialRewards.yml` | `VoteStreaks` progress-group milestones and `LostRewards` | Read-only inventory / Full YAML | +| `SpecialRewards.yml` | `MonthlyAwards`, `WeeklyAwards`, `DailyAwards` rank rewards | Read-only inventory / Full YAML | +| `SpecialRewards.yml` | `NameMCLikeReward.Rewards`, legacy reward sections | Read-only inventory / Full YAML | +| `Rewards/.yml` | Named AdvancedCore reward definitions | Bounded inventory/READ and existing-root visual edits with source-preserving PREVIEW/APPLY; no file creation/deletion | + +The current defaults were successfully parsed as 3 VoteSites scopes, 6 Config +scopes, and 24 SpecialRewards scopes. The exact dynamic families are: + +- `VoteSites..Rewards` and, when configured, + `VoteSites..WaitUntilVoteDelayRewards` and + `VoteSites..CoolDownEndRewards`; plus `EverySiteReward`. +- `VoteReminderOptions.Defaults.Rewards` and + `VoteReminders..Rewards` in `Config.yml`. +- `AnySiteRewards`, `VoteParty.Rewards`, `VoteMilestones..Rewards`, + `VoteStreaks..Rewards`, + `VoteStreaks.ProgressGroups..LostRewards`, and + `VoteStreaks.ProgressGroups..Milestones..Rewards`. +- `NameMCLikeReward.Rewards`; legacy `Cumulative..Rewards`, + `MileStones..Rewards`, and `VoteStreak...Rewards`; + `MonthlyAwards..Rewards`, `WeeklyAwards..Rewards`, and + `DailyAwards..Rewards` in `SpecialRewards.yml`. + +The AdvancedCore reward engine additionally has nested map structures such as +`AdvancedPriority`, `Choices`, `SpecialChance`, and `Rewards` sub-rewards. +Names under these maps are identities and order may affect execution. The +read-only structural tree exposes bounded path names, but Simple mode does not +rewrite or normalize those nested maps. + +The advanced inventory reports reward scope presence and known primitive fields, +plus names of unhandled keys. It does not turn an advanced-only scope into a +visual write path. Named reward files are separate scopes, never inlined into a +referring site. References may be visible in Advanced structure, but this editor +does not resolve or rewrite them. Creation/deletion of named files remains outside +the bounded visual API. + +The Simple-mode subset applies to **existing inline Vote Site rewards** identified +by YAML site key, never by `Name` or `ServiceSite`, and to the root of existing +named `Rewards/.yml` files where the new capability is negotiated: + +| Operation | Exact path | Meaning | +|---|---|---| +| Append / remove one exact command | `VoteSites..Rewards.Commands` | Preserve each target's distinct existing list; duplicate-match removal fails closed | +| Explicitly replace command list | `VoteSites..Rewards.Commands` | Destructive, requires confirmation; fails closed if comments would be lost | +| Set player or broadcast message | `VoteSites..Rewards.Messages.Player` / `.Broadcast` | Edit only an existing scalar, preserving sibling message keys | +| Set numeric amount/chance | `VoteSites..Rewards.Money` / `.Chance` | Edit only an existing numeric scalar; no claim that an economy plugin is active or chance is guaranteed | +| Set existing item material/amount | `VoteSites..Rewards.Items..Material` / `.Amount` or the equivalent named-file root path | Change only that scalar source span; preserve item display metadata, enchantments, unknown keys and sibling items. Material is an exact runtime identifier, not a stale browser material list. Amount is 1–64. | +| Create inline reward | `VoteSites..Rewards` | Explicit new section with one console command only, and only if absent | +| Remove inline reward | `VoteSites..Rewards` | Exact subtree removal; the site and all named files remain | + +`Commands` is an ordered list in the current defaults. The AdvancedCore loader +also accepts `Commands.Console` and `Commands.Player`; the current read model +marks those map shapes advanced-only and does not normalize them. `Messages.Player` +and `.Broadcast` are separate. `Money` may have a random-range mapping, which +is advanced-only. Item metadata, nested `AdvancedPriority`, `Choices`, +`SpecialChance`, conditions, references, and custom/newer fields are visible as +advanced keys and preserved by path-local edits. Direct item Material/Amount +leaves are editable only when their exact existing source shape is safe; no +item map or metadata is synthesized. Nested `AdvancedPriority`, `Choices` and +similar child rewards remain read-only in the structure tree: their identity, +order and execution semantics cannot safely be generalized into a path edit. +Current `SpecialRewards.yml` actively uses `VoteMilestones`, Vote Party, +streak/progress groups, lost rewards, rank awards and other structures. These +are inventoried/read-only in the visual page with Full YAML as the edit fallback; +they are not called deprecated or silently flattened. The legacy reward builder +remains reachable and explicitly warns that it replaces a selected subtree; +it is not the existing-reward editor. + +Named files are inventoried by names only, at most 100 direct `.yml` files of +at most 512 KiB each. Logical basenames are ASCII letters/digits followed by +letters/digits/underscore/hyphen (100 characters maximum). Traversal, +separators, absolute paths, symlinks, case-ambiguous names and other extensions +are rejected or excluded. The browser lazily reads only a selected named file, +and every edit uses its own revision-bound retained READ and exact approval. +The capable connector uses descriptor-relative secure directory operations for +named-file reads and publication; on a filesystem provider without that facility, +named-file operations fail closed. Staged file contents and pinned directory +entries are forced on supported providers. Java does not provide that directory +guarantee portably across every filesystem provider; unsupported providers do +not advertise this optional capability. +Old connectors cannot advertise this capability, so they cannot be made to +serve named files by a browser request. + +Every target is READ independently. A visual edit is an explicit operation, +not a merged form value. PREVIEW is created from each target's own retained +source and expected revision, through the existing operation journal. A changed +target or field invalidates its approval; abandoned previews use the existing +disposal path. APPLY requires exact preview approval, then a confirmed fresh +READ. A failed reload remains a rollback/failure, never a success. Navigation +does not write. Visual batches retain the eight-changed-target limit. Source +parsing rejects duplicate/ambiguous keys, anchors/aliases, malformed and +multi-document YAML, excessive nesting and oversized documents; unsafe +operations fail closed instead of normalizing the entire document. + +The inventory's `advancedKeys` are names only. The typed endpoint is +authenticated and CSRF-protected, bounded to retained managed-file READs, and +does not return raw YAML, approval tokens, or unrelated file contents. + +The disposable Paper 26.2 connector gate exercised inline reward create/append/ +scalar edits and named-file inventory, READ, append, Money edit, reload, +confirmed READ, stale revision rejection and path rejection through actual +Control HTTP and VotingPlugin filesystem services. The server and configuration +were disposable; no production apply was performed. diff --git a/docs/webui-vote-sites.md b/docs/webui-vote-sites.md new file mode 100644 index 00000000..0a4d7c53 --- /dev/null +++ b/docs/webui-vote-sites.md @@ -0,0 +1,33 @@ +# Vote Sites visual editor (Phase 3) + +The Vote Sites editor is scoped to the selected Bukkit workspace, never the inspected server. A single server and multiple servers use the same model. The source of truth for this contract is VotingPlugin `origin/master` at `11f20c39f551c40c55923c6a686b1b6c2a260f83` and its default `VoteSites.yml`; that source SHA is review evidence, not a dependency pin. The visual mockups and historical VotingPluginEditor behavior do not define YAML names or defaults. + +## Exposed paths + +All paths are under `VoteSites.yml` → `VoteSites.`: + +| Path suffix | Type / absent behavior | Editing rule | +| --- | --- | --- | +| `Enabled` | Boolean; absent sites default disabled | Typed boolean | +| `Name` | Display name; absent uses site key | Bounded string | +| `ServiceSite` | Service matcher; missing or invalid site may not match votes | Bounded nonempty string | +| `VoteURL` | Vote link/text, possibly VotingPlugin link formatting | Bounded nonempty string; no external URL check | +| `VoteDelay` | Duration string such as `24h`; legacy numeric delay has separate semantics | Bounded nonempty string; numeric legacy value is read as unsupported, not normalized | +| `Priority` | Integer; higher priority sorts first | Signed 32-bit integer | +| `Hidden` | Boolean | Typed boolean | +| `DisplayItem.Material` | Material identifier string, interpreted by the installed Minecraft/VP runtime | Bounded text; unknown/current-version materials are retained unmodified | +| `DisplayItem.Amount` | Integer 1–64 for visual edits | Typed integer | + +The site key is the YAML mapping key, not `Name` or `ServiceSite`. New keys are restricted to 1–64 letters, digits, underscores and hyphens; case-insensitive conflicts are rejected. Arbitrary properties, inline rewards, comments, target-local site differences, and newer-version properties are not reserialized when editing an unrelated field. An explicit site removal removes that site's own inline rewards too; it never touches another site or a separate reward file. The UI confirms removal, then still requires preview and approval. + +## Safe per-target workflow + +The editor calls existing `config.files.v1` READ for `VoteSites.yml` on each eligible selected backend. The additive authenticated typed state endpoint reads only the retained successful result for that node and returns the exact session/revision, site key, nine allowlisted field states, editable flag and a reward-presence boolean. Both editors share the generic SAME/MIXED/MISSING/UNSUPPORTED/ERROR aggregator in `configuration-state.js`; the site-specific model adds key presence and add/remove policies, which the Phase 2 boolean-only `MultiTargetState` cannot represent. A matching supported subset is not presented as the entire workspace's state. It tracks only explicit field edits. Add creation on missing targets and editing existing sites require an explicit policy if presence is partial. No missing site is created by opening its editor. Offline and excluded targets stay visible and require acknowledgement before an otherwise-ready apply. + +Each typed preview is a single-target `ADD`, `EDIT`, or `REMOVE` with the exact retained READ ID, target ID, key and allowlisted fields. Control patches that target's own source spans and sends an ordinary revision-bound `VoteSites.yml` FILE preview through existing connector/journal logic. It never copies a source server's document to another target, exposes a generic YAML patch API, or silently writes during navigation. The pending approval is disposed on re-preview or changed draft/scope. A fresh read of the exact workspace occurs immediately before applying the explicit per-target approval tokens, followed by cache invalidation and fresh confirmation reads. The connector's write/reload/rollback behavior and durable operation entries remain authoritative; mixed success is reported per target and a successful write alone does not establish confirmed runtime state. At most eight **changed** targets can be previewed per batch; nothing is silently dropped. No automatic APPLY retry exists. + +The READ/preview/apply endpoints reuse admin authentication, session CSRF, bounded request JSON, connector capability checks, file redaction and operation journal. Malformed YAML, duplicate or case-ambiguous site/property keys, aliases/merges and unsupported field structures fail closed. A noncanonical casing such as `enabled` is shown as unsupported instead of being treated as an absent canonical `Enabled` field. Existing synchronization remains in Quick Setup as a separate source-to-destinations operation with its own semantics; normal visual editing never invokes it. Detected service names and vote-site health come from Control's read-only `data.inspect.v1` observations, not website uptime/ping checks. + +## Deliberate boundaries + +Current VotingPlugin loads sites from the main `VoteSites.yml`; separately stored site files in its managed configuration system are not loaded as part of this runtime site registry. The visual editor is limited to the main file and does not broaden filesystem access. An unsupported material is shown as its source value and is never coerced; the browser does not ship a stale cross-version material list. Inline rewards are preserved when editing ordinary site fields; the separate [Rewards workspace](webui-rewards.md) provides bounded visual reward edits and a Full YAML fallback for complex structures. One target's failed reload does not roll back successful changes on another target (no distributed atomicity). diff --git a/docs/webui-workspaces.md b/docs/webui-workspaces.md new file mode 100644 index 00000000..31e6b2ee --- /dev/null +++ b/docs/webui-workspaces.md @@ -0,0 +1,56 @@ +# WebUI workspaces + +Authentication opens Home, where an administrator chooses one or more registered Bukkit backends or Global Settings. +Proxy nodes remain visible but are not Bukkit configuration targets. Offline backends can be inspected; unsupported +capabilities remain unavailable. The select-all action includes only online Bukkit nodes with a supported management +capability, within the existing target bound. + +The browser keeps three separate concepts for the authenticated page session: + +- `managementScope`: `SERVER`, `MULTI_SERVER`, or `GLOBAL` (unset before choosing). +- `selectedTargetIds`: the backend workspace, not the targets of every operation. +- `inspectedServerId`: an optional individual overview, independent of the workspace selection. + +Opening an individual overview does not replace a multi-server selection. The scope bar names the workspace and the +single-server tool source. Home supports search, chips, clear, and continuing to an overview. Global Settings remembers +the previous backend selection, offers an explicit return, and never means “all servers.” Signing out clears the state. +Only scope, target IDs, inspected ID and route are saved in browser `sessionStorage` for the authenticated tab. +On reload, stored IDs are reconciled against current nodes; preview tokens, configuration documents and secrets are +never persisted. Signing out clears the saved workspace, and a new login opens Home. + +## Workspace safety boundary + +General Settings, Vote Sites and Rewards use independent per-target reads and dirty-only proposals (see their linked +documents). The legacy guided presets, current reward builder, and Full YAML editing remain **single-source tools**, +not mixed-value editors. +Their ordinary previews contain only the identified workspace source. Inspecting a backend outside the workspace does +not authorize editing it. Read-only Compare uses selected readable backends. Explicit proxy-method and VoteSites-sync +workflows keep their own target selection and preview/approval rules. Plugin staging also retains its separate explicit +eligible-target approval; navigation never stages a JAR or restarts a server. + +Existing automatic reads, single-flight loading, session/revision guards, inline retry, and post-apply invalidation and +confirmed reads remain the loading system. Scope/navigation changes invalidate approvals, not apply configuration. +Navigation itself introduces no write or reload semantics. Typed editors and named reward files add narrow authenticated +API/capability contracts documented on their respective pages. + +Hash routes include `#home`, `#servers//overview`, `#workspace/overview`, `#workspace/settings`, +`#workspace/vote-sites`, `#workspace/rewards`, `#workspace/configuration`, and `#global/network`. +Back/forward changes the view without replacing the backend workspace. Legacy panel routes still work after choosing +a scope. Configuration Full YAML and legacy guided tools explicitly identify their one document/source; the visual +editors use the selected backend workspace. Unsupported advanced structures remain accessible in Full YAML. + +Overview cards use registry connectivity, reported versions/capabilities, proxy-reported presence, bounded health +inspections, and retained configuration operations. Unknown is not healthy. Website latency, server capacity, host +resources, player join logs, and invented analytics are not shown. + +## Local verification + +```shell +node --check src/main/resources/web/app.js +node --check src/main/resources/web/workspace.js +node --test src/test/web/*.test.cjs +mvn test +``` + +The Node tests exercise the workspace model and application helpers using deterministic DOM/API stubs. Separate +Chromium fixture smoke checks cover actual bundled WebUI assets at desktop and mobile widths. 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 d061c0e2..5d3907a2 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -149,6 +149,191 @@ public synchronized OperationView createApply(UUID previewId, String approvalTok return view(apply); } + /** Typed view of a retained, successful read of this particular backend's Config.yml. */ + public synchronized SettingsState generalSettingsState(UUID readId, String nodeId) { + ConfigurationTaskResult snapshot = settingsSnapshot(readId, nodeId); + try { + return new SettingsState(readId, nodeId, snapshot.sessionId(), snapshot.revision(), + GeneralSettingsDocument.fields(snapshot.configuration().content())); + } catch (IllegalArgumentException malformed) { + throw new ValidationException("INVALID_CONFIGURATION", "General Settings requires an unambiguous YAML mapping", List.of(nodeId)); + } + } + + public synchronized OperationView createGeneralSettingsPreview(UUID readId, String nodeId, + Map overrides) { + ConfigurationTaskResult snapshot = settingsSnapshot(readId, nodeId); + if (overrides == null || overrides.isEmpty() || overrides.size() > 9 + || overrides.values().stream().anyMatch(value -> !(value instanceof Boolean))) { + throw invalid("General Settings requires one to nine boolean edits"); + } + Map edits = new LinkedHashMap<>(); + overrides.forEach((key, value) -> edits.put(key, (Boolean) value)); + String proposal; + try { + proposal = GeneralSettingsDocument.patch(snapshot.configuration().content(), edits); + } catch (IllegalArgumentException unsupported) { + throw invalid("Only present, supported General Settings booleans can be edited"); + } + ManagedConfiguration configuration = ManagedConfiguration.file("Config.yml", proposal); + ValidatedTargets targets = validateTargets(List.of(nodeId), FILE_CAPABILITY); + validateConfigurationTargets(targets, configuration); + byte[] token = new byte[32]; + random.nextBytes(token); + return create("PREVIEW", targets, configuration, + Base64.getUrlEncoder().withoutPadding().encodeToString(token), Map.of(nodeId, snapshot.revision())); + } + + private ConfigurationTaskResult settingsSnapshot(UUID readId, String nodeId) { + return fileSnapshot(readId, nodeId, "Config.yml"); + } + + private ConfigurationTaskResult fileSnapshot(UUID readId, String nodeId, String fileName) { + prune(); + ManagedConfiguration selector = ManagedConfiguration.file(fileName, null); + ValidatedTargets targets = validateTargets(nodeId == null ? List.of() : List.of(nodeId), selector.capability()); + validateConfigurationTargets(targets, selector); + StoredOperation read = operations.get(readId); + ConfigurationTaskResult result = read == null ? null : read.results.get(nodeId); + if (read == null || !"READ".equals(read.type) + || !ManagedConfiguration.FILE.equals(read.configuration.domain()) + || !fileName.equals(read.configuration.fileName()) || result == null || !result.success() + || result.configuration() == null || result.configuration().content() == null) { + throw new ValidationException("READ_REQUIRED", "A retained successful " + fileName + " read is required", + List.of(nodeId)); + } + if (!Objects.equals(result.sessionId(), targets.sessions().get(nodeId))) { + throw new ValidationException("TARGET_CHANGED", "The backend reconnected; read it again", List.of(nodeId)); + } + return result; + } + + public record SettingsState(UUID readOperationId, String nodeId, UUID sessionId, String revision, + Map fields) { } + + /** Typed, secret-bounded inventory from one retained successful VoteSites.yml read. */ + public synchronized VoteSitesState voteSitesState(UUID readId, String nodeId) { + ConfigurationTaskResult snapshot = fileSnapshot(readId, nodeId, "VoteSites.yml"); + try { + List sites = VoteSitesDocument.inventory(snapshot.configuration().content()).sites().stream() + .map(site -> new VoteSiteState(site.key(), site.editable(), site.fields(), site.rewardsConfigured())) + .toList(); + return new VoteSitesState(readId, nodeId, snapshot.sessionId(), snapshot.revision(), sites); + } catch (IllegalArgumentException malformed) { + throw new ValidationException("INVALID_CONFIGURATION", + "Vote Sites requires one unambiguous main VoteSites.yml mapping", List.of(nodeId)); + } + } + + /** Creates one revision-bound preview for one exact typed site action on one target. */ + public synchronized OperationView createVoteSitesPreview(UUID readId, String nodeId, String action, + String siteKey, Map fields) { + ConfigurationTaskResult snapshot = fileSnapshot(readId, nodeId, "VoteSites.yml"); + String proposal; + try { + proposal = switch (action == null ? "" : action) { + case "ADD" -> VoteSitesDocument.add(snapshot.configuration().content(), siteKey, fields); + case "EDIT" -> VoteSitesDocument.edit(snapshot.configuration().content(), siteKey, fields); + case "REMOVE" -> { + if (fields != null && !fields.isEmpty()) throw new IllegalArgumentException(); + yield VoteSitesDocument.remove(snapshot.configuration().content(), siteKey); + } + default -> throw new IllegalArgumentException(); + }; + } catch (IllegalArgumentException invalidSiteOperation) { + throw invalid("Vote Site action, key, fields, or source structure is invalid"); + } + if (proposal.equals(snapshot.configuration().content())) { + throw invalid("Vote Site action does not change this target"); + } + ManagedConfiguration configuration = ManagedConfiguration.file("VoteSites.yml", proposal); + ValidatedTargets targets = validateTargets(List.of(nodeId), FILE_CAPABILITY); + validateConfigurationTargets(targets, configuration); + byte[] token = new byte[32]; + random.nextBytes(token); + return create("PREVIEW", targets, configuration, + Base64.getUrlEncoder().withoutPadding().encodeToString(token), Map.of(nodeId, snapshot.revision())); + } + + public record VoteSitesState(UUID readOperationId, String nodeId, UUID sessionId, String revision, + List sites) { } + + public record VoteSiteState(String siteKey, boolean editable, Map fields, + boolean rewardsConfigured) { } + + private static boolean isNamedRewardFile(String fileName) { + return fileName != null && fileName.matches("Rewards/[A-Za-z0-9][A-Za-z0-9_-]{0,99}\\.yml"); + } + + /** Narrow typed reward inventory from the retained READ, never raw YAML or approval material. */ + public synchronized RewardsState rewardsState(UUID readId, String nodeId, String fileName) { + if (fileName == null || !Set.of("VoteSites.yml", "Config.yml", "SpecialRewards.yml").contains(fileName) + && !isNamedRewardFile(fileName)) + throw invalid("Unsupported reward file"); + ConfigurationTaskResult snapshot = fileSnapshot(readId, nodeId, fileName); + try { + return new RewardsState(readId, nodeId, fileName, snapshot.sessionId(), snapshot.revision(), + RewardsDocument.inventory(snapshot.configuration().content(), fileName)); + } catch (IllegalArgumentException malformed) { + throw new ValidationException("INVALID_CONFIGURATION", "Reward inventory requires an unambiguous YAML mapping", List.of(nodeId)); + } + } + + /** Source-preserving one-target reward edit, approved through the existing PREVIEW/APPLY path. */ + public synchronized OperationView createRewardsPreview(UUID readId, String nodeId, String fileName, + String rewardPath, String action, String field, Object value) { + if (!"VoteSites.yml".equals(fileName) && !isNamedRewardFile(fileName)) + throw invalid("This reward scope is advanced-only"); + ConfigurationTaskResult snapshot = fileSnapshot(readId, nodeId, fileName); + String proposal; + try { + proposal = RewardsDocument.patch(snapshot.configuration().content(), fileName, rewardPath, + new RewardsDocument.Edit(action, field, value)); + } catch (IllegalArgumentException unsupported) { + throw invalid("Reward edit is invalid, unsafe, or unsupported for this source structure"); + } + if (proposal.equals(snapshot.configuration().content())) throw invalid("Reward edit does not change this target"); + ManagedConfiguration configuration = ManagedConfiguration.file(fileName, proposal); + ValidatedTargets targets = validateTargets(List.of(nodeId), configuration.capability()); + validateConfigurationTargets(targets, configuration); + byte[] token = new byte[32]; + random.nextBytes(token); + return create("PREVIEW", targets, configuration, + Base64.getUrlEncoder().withoutPadding().encodeToString(token), Map.of(nodeId, snapshot.revision())); + } + + public record RewardsState(UUID readOperationId, String nodeId, String fileName, UUID sessionId, String revision, + List scopes) { } + + public synchronized OperationView discardRewardsPreview(UUID id, String token) { + return discardVisualPreview(id, token, "REWARDS"); + } + + /** Releases abandoned visual approvals without scheduling any work on a node. */ + public synchronized OperationView discardGeneralSettingsPreview(UUID id, String token) { + return discardVisualPreview(id, token, "GENERAL_SETTINGS"); + } + + public synchronized OperationView discardVoteSitesPreview(UUID id, String token) { + return discardVisualPreview(id, token, "VOTE_SITES"); + } + + private OperationView discardVisualPreview(UUID id, String token, String source) { + prune(); + StoredOperation preview = operations.get(id); + if (preview == null || !"PREVIEW".equals(preview.type) || preview.expectedRevisions.isEmpty() + || token == null || token.length() > 128 || preview.approvalToken == null + || !MessageDigest.isEqual(token.getBytes(java.nio.charset.StandardCharsets.UTF_8), + preview.approvalToken.getBytes(java.nio.charset.StandardCharsets.UTF_8))) { + throw new ValidationException("APPROVAL_REQUIRED", "The exact visual preview approval is required", List.of()); + } + // Tokens are intentionally never journaled and recovered previews are always non-applicable. + // Append the audit event before changing the sole in-memory approval bit. + audit.append("PREVIEW_DISCARDED", preview.id, null, source); + preview.approvalUsed = true; + return view(preview); + } + public synchronized OperationView get(UUID id) { prune(); StoredOperation operation = operations.get(id); @@ -183,6 +368,9 @@ public synchronized OperationView retry(UUID id) { throw new ValidationException("OPERATION_INCOMPLETE", "Wait for the operation to finish before retrying", List.of()); } + if ("PREVIEW".equals(original.type) && !original.expectedRevisions.isEmpty()) { + throw new ValidationException("PREVIEW_REQUIRED", "Visual settings require a fresh read and preview", List.of()); + } List failed = original.results.entrySet().stream().filter(entry -> !entry.getValue().success()) .map(Map.Entry::getKey).toList(); if (failed.isEmpty()) throw invalid("operation has no failed nodes"); @@ -574,6 +762,13 @@ private OperationView completeCurrentSession(UUID operationId, String nodeId, Co return view(operation); } validateResultConfiguration(operation, result, node); + if ("PREVIEW".equals(operation.type) && result.success() + && operation.expectedRevisions.containsKey(nodeId) + && !Objects.equals(operation.expectedRevisions.get(nodeId), result.revision())) { + result = new ConfigurationTaskResult(result.sessionId(), false, "STALE_REVISION", + "Configuration changed since READ; read and preview again", null, + (ManagedConfiguration) null, List.of(), false, false, result.attemptId()); + } String priorState = operation.states.get(nodeId); ConfigurationTaskResult priorResult = operation.results.get(nodeId); Instant priorLease = operation.leasedAt.get(nodeId); @@ -608,11 +803,16 @@ private OperationView completeCurrentSession(UUID operationId, String nodeId, Co } private OperationView create(String type, ValidatedTargets targets, ManagedConfiguration config, String token) { + return create(type, targets, config, token, Map.of()); + } + + private OperationView create(String type, ValidatedTargets targets, ManagedConfiguration config, String token, + Map revisions) { LinkedHashMap priorOperations = new LinkedHashMap<>(operations); long priorChanges = retainedChangeBytes; long priorMessages = retainedMessageBytes; long priorFiles = retainedFileBytes; - StoredOperation operation = store(type, targets, config, token, Map.of(), null); + StoredOperation operation = store(type, targets, config, token, revisions, null); try { audit.append("OPERATION_CREATED", operation.id, null, type); persist(); @@ -650,7 +850,11 @@ private void ensureFileCapacity(ManagedConfiguration config, UUID protectedOpera Iterator> iterator = operations.entrySet().iterator(); while (retained >= MAX_FILE_OPERATIONS && iterator.hasNext()) { StoredOperation candidate = iterator.next().getValue(); - if (candidate.fileOperation() && candidate.complete() && !candidate.id.equals(protectedOperation)) { + boolean boundApprovalPending = "PREVIEW".equals(candidate.type) && !candidate.expectedRevisions.isEmpty() + && !candidate.approvalUsed && candidate.createdAt.plus(ACTIVE_RETENTION).isAfter(clock.instant()) + && candidate.results.values().stream().allMatch(ConfigurationTaskResult::success); + if (candidate.fileOperation() && candidate.complete() && !candidate.id.equals(protectedOperation) + && !boundApprovalPending) { audit.append("OPERATION_EVICTED", candidate.id, null, "FILE_RETENTION_LIMIT"); releaseResultDetails(candidate); iterator.remove(); @@ -856,6 +1060,7 @@ private OperationView view(StoredOperation operation, boolean includeRetainedCon private static boolean retryable(StoredOperation operation) { if (operation.recovered || !operation.complete() || operation.results.values().stream().allMatch(ConfigurationTaskResult::success)) return false; + if ("PREVIEW".equals(operation.type) && !operation.expectedRevisions.isEmpty()) return false; return !("APPLY".equals(operation.type) && ManagedConfiguration.QUICK_SETUP.equals(operation.configuration.domain()) && ManagedConfiguration.PROXY_METHOD.equals(operation.configuration.preset())); diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/GeneralSettingsDocument.java b/src/main/java/com/bencodez/votingplugin/control/domain/GeneralSettingsDocument.java new file mode 100644 index 00000000..1e1a096b --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/domain/GeneralSettingsDocument.java @@ -0,0 +1,181 @@ +package com.bencodez.votingplugin.control.domain; + +import java.io.StringReader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; +import org.yaml.snakeyaml.nodes.MappingNode; +import org.yaml.snakeyaml.nodes.Node; +import org.yaml.snakeyaml.nodes.NodeTuple; +import org.yaml.snakeyaml.nodes.ScalarNode; +import org.yaml.snakeyaml.nodes.Tag; + +/** + * Bounded, lossless access to the small allow-list of top-level Config.yml + * general settings. This deliberately edits scalar source spans rather than + * dumping YAML, so comments, redactions, ordering, and unknown settings stay + * byte-for-byte (as Java characters) unchanged. + */ +public final class GeneralSettingsDocument { + private static final int MAX_DOCUMENT_CODE_POINTS = 512 * 1024; + private static final int MAX_NESTING_DEPTH = 40; + private static final int MAX_COLLECTION_ALIASES = 16; + + private static final List KNOWN_KEYS = List.of( + "ProcessRewards", "AutoCreateVoteSites", "ExtraAllSitesCheck", "CountFakeVotes", + "DisableNoServiceSiteMessage", "DisableUpdateChecking", "UseVoteGUIMainCommand", + "CloseInventoryOnVote", "ExtraVoteShopCheck"); + private static final Set KNOWN_KEY_SET = Set.copyOf(KNOWN_KEYS); + + private GeneralSettingsDocument() { + } + + /** Returns the curated fixed settings only; it never exposes document content. */ + public static Map fields(String redactedContent) { + Parsed parsed = parse(redactedContent); + LinkedHashMap result = new LinkedHashMap<>(); + for (String key : KNOWN_KEYS) { + Location location = parsed.locations().get(key); + result.put(key, location == null ? new Field(Status.MISSING, null) + : new Field(location.status(), location.value())); + } + return Collections.unmodifiableMap(result); + } + + /** + * Applies explicitly chosen curated settings. A missing, non-boolean, anchored, + * or aliased setting is never invented or rewritten. + */ + public static String patch(String content, Map overrides) { + validateOverrides(overrides); + Parsed parsed = parse(content); + List replacements = new ArrayList<>(); + for (Map.Entry override : overrides.entrySet()) { + Location location = parsed.locations().get(override.getKey()); + if (location == null || location.status() != Status.AVAILABLE) throw invalidOverrides(); + if (location.value().booleanValue() != override.getValue().booleanValue()) { + replacements.add(new Replacement(location.startOffset(), location.endOffset(), + Boolean.toString(override.getValue()))); + } + } + if (replacements.isEmpty()) return content; + replacements.sort((left, right) -> Integer.compare(right.startOffset(), left.startOffset())); + StringBuilder patched = new StringBuilder(content); + int previousStart = content.length(); + for (Replacement replacement : replacements) { + if (replacement.endOffset() > previousStart || replacement.startOffset() > replacement.endOffset()) { + throw invalidDocument(); + } + patched.replace(replacement.startOffset(), replacement.endOffset(), replacement.value()); + previousStart = replacement.startOffset(); + } + return patched.toString(); + } + + private static Parsed parse(String content) { + if (content == null || content.indexOf('\0') >= 0 + || content.codePointCount(0, content.length()) > MAX_DOCUMENT_CODE_POINTS) { + throw invalidDocument(); + } + try { + LoaderOptions options = new LoaderOptions(); + options.setAllowDuplicateKeys(false); + options.setAllowRecursiveKeys(false); + options.setMaxAliasesForCollections(MAX_COLLECTION_ALIASES); + options.setNestingDepthLimit(MAX_NESTING_DEPTH); + options.setCodePointLimit(MAX_DOCUMENT_CODE_POINTS); + options.setMergeOnCompose(false); + Yaml yaml = new Yaml(new SafeConstructor(options)); + List documents = new ArrayList<>(); + for (Node node : yaml.composeAll(new StringReader(content))) documents.add(node); + if (documents.size() != 1 || !(documents.get(0) instanceof MappingNode root)) throw invalidDocument(); + return new Parsed(locations(content, root)); + } catch (RuntimeException failure) { + throw invalidDocument(); + } + } + + private static Map locations(String content, MappingNode root) { + LinkedHashMap result = new LinkedHashMap<>(); + Set rootKeys = new java.util.HashSet<>(); + for (NodeTuple tuple : root.getValue()) { + if (!(tuple.getKeyNode() instanceof ScalarNode key)) throw invalidDocument(); + String name = key.getValue(); + if (!rootKeys.add(name)) throw invalidDocument(); + if (!KNOWN_KEY_SET.contains(name)) continue; + result.put(name, location(content, tuple.getValueNode())); + } + return result; + } + + private static Location location(String content, Node value) { + if (!(value instanceof ScalarNode scalar) || scalar.getAnchor() != null || !Tag.BOOL.equals(scalar.getTag())) { + return new Location(Status.UNSUPPORTED, null, -1, -1); + } + Boolean booleanValue = yamlBoolean(scalar.getValue()); + if (booleanValue == null) return new Location(Status.UNSUPPORTED, null, -1, -1); + int startCodePoint = scalar.getStartMark().getIndex(); + int endCodePoint = scalar.getEndMark().getIndex(); + int totalCodePoints = content.codePointCount(0, content.length()); + if (startCodePoint < 0 || endCodePoint < startCodePoint || endCodePoint > totalCodePoints) { + throw invalidDocument(); + } + int startOffset = content.offsetByCodePoints(0, startCodePoint); + int endOffset = content.offsetByCodePoints(0, endCodePoint); + return new Location(Status.AVAILABLE, booleanValue, startOffset, endOffset); + } + + private static Boolean yamlBoolean(String value) { + return switch (value.toLowerCase(java.util.Locale.ROOT)) { + case "true", "yes", "on" -> Boolean.TRUE; + case "false", "no", "off" -> Boolean.FALSE; + default -> null; + }; + } + + private static void validateOverrides(Map overrides) { + if (overrides == null || overrides.isEmpty() || overrides.size() > KNOWN_KEYS.size()) { + throw invalidOverrides(); + } + for (Map.Entry override : overrides.entrySet()) { + if (override.getKey() == null || override.getValue() == null + || !KNOWN_KEY_SET.contains(override.getKey())) throw invalidOverrides(); + } + } + + private static IllegalArgumentException invalidDocument() { + return new IllegalArgumentException("general settings document is invalid"); + } + + private static IllegalArgumentException invalidOverrides() { + return new IllegalArgumentException("general settings overrides are invalid"); + } + + public enum Status { + AVAILABLE, MISSING, UNSUPPORTED + } + + public record Field(Status status, Boolean value) { + public Field { + if (status == null || (status == Status.AVAILABLE) != (value != null)) { + throw new IllegalArgumentException("field is invalid"); + } + } + } + + private record Parsed(Map locations) { + } + + private record Location(Status status, Boolean value, int startOffset, int endOffset) { + } + + private record Replacement(int startOffset, int endOffset, String value) { + } +} 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 914d2c88..acc9487f 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -3,6 +3,7 @@ import com.bencodez.votingplugin.control.protocol.BackendServerIdentity; import com.bencodez.votingplugin.control.protocol.Heartbeat; import com.bencodez.votingplugin.control.protocol.InspectionQuery; +import com.bencodez.votingplugin.control.protocol.ManagedConfiguration; import com.bencodez.votingplugin.control.protocol.NodeRegistration; import com.bencodez.votingplugin.control.protocol.NodeStatus; import com.bencodez.votingplugin.control.protocol.PresenceSnapshot; @@ -33,7 +34,7 @@ public final class InMemoryNodeRegistry implements NodeRegistry { ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY, ConfigurationOperations.TRANSPORT_TEST_CAPABILITY, ConfigurationOperations.PROXY_METHOD_CAPABILITY, ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY, DeploymentOperations.CAPABILITY, - "config.file-comments.v1", InspectionQuery.CAPABILITY); + "config.file-comments.v1", ManagedConfiguration.REWARD_FILE_CAPABILITY, InspectionQuery.CAPABILITY); private static final Pattern ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); private static final Pattern CAPABILITY = Pattern.compile("[a-z][a-z0-9.-]{0,63}"); private static final Set PLATFORMS = Set.of("BUNGEECORD", "VELOCITY", "BUKKIT"); diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java index 1077f036..163e48f0 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java @@ -28,6 +28,7 @@ public final class InspectionOperations { 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 REWARD_FILE_NAME = Pattern.compile("[A-Za-z0-9][A-Za-z0-9_-]{0,99}\\.yml"); 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}"); @@ -70,7 +71,8 @@ public synchronized InspectionView create(String nodeId, InspectionQuery query) if (nodeId == null || !NODE_ID.matcher(nodeId).matches()) throw invalid("nodeId is invalid"); NodeStatus node = registry.find(nodeId); if (node == null) throw new ValidationException("NODE_NOT_FOUND", "Node was not found", List.of(nodeId)); - if (!node.online() || !node.acceptedCapabilities().contains(InspectionQuery.CAPABILITY)) { + if (!node.online() || !node.acceptedCapabilities().contains(InspectionQuery.CAPABILITY) + || query.requiresRewardFiles() && !node.acceptedCapabilities().contains(InspectionQuery.REWARD_FILE_CAPABILITY)) { throw new ValidationException("NODE_UNAVAILABLE", "Node cannot answer inspection queries", List.of(nodeId)); } evictOldestCompletedAtCapacity(); @@ -110,7 +112,9 @@ private InspectionTask claimCurrentSession(String nodeId, NodeStatus node) { Instant now = clock.instant(); for (StoredInspection stored : inspections.values()) { if (!stored.nodeId.equals(nodeId) || "COMPLETE".equals(stored.state)) continue; - if (!node.online() || !node.acceptedCapabilities().contains(InspectionQuery.CAPABILITY)) { + if (!node.online() || !node.acceptedCapabilities().contains(InspectionQuery.CAPABILITY) + || stored.query.requiresRewardFiles() + && !node.acceptedCapabilities().contains(InspectionQuery.REWARD_FILE_CAPABILITY)) { completeUnavailable(stored, node.sessionId()); continue; } @@ -237,6 +241,10 @@ private static void validateResult(InspectionTaskResult result, String expectedK if ("player".equals(expectedKind) && !validPlayerResult(result.data().path("result"))) { throw invalid("player inspection data is invalid"); } + if ("reward-file-inventory".equals(expectedKind) + && !validRewardFileInventory(result.data().path("result"))) { + throw invalid("named reward file inventory is invalid"); + } } if (jsonBytes(result.data()) > MAX_DATA_BYTES || bytes(result.message()) > MAX_MESSAGE_BYTES) { throw invalid("inspection result exceeds retention limits"); @@ -247,6 +255,17 @@ private static int jsonBytes(com.fasterxml.jackson.databind.JsonNode value) { return value == null ? 0 : value.toString().getBytes(StandardCharsets.UTF_8).length; } + private static boolean validRewardFileInventory(JsonNode value) { + if (!exactFields(value, Set.of("files")) || !value.path("files").isArray() + || value.path("files").size() > 100) return false; + Set names = new HashSet<>(); + for (JsonNode file : value.path("files")) { + if (!file.isTextual() || !REWARD_FILE_NAME.matcher(file.textValue()).matches() + || !names.add(file.textValue().toLowerCase(java.util.Locale.ROOT))) return false; + } + return true; + } + /** * 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. diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/RewardsDocument.java b/src/main/java/com/bencodez/votingplugin/control/domain/RewardsDocument.java new file mode 100644 index 00000000..34e110ca --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/domain/RewardsDocument.java @@ -0,0 +1,496 @@ +package com.bencodez.votingplugin.control.domain; + +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; +import org.yaml.snakeyaml.nodes.AnchorNode; +import org.yaml.snakeyaml.nodes.MappingNode; +import org.yaml.snakeyaml.nodes.Node; +import org.yaml.snakeyaml.nodes.NodeTuple; +import org.yaml.snakeyaml.nodes.ScalarNode; +import org.yaml.snakeyaml.nodes.SequenceNode; +import org.yaml.snakeyaml.nodes.Tag; + +/** Bounded, source-span based reward inspection and editing; never serializes a reward subtree. */ +public final class RewardsDocument { + private static final int MAX_SOURCE = 512 * 1024; + private static final int MAX_REWARDS = 200; + private static final int MAX_LIST = 100; + private static final int MAX_STRUCTURE_KEY_BYTES = 128; + private static final int MAX_STRUCTURE_PATH_BYTES = 512; + private static final int MAX_STRUCTURE_TOTAL_BYTES = 16 * 1024; + private static final Pattern SITE_KEY = Pattern.compile("[A-Za-z0-9_-]{1,64}"); + private static final Pattern ITEM_KEY = Pattern.compile("[A-Za-z0-9_-]{1,64}"); + private static final Pattern MATERIAL = Pattern.compile("[A-Z0-9_]{1,80}"); + private static final Set EDITABLE_FIELDS = Set.of("Commands", + "Messages.Player", "Messages.Broadcast", "Money", "Chance"); + + private RewardsDocument() { } + + public static List inventory(String content, String fileName) { + MappingNode root = parse(content); + List result = new ArrayList<>(); + if (namedRewardFile(fileName)) { + addScope(result, "$", root, true); + } else if ("VoteSites.yml".equals(fileName)) { + Node sites = child(root, "VoteSites"); + if (sites instanceof MappingNode mapping) { + for (NodeTuple tuple : mapping.getValue()) { + String key = key(tuple.getKeyNode()); + if (!(tuple.getValueNode() instanceof MappingNode site)) continue; + String rewardPath = "VoteSites." + key + ".Rewards"; + Node reward = child(site, "Rewards"); + if (reward == null && caseFoldedKey(site, "Rewards")) { + if (result.size() >= MAX_REWARDS) throw invalid(); + result.add(new Scope(rewardPath, "UNSUPPORTED", false, Map.of(), List.of(), List.of())); + } else addScope(result, rewardPath, reward, SITE_KEY.matcher(key).matches()); + for (String extra : List.of("WaitUntilVoteDelayRewards", "CoolDownEndRewards")) { + if (child(site, extra) != null) addScope(result, "VoteSites." + key + "." + extra, child(site, extra), false); + } + } + } + addScope(result, "EverySiteReward", child(root, "EverySiteReward"), false); + } else if ("SpecialRewards.yml".equals(fileName) || "Config.yml".equals(fileName)) { + walkRewards(result, root, "", 0, new StructureBudget()); + if ("SpecialRewards.yml".equals(fileName) && child(root, "AnySiteRewards") == null) + addScope(result, "AnySiteRewards", null, false); + } else throw invalid(); + return List.copyOf(result); + } + + /** One explicitly selected operation against one retained target's own document. */ + public static String patch(String content, String fileName, String rewardPath, Edit edit) { + if (edit == null || edit.operation() == null || rewardPath == null) throw invalid(); + MappingNode root = parse(content); + if (namedRewardFile(fileName)) { + if (!"$".equals(rewardPath) || "CREATE_REWARD".equals(edit.operation()) + || "REMOVE_REWARD".equals(edit.operation()) || !block(root)) throw invalid(); + return patchFields(content, root, null, edit); + } + if (!"VoteSites.yml".equals(fileName)) throw invalid(); + String[] parts = rewardPath.split("\\.", -1); + if (parts.length != 3 || !"VoteSites".equals(parts[0]) || !"Rewards".equals(parts[2]) + || !SITE_KEY.matcher(parts[1]).matches()) throw invalid(); + Node sites = child(root, "VoteSites"); + Node site = sites instanceof MappingNode mapping ? child(mapping, parts[1]) : null; + if (!(site instanceof MappingNode siteMap) || !block(siteMap)) throw invalid(); + NodeTuple rewardTuple = tuple(siteMap, "Rewards"); + if ("CREATE_REWARD".equals(edit.operation())) { + if (caseFoldedKey(siteMap, "Rewards") || !"Commands".equals(edit.field()) + || !(edit.value() instanceof String command)) throw invalid(); + validateText(command); + int at = blockEnd(content, afterLine(content, offset(content, siteMap.getStartMark().getIndex())), siteMap.getStartMark().getColumn()); + int indent = childIndent(siteMap, siteMap.getStartMark().getColumn() + 2); + return insert(content, at, spaces(indent) + "Rewards:\n" + spaces(indent + 2) + "Commands:\n" + + spaces(indent + 2) + "- " + quote(command) + "\n"); + } + if (rewardTuple == null || !(rewardTuple.getValueNode() instanceof MappingNode reward) || !block(reward)) throw invalid(); + if ("REMOVE_REWARD".equals(edit.operation())) { + if (edit.field() != null || edit.value() != null) throw invalid(); + int start = lineStart(content, offset(content, rewardTuple.getKeyNode().getStartMark().getIndex())); + int boundary = blockEnd(content, afterLine(content, start), rewardTuple.getKeyNode().getStartMark().getColumn()); + int end = meaningfulBlockEnd(content, start, boundary); + return content.substring(0, start) + content.substring(end); + } + return patchFields(content, reward, rewardTuple, edit); + } + + private static String patchFields(String content, MappingNode reward, NodeTuple rewardTuple, Edit edit) { + if (edit.field() == null || (!EDITABLE_FIELDS.contains(edit.field()) && !itemField(edit.field()))) throw invalid(); + if ("SET_SCALAR".equals(edit.operation()) && "Commands".equals(edit.field())) throw invalid(); + if (Set.of("APPEND_LIST_ENTRY", "REMOVE_LIST_ENTRY", "REPLACE_LIST").contains(edit.operation()) + && !"Commands".equals(edit.field())) throw invalid(); + NodeTuple field = findTuple(reward, edit.field()); + if (field == null) { + if (!"SET_SCALAR".equals(edit.operation()) && !"APPEND_LIST_ENTRY".equals(edit.operation())) throw invalid(); + return addMissingField(content, reward, rewardTuple, edit); + } + Node current = field.getValueNode(); + if ("SET_SCALAR".equals(edit.operation())) { + String value = scalarValue(edit.field(), edit.value()); + if (!(current instanceof ScalarNode scalar) || Tag.NULL.equals(scalar.getTag())) throw invalid(); + if (Set.of("Money", "Chance").contains(edit.field()) + && !Tag.INT.equals(scalar.getTag()) && !Tag.FLOAT.equals(scalar.getTag())) throw invalid(); + if (Set.of("Messages.Player", "Messages.Broadcast").contains(edit.field()) + && !Tag.STR.equals(scalar.getTag())) throw invalid(); + if (edit.field().endsWith(".Material") && !Tag.STR.equals(scalar.getTag())) throw invalid(); + if (edit.field().endsWith(".Amount") && !Tag.INT.equals(scalar.getTag())) throw invalid(); + int start = offset(content, current.getStartMark().getIndex()); + int end = offset(content, current.getEndMark().getIndex()); + if (start < 0 || end < start || end > content.length() || content.substring(start, end).contains("\n")) throw invalid(); + return content.substring(0, start) + value + content.substring(end); + } + if (current instanceof SequenceNode empty && empty.getValue().isEmpty()) { + return patchEmptyCommands(content, field, empty, edit); + } + if (!(current instanceof SequenceNode sequence) || sequence.getFlowStyle() != DumperOptions.FlowStyle.BLOCK + || sequence.getValue().isEmpty() || sequence.getValue().size() > MAX_LIST) throw invalid(); + for (Node node : sequence.getValue()) if (!simpleCommandScalar(node)) throw invalid(); + if ("REPLACE_LIST".equals(edit.operation())) { + if (!(edit.value() instanceof List requested) || requested.size() > MAX_LIST) throw invalid(); + List replacement = new ArrayList<>(); + for (Object value : requested) { + if (!(value instanceof String line)) throw invalid(); + validateText(line); replacement.add(line); + } + int start = lineStart(content, offset(content, field.getKeyNode().getStartMark().getIndex())); + int end = afterLine(content, offset(content, sequence.getValue().get(sequence.getValue().size() - 1).getEndMark().getIndex())); + if (containsCommentOutsideScalars(content, start, end, sequence.getValue())) throw invalid(); + int keyIndent = field.getKeyNode().getStartMark().getColumn(); + int itemIndent = lineIndent(content, offset(content, sequence.getValue().get(0).getStartMark().getIndex())); + StringBuilder text = new StringBuilder(spaces(keyIndent)).append(edit.field()).append(replacement.isEmpty() ? ": []\n" : ":\n"); + replacement.forEach(line -> text.append(spaces(itemIndent)).append("- ").append(quote(line)).append('\n')); + return content.substring(0, start) + text + content.substring(end); + } + if (!(edit.value() instanceof String entry)) throw invalid(); + validateText(entry); + if ("APPEND_LIST_ENTRY".equals(edit.operation())) { + if (sequence.getValue().size() >= MAX_LIST) throw invalid(); + int at = sequence.getValue().isEmpty() + ? afterLine(content, offset(content, field.getKeyNode().getStartMark().getIndex())) + : afterLine(content, offset(content, sequence.getValue().get(sequence.getValue().size() - 1).getEndMark().getIndex())); + int indent = sequence.getValue().isEmpty() ? field.getKeyNode().getStartMark().getColumn() : lineIndent(content, offset(content, sequence.getValue().get(0).getStartMark().getIndex())); + return insert(content, at, spaces(indent) + "- " + quote(entry) + "\n"); + } + if ("REMOVE_LIST_ENTRY".equals(edit.operation())) { + List matches = sequence.getValue().stream().filter(node -> entry.equals(((ScalarNode) node).getValue())).toList(); + if (matches.size() != 1) throw invalid(); // duplicates need an explicit index, never guess + Node selected = matches.get(0); + int start = lineStart(content, offset(content, selected.getStartMark().getIndex())); + int end = afterLine(content, start); + if (!content.substring(start, end).stripLeading().startsWith("-")) throw invalid(); + // The simple operation removes one physical line. An inline note after + // the scalar is not part of the command value and must not be lost. + if (content.substring(offset(content, selected.getEndMark().getIndex()), end).contains("#")) throw invalid(); + if (sequence.getValue().size() == 1) { + int keyStart = lineStart(content, offset(content, field.getKeyNode().getStartMark().getIndex())); + if (content.substring(keyStart, end).contains("#")) throw invalid(); + return content.substring(0, keyStart) + spaces(field.getKeyNode().getStartMark().getColumn()) + + edit.field() + ": []\n" + content.substring(end); + } + return content.substring(0, start) + content.substring(end); + } + throw invalid(); + } + + private static String patchEmptyCommands(String content, NodeTuple field, SequenceNode sequence, Edit edit) { + if (!"Commands".equals(edit.field()) || sequence.getFlowStyle() != DumperOptions.FlowStyle.FLOW) throw invalid(); + int start = offset(content, sequence.getStartMark().getIndex()); + int end = offset(content, sequence.getEndMark().getIndex()); + int lineEnd = afterLine(content, end); + String suffix = content.substring(end, lineEnd); + if (!content.substring(start, end).matches("\\[[ \\t]*\\]") + || !suffix.isBlank() && !suffix.stripLeading().startsWith("#")) throw invalid(); + List replacement = new ArrayList<>(); + if ("APPEND_LIST_ENTRY".equals(edit.operation())) { + if (!(edit.value() instanceof String entry)) throw invalid(); + validateText(entry); + replacement.add(entry); + } else if ("REPLACE_LIST".equals(edit.operation())) { + if (!(edit.value() instanceof List requested) || requested.size() > MAX_LIST) throw invalid(); + for (Object value : requested) { + if (!(value instanceof String entry)) throw invalid(); + validateText(entry); + replacement.add(entry); + } + } else throw invalid(); + if (replacement.isEmpty()) return content; + int itemIndent = field.getKeyNode().getStartMark().getColumn(); + // Keep a note attached to the Commands key, before the new block list. + StringBuilder block = new StringBuilder(suffix); + if (!suffix.endsWith("\n")) block.append('\n'); + for (String entry : replacement) block.append(spaces(itemIndent)).append("- ").append(quote(entry)).append('\n'); + return content.substring(0, start) + block + content.substring(lineEnd); + } + + private static String addMissingField(String content, MappingNode reward, NodeTuple rewardTuple, Edit edit) { + String field = edit.field(); + if (field.contains(".")) throw invalid(); // never synthesize a possibly conflicting parent map + if (caseFoldedKey(reward, field)) throw invalid(); + String value; + if ("SET_SCALAR".equals(edit.operation())) value = scalarValue(field, edit.value()); + else { + if (!(edit.value() instanceof String text)) throw invalid(); + validateText(text); + value = quote(text); + } + int indent = rewardTuple == null ? 0 : childIndent(reward, rewardTuple.getKeyNode().getStartMark().getColumn() + 2); + int at = rewardTuple == null ? offset(content, reward.getEndMark().getIndex()) : blockEnd(content, + afterLine(content, offset(content, rewardTuple.getKeyNode().getStartMark().getIndex())), + rewardTuple.getKeyNode().getStartMark().getColumn()); + String line = spaces(indent) + field + ":" + ("SET_SCALAR".equals(edit.operation()) ? " " + value + "\n" : "\n" + spaces(indent) + "- " + value + "\n"); + return insert(content, at, rewardTuple == null && !content.isEmpty() && !content.endsWith("\n") ? "\n" + line : line); + } + + private static boolean namedRewardFile(String fileName) { + return fileName != null && fileName.matches("Rewards/[A-Za-z0-9][A-Za-z0-9_-]{0,99}\\.yml"); + } + + private static void walkRewards(List result, MappingNode mapping, String prefix, int depth, + StructureBudget budget) { + if (depth > 12 || result.size() >= MAX_REWARDS) throw invalid(); + for (NodeTuple tuple : mapping.getValue()) { + String name = boundedStructureKey(key(tuple.getKeyNode())); + String path = prefix.isEmpty() ? name : prefix + "." + name; + if (Set.of("Rewards", "EverySiteReward", "AnySiteRewards", "LostRewards").contains(name)) { + if (utf8Bytes(path) > MAX_STRUCTURE_PATH_BYTES) throw invalid(); + budget.add(path); + addScope(result, path, tuple.getValueNode(), false); + } + if (tuple.getValueNode() instanceof MappingNode child && block(child)) + walkRewards(result, child, path, depth + 1, budget); + } + } + + private static void addScope(List result, String path, Node node, boolean editable) { + if (result.size() >= MAX_REWARDS) throw invalid(); + if (node == null) { + result.add(new Scope(path, "MISSING", editable, Map.of(), List.of(), List.of())); return; + } + if (!(node instanceof MappingNode reward)) { + result.add(new Scope(path, "UNSUPPORTED", false, Map.of(), List.of(), List.of())); return; + } + Map fields = new LinkedHashMap<>(); + List advanced = new ArrayList<>(); + for (NodeTuple tuple : reward.getValue()) { + String name = key(tuple.getKeyNode()); + Node value = tuple.getValueNode(); + if ("Commands".equals(name) && value instanceof SequenceNode list && stringList(list) != null) fields.put(name, stringList(list)); + else if ("Messages".equals(name) && value instanceof MappingNode messages && block(messages)) { + for (NodeTuple message : messages.getValue()) { + String label = key(message.getKeyNode()); + if (Set.of("Player", "Broadcast").contains(label) && message.getValueNode() instanceof ScalarNode scalar + && Tag.STR.equals(scalar.getTag()) && oneSourceLine(scalar)) fields.put("Messages." + label, scalar.getValue()); + else advanced.add("Messages." + label); + } + } else if (Set.of("Money", "Chance").contains(name) && value instanceof ScalarNode scalar + && (Tag.INT.equals(scalar.getTag()) || Tag.FLOAT.equals(scalar.getTag())) && oneSourceLine(scalar)) + fields.put(name, scalar.getValue()); + else if ("Items".equals(name) && value instanceof MappingNode items && block(items)) { + addItemFields(fields, items); + advanced.add(name); // metadata and unsupported item shapes remain Advanced-only + } + else { + advanced.add(name); + for (String canonical : List.of("Commands", "Messages", "Money", "Chance", "Items")) + if (!canonical.equals(name) && canonical.equalsIgnoreCase(name)) advanced.add(canonical); + } + } + List tree = new ArrayList<>(); + structure(reward, "", 0, tree, new StructureBudget()); + result.add(new Scope(path, reward.getValue().isEmpty() ? "EMPTY" : "PRESENT", editable && block(reward), + Map.copyOf(fields), List.copyOf(advanced), List.copyOf(tree))); + } + + private static void structure(Node node, String prefix, int depth, List result, StructureBudget budget) { + if (depth > 8 || result.size() > 120) throw invalid(); + if (!(node instanceof MappingNode mapping)) return; + for (NodeTuple tuple : mapping.getValue()) { + String name = boundedStructureKey(key(tuple.getKeyNode())); + String path = prefix.isEmpty() ? name : prefix + "." + name; + if (utf8Bytes(path) > MAX_STRUCTURE_PATH_BYTES) throw invalid(); + budget.add(path); + result.add(tuple.getValueNode() instanceof SequenceNode ? path + "[]" : path); + if (result.size() > 120) throw invalid(); + if (tuple.getValueNode() instanceof MappingNode child) structure(child, path, depth + 1, result, budget); + } + } + + private static String boundedStructureKey(String value) { + if (utf8Bytes(value) > MAX_STRUCTURE_KEY_BYTES) throw invalid(); + return value; + } + + private static int utf8Bytes(String value) { return value.getBytes(StandardCharsets.UTF_8).length; } + + private static final class StructureBudget { + private int bytes; + + void add(String path) { + bytes += utf8Bytes(path); + if (bytes > MAX_STRUCTURE_TOTAL_BYTES) throw invalid(); + } + } + + private static List stringList(SequenceNode node) { + if (node.getValue().size() > MAX_LIST || (node.getFlowStyle() != DumperOptions.FlowStyle.BLOCK + && !node.getValue().isEmpty())) return null; + List result = new ArrayList<>(); + for (Node child : node.getValue()) { + if (!simpleCommandScalar(child)) return null; + ScalarNode scalar = (ScalarNode) child; + result.add(scalar.getValue()); + } + return result; + } + + private static boolean simpleCommandScalar(Node node) { + return node instanceof ScalarNode scalar && Tag.STR.equals(scalar.getTag()) + && oneSourceLine(scalar); + } + + private static boolean oneSourceLine(ScalarNode scalar) { + return scalar.getStartMark().getLine() == scalar.getEndMark().getLine(); + } + + /** + * Exposes only stable item leaves. Metadata remains out of the typed browser state and a + * source-span replacement changes only the selected scalar, leaving it intact. + */ + private static void addItemFields(Map fields, MappingNode items) { + if (items.getValue().size() > 20) return; + for (NodeTuple item : items.getValue()) { + String itemKey = key(item.getKeyNode()); + if (!ITEM_KEY.matcher(itemKey).matches() || !(item.getValueNode() instanceof MappingNode itemMap) || !block(itemMap)) continue; + Node material = child(itemMap, "Material"); + if (material instanceof ScalarNode scalar && Tag.STR.equals(scalar.getTag()) && oneSourceLine(scalar)) + fields.put("Items." + itemKey + ".Material", scalar.getValue()); + Node amount = child(itemMap, "Amount"); + if (amount instanceof ScalarNode scalar && Tag.INT.equals(scalar.getTag()) && oneSourceLine(scalar)) + fields.put("Items." + itemKey + ".Amount", scalar.getValue()); + } + } + + private static MappingNode parse(String content) { + if (content == null || content.length() > MAX_SOURCE || content.indexOf('\0') >= 0) throw invalid(); + LoaderOptions options = new LoaderOptions(); options.setAllowDuplicateKeys(false); options.setAllowRecursiveKeys(false); + options.setMaxAliasesForCollections(0); options.setNestingDepthLimit(40); options.setCodePointLimit(MAX_SOURCE); + List docs = new ArrayList<>(); + try { + for (Node node : new Yaml(new SafeConstructor(options)).composeAll(new StringReader(content))) docs.add(node); + } catch (RuntimeException error) { throw invalid(); } + if (docs.size() != 1 || !(docs.get(0) instanceof MappingNode root) || !block(root)) throw invalid(); + rejectAmbiguity(root); + return root; + } + + private static void rejectAmbiguity(Node node) { + if (node.getAnchor() != null || node instanceof AnchorNode) throw invalid(); + if (node instanceof MappingNode mapping) { + if (mapping.isMerged()) throw invalid(); + Set seen = new java.util.HashSet<>(); + for (NodeTuple tuple : mapping.getValue()) { + if (!seen.add(key(tuple.getKeyNode()).toLowerCase(java.util.Locale.ROOT))) throw invalid(); + rejectAmbiguity(tuple.getValueNode()); + } + } else if (node instanceof SequenceNode sequence) for (Node child : sequence.getValue()) rejectAmbiguity(child); + } + + private static Node child(MappingNode mapping, String name) { NodeTuple tuple = tuple(mapping, name); return tuple == null ? null : tuple.getValueNode(); } + private static boolean caseFoldedKey(MappingNode mapping, String name) { + String folded = name.toLowerCase(java.util.Locale.ROOT); + for (NodeTuple tuple : mapping.getValue()) + if (folded.equals(key(tuple.getKeyNode()).toLowerCase(java.util.Locale.ROOT))) return true; + return false; + } + private static NodeTuple tuple(MappingNode mapping, String name) { + for (NodeTuple tuple : mapping.getValue()) if (name.equals(key(tuple.getKeyNode()))) return tuple; + return null; + } + private static NodeTuple findTuple(MappingNode mapping, String field) { + String[] parts = field.split("\\."); MappingNode parent = mapping; NodeTuple item = null; + for (int i = 0; i < parts.length; i++) { + item = tuple(parent, parts[i]); + if (item == null) return null; + if (i < parts.length - 1) { + if (!(item.getValueNode() instanceof MappingNode next) || !block(next)) throw invalid(); + parent = next; + } + } + return item; + } + private static String key(Node node) { if (!(node instanceof ScalarNode scalar) || scalar.getAnchor() != null) throw invalid(); return scalar.getValue(); } + private static boolean block(MappingNode node) { return node.getFlowStyle() == DumperOptions.FlowStyle.BLOCK; } + private static int childIndent(MappingNode node, int fallback) { return node.getValue().isEmpty() ? fallback : node.getValue().get(0).getKeyNode().getStartMark().getColumn(); } + private static int offset(String source, int codePoints) { + if (codePoints < 0 || codePoints > source.codePointCount(0, source.length())) throw invalid(); + return source.offsetByCodePoints(0, codePoints); + } + private static int lineStart(String source, int at) { int pos = Math.min(at, source.length()); while (pos > 0 && source.charAt(pos - 1) != '\n') pos--; return pos; } + private static int afterLine(String source, int at) { int end = source.indexOf('\n', lineStart(source, at)); return end < 0 ? source.length() : end + 1; } + private static int lineIndent(String source, int at) { int pos = lineStart(source, at); while (pos < source.length() && source.charAt(pos) == ' ') pos++; return pos - lineStart(source, at); } + /** Replacing a list may discard comments, but hashes inside quoted command values are data. */ + private static boolean containsCommentOutsideScalars(String source, int start, int end, List scalars) { + for (int hash = source.indexOf('#', start); hash >= 0 && hash < end; hash = source.indexOf('#', hash + 1)) { + boolean scalarValue = false; + for (Node scalar : scalars) { + int scalarStart = offset(source, scalar.getStartMark().getIndex()); + int scalarEnd = offset(source, scalar.getEndMark().getIndex()); + if (hash >= scalarStart && hash < scalarEnd) { scalarValue = true; break; } + } + if (!scalarValue) return true; + } + return false; + } + private static int blockEnd(String source, int at, int parentIndent) { + while (at < source.length()) { + int end = afterLine(source, at); int cursor = at; + while (cursor < end && source.charAt(cursor) == ' ') cursor++; + if (cursor < end && source.charAt(cursor) == '\t') throw invalid(); + if (cursor < end && source.charAt(cursor) != '#' && source.charAt(cursor) != '\n' && source.charAt(cursor) != '\r' + && cursor - at <= parentIndent) return at; + at = end; + } + return source.length(); + } + private static int meaningfulBlockEnd(String source, int start, int boundary) { + int cursor = start; + int last = start; + while (cursor < boundary) { + int end = afterLine(source, cursor); + if (end > boundary) end = boundary; + int first = cursor; + while (first < end && source.charAt(first) == ' ') first++; + if (first < end && source.charAt(first) != '#' && source.charAt(first) != '\n' + && source.charAt(first) != '\r') last = end; + cursor = end; + } + return last; + } + private static String insert(String source, int at, String text) { return source.substring(0, at) + (at > 0 && source.charAt(at - 1) != '\n' ? "\n" : "") + text + source.substring(at); } + private static String spaces(int size) { return " ".repeat(Math.max(0, size)); } + private static void validateText(String value) { + if (value == null || value.isBlank() || value.length() > 500) throw invalid(); + for (int i = 0; i < value.length(); i++) { + char character = value.charAt(i); + if (character < 0x20 && character != '\n' && character != '\r' && character != '\t') throw invalid(); + } + } + private static String scalarValue(String field, Object value) { + if (Set.of("Money", "Chance").contains(field)) { + if (!(value instanceof Number number) || !Double.isFinite(number.doubleValue()) || number.doubleValue() < 0) throw invalid(); + return number.toString(); + } + if (field.endsWith(".Amount")) { + if (!(value instanceof Number number) || !Double.isFinite(number.doubleValue()) + || number.doubleValue() != Math.rint(number.doubleValue()) || number.doubleValue() < 1 || number.doubleValue() > 64) throw invalid(); + return Long.toString(number.longValue()); + } + if (field.endsWith(".Material")) { + if (!(value instanceof String material) || !MATERIAL.matcher(material).matches()) throw invalid(); + return quote(material); + } + if (!(value instanceof String text)) throw invalid(); validateText(text); return quote(text); + } + private static boolean itemField(String field) { + String[] parts = field.split("\\.", -1); + return parts.length == 3 && "Items".equals(parts[0]) && ITEM_KEY.matcher(parts[1]).matches() + && Set.of("Material", "Amount").contains(parts[2]); + } + private static String quote(String text) { return "\"" + text.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") + "\""; } + private static IllegalArgumentException invalid() { return new IllegalArgumentException("Reward structure or edit is unsupported or ambiguous"); } + + public record Scope(String path, String status, boolean editable, Map fields, + List advancedKeys, List structurePaths) { } + public record Edit(String operation, String field, Object value) { } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/VoteSitesDocument.java b/src/main/java/com/bencodez/votingplugin/control/domain/VoteSitesDocument.java new file mode 100644 index 00000000..1a9dcaeb --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/domain/VoteSitesDocument.java @@ -0,0 +1,546 @@ +package com.bencodez.votingplugin.control.domain; + +import java.io.StringReader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; +import org.yaml.snakeyaml.nodes.AnchorNode; +import org.yaml.snakeyaml.nodes.MappingNode; +import org.yaml.snakeyaml.nodes.Node; +import org.yaml.snakeyaml.nodes.NodeTuple; +import org.yaml.snakeyaml.nodes.ScalarNode; +import org.yaml.snakeyaml.nodes.SequenceNode; +import org.yaml.snakeyaml.nodes.Tag; + +/** + * A deliberately small, source-span-preserving view of the current main + * {@code VoteSites.yml}. It is not a general YAML editor: unknown keys (and + * Rewards in particular) are neither returned nor dumped back through YAML. + */ +public final class VoteSitesDocument { + private static final int MAX_DOCUMENT_CODE_POINTS = 512 * 1024; + private static final int MAX_NESTING_DEPTH = 40; + private static final int MAX_COLLECTION_ALIASES = 16; + private static final int MAX_SITES = 200; + private static final int MAX_SITE_KEY_CODE_POINTS = 256; + private static final Pattern SAFE_KEY = Pattern.compile("[A-Za-z0-9_-]{1,64}"); + private static final Pattern MATERIAL = Pattern.compile("[A-Za-z0-9_:-]{1,100}"); + private static final List FIELDS = List.of("Enabled", "Name", "ServiceSite", "VoteURL", "VoteDelay", + "Priority", "Hidden", "DisplayItem.Material", "DisplayItem.Amount"); + private static final Set FIELD_SET = Set.copyOf(FIELDS); + + private VoteSitesDocument() { + } + + /** Returns only bounded site metadata and the nine allow-listed fields. */ + public static Inventory inventory(String content) { + Parsed parsed = parse(content); + List sites = new ArrayList<>(); + for (SiteInfo site : parsed.sites()) { + LinkedHashMap fields = new LinkedHashMap<>(); + for (String name : FIELDS) fields.put(name, readField(site, name)); + sites.add(new Site(site.key(), safeKey(site.key()), Collections.unmodifiableMap(fields), site.rewardsConfigured())); + } + return new Inventory(Collections.unmodifiableList(sites)); + } + + /** Adds one safe, absent site with all nine typed fields. */ + public static String add(String content, String siteKey, Map fields) { + Parsed parsed = parse(content); + if (!safeKey(siteKey) || parsed.sites().stream().anyMatch(site -> site.key().equalsIgnoreCase(siteKey)) + || parsed.sites().size() >= MAX_SITES) { + throw invalidOperation(); + } + Map checked = validateFields(fields, true); + int indent = parsed.siteIndent(); + int at = blockEnd(content, afterLine(content, parsed.voteSitesKeyStart()), parsed.voteSitesIndent()); + String insertion = insertionPrefix(content, at) + spaces(indent) + yamlQuote(siteKey) + ":\n" + + renderFields(checked, indent + 2); + return content.substring(0, at) + insertion + content.substring(at); + } + + /** Applies one or more typed allow-listed fields to exactly one safe site. */ + public static String edit(String content, String siteKey, Map overrides) { + Parsed parsed = parse(content); + SiteInfo site = parsed.byKey().get(siteKey); + if (!safeKey(siteKey) || site == null) throw invalidOperation(); + Map checked = validateFields(overrides, false); + List replacements = new ArrayList<>(); + LinkedHashMap missingDirect = new LinkedHashMap<>(); + LinkedHashMap missingDisplay = new LinkedHashMap<>(); + for (Map.Entry override : checked.entrySet()) { + FieldLocation location = fieldLocation(content, site, override.getKey()); + if (location == null) { + (override.getKey().startsWith("DisplayItem.") ? missingDisplay : missingDirect) + .put(override.getKey(), override.getValue()); + } else { + if (location.status() != Status.AVAILABLE) throw invalidOperation(); + String value = renderValue(override.getKey(), override.getValue()); + if (!location.value().equals(override.getValue())) { + replacements.add(new Replacement(location.startOffset(), location.endOffset(), value)); + } + } + } + if (!missingDirect.isEmpty()) { + int at = blockEnd(content, afterLine(content, site.keyStartOffset()), site.keyIndent()); + String inserted = insertionPrefix(content, at) + renderProperties(missingDirect, site.propertyIndent()); + if (!missingDisplay.isEmpty() && site.properties().get("DisplayItem") == null) { + inserted += spaces(site.propertyIndent()) + "DisplayItem:\n" + + renderDisplayProperties(missingDisplay, site.propertyIndent() + 2); + missingDisplay.clear(); + } + replacements.add(new Replacement(at, at, inserted)); + } + if (!missingDisplay.isEmpty()) { + NodeTuple displayTuple = site.properties().get("DisplayItem"); + if (displayTuple == null) { + int at = blockEnd(content, afterLine(content, site.keyStartOffset()), site.keyIndent()); + replacements.add(new Replacement(at, at, insertionPrefix(content, at) + spaces(site.propertyIndent()) + + "DisplayItem:\n" + renderDisplayProperties(missingDisplay, site.propertyIndent() + 2))); + } else if (displayTuple.getValueNode() instanceof MappingNode display + && isBlock(display) && display.getAnchor() == null) { + int keyStart = offset(content, displayTuple.getKeyNode().getStartMark().getIndex()); + int at = blockEnd(content, afterLine(content, keyStart), site.propertyIndent()); + replacements.add(new Replacement(at, at, + insertionPrefix(content, at) + renderDisplayProperties(missingDisplay, + childIndent(display, site.propertyIndent(), site.propertyIndent() + 2, content)))); + } else { + throw invalidOperation(); + } + } + return apply(content, replacements); + } + + /** Removes exactly one safe site mapping, retaining trailing comments outside its block. */ + public static String remove(String content, String siteKey) { + Parsed parsed = parse(content); + SiteInfo site = parsed.byKey().get(siteKey); + if (!safeKey(siteKey) || site == null) throw invalidOperation(); + int start = lineStart(content, site.keyStartOffset()); + int boundary = blockEnd(content, afterLine(content, site.keyStartOffset()), site.keyIndent()); + int end = meaningfulBlockEnd(content, start, boundary); + return content.substring(0, start) + content.substring(end); + } + + private static Parsed parse(String content) { + if (content == null || content.indexOf('\0') >= 0 + || content.codePointCount(0, content.length()) > MAX_DOCUMENT_CODE_POINTS) throw invalidDocument(); + try { + LoaderOptions options = new LoaderOptions(); + options.setAllowDuplicateKeys(false); + options.setAllowRecursiveKeys(false); + options.setMaxAliasesForCollections(MAX_COLLECTION_ALIASES); + options.setNestingDepthLimit(MAX_NESTING_DEPTH); + options.setCodePointLimit(MAX_DOCUMENT_CODE_POINTS); + options.setMergeOnCompose(false); + List documents = new ArrayList<>(); + for (Node node : new Yaml(new SafeConstructor(options)).composeAll(new StringReader(content))) documents.add(node); + if (documents.size() != 1 || !(documents.get(0) instanceof MappingNode root) || !isBlock(root)) throw invalidDocument(); + MappingNode voteSites = null; + boolean emptyVoteSites = false; + int voteSitesIndent = -1; + int voteSitesKeyStart = -1; + Set rootKeys = new HashSet<>(); + for (NodeTuple tuple : root.getValue()) { + String key = scalarKey(tuple.getKeyNode()); + if (!rootKeys.add(key)) throw invalidDocument(); + if ("VoteSites".equals(key)) { + if (voteSites != null || emptyVoteSites) { + throw invalidDocument(); + } + if (tuple.getValueNode() instanceof MappingNode mapping && isBlock(mapping)) { + voteSites = mapping; + } else if (tuple.getValueNode() instanceof ScalarNode scalar && Tag.NULL.equals(scalar.getTag()) + && scalar.getValue().isEmpty()) { + // Removing the last site leaves "VoteSites:"; allow a later + // explicit add without making the document unmanageable. + emptyVoteSites = true; + } else { + throw invalidDocument(); + } + voteSitesIndent = column(tuple.getKeyNode(), content); + voteSitesKeyStart = offset(content, tuple.getKeyNode().getStartMark().getIndex()); + } + } + if ((voteSites == null && !emptyVoteSites) || voteSites != null && voteSites.getAnchor() != null) throw invalidDocument(); + if (voteSites != null) rejectAnchorsAliasesAndMerges(voteSites); + List sites = new ArrayList<>(); + LinkedHashMap byKey = new LinkedHashMap<>(); + Set folded = new HashSet<>(); + int siteIndent = -1; + for (NodeTuple tuple : voteSites == null ? List.of() : voteSites.getValue()) { + String key = scalarKey(tuple.getKeyNode()); + if (byKey.containsKey(key) || !folded.add(key.toLowerCase(Locale.ROOT)) + || key.codePointCount(0, key.length()) > MAX_SITE_KEY_CODE_POINTS + || !(tuple.getValueNode() instanceof MappingNode site) || !isBlock(site)) throw invalidDocument(); + int keyIndent = column(tuple.getKeyNode(), content); + if (keyIndent <= voteSitesIndent) throw invalidDocument(); + if (siteIndent < 0) siteIndent = keyIndent; + else if (siteIndent != keyIndent) throw invalidDocument(); + LinkedHashMap properties = properties(site); + boolean rewardsConfigured = hasContent(properties.get("Rewards")); + SiteInfo info = new SiteInfo(key, site, properties, keyIndent, + childIndent(site, keyIndent, keyIndent + 2, content), + offset(content, tuple.getKeyNode().getStartMark().getIndex()), rewardsConfigured); + sites.add(info); + byKey.put(key, info); + if (sites.size() > MAX_SITES) throw invalidDocument(); + } + return new Parsed(Collections.unmodifiableList(sites), Collections.unmodifiableMap(byKey), voteSitesIndent, + voteSitesKeyStart, siteIndent < 0 ? voteSitesIndent + 2 : siteIndent); + } catch (IllegalArgumentException failure) { + throw failure; + } catch (RuntimeException failure) { + throw invalidDocument(); + } + } + + private static LinkedHashMap properties(MappingNode mapping) { + LinkedHashMap result = new LinkedHashMap<>(); + Set folded = new HashSet<>(); + for (NodeTuple tuple : mapping.getValue()) { + String key = scalarKey(tuple.getKeyNode()); + if (!folded.add(key.toLowerCase(Locale.ROOT)) || result.put(key, tuple) != null) throw invalidDocument(); + } + return result; + } + + private static boolean hasNonCanonicalKey(Map properties, String canonical) { + return properties.keySet().stream().anyMatch(key -> !key.equals(canonical) && key.equalsIgnoreCase(canonical)); + } + + private static Field readField(SiteInfo site, String name) { + FieldLocation location = fieldLocation(null, site, name); + return location == null ? new Field(Status.MISSING, null) : new Field(location.status(), location.value()); + } + + private static FieldLocation fieldLocation(String content, SiteInfo site, String name) { + Node value; + if (name.startsWith("DisplayItem.")) { + NodeTuple display = site.properties().get("DisplayItem"); + if (display == null) return hasNonCanonicalKey(site.properties(), "DisplayItem") + ? new FieldLocation(Status.UNSUPPORTED, null, -1, -1) : null; + if (!(display.getValueNode() instanceof MappingNode map) || !isBlock(map)) { + return new FieldLocation(Status.UNSUPPORTED, null, -1, -1); + } + Map nested = properties(map); + String child = name.substring("DisplayItem.".length()); + NodeTuple tuple = nested.get(child); + if (tuple == null) return hasNonCanonicalKey(nested, child) + ? new FieldLocation(Status.UNSUPPORTED, null, -1, -1) : null; + value = tuple.getValueNode(); + } else { + NodeTuple tuple = site.properties().get(name); + if (tuple == null) return hasNonCanonicalKey(site.properties(), name) + ? new FieldLocation(Status.UNSUPPORTED, null, -1, -1) : null; + value = tuple.getValueNode(); + } + return scalarLocation(content, value, name); + } + + private static FieldLocation scalarLocation(String content, Node node, String name) { + if (!(node instanceof ScalarNode scalar) || scalar.getAnchor() != null) { + return new FieldLocation(Status.UNSUPPORTED, null, -1, -1); + } + Object value = switch (name) { + case "Enabled", "Hidden" -> booleanValue(scalar); + case "Priority", "DisplayItem.Amount" -> integerValue(scalar); + default -> stringValue(scalar); + }; + if (value == null) return new FieldLocation(Status.UNSUPPORTED, null, -1, -1); + if (!validInput(name, value)) return new FieldLocation(Status.UNSUPPORTED, null, -1, -1); + int start = scalar.getStartMark().getIndex(); + int end = scalar.getEndMark().getIndex(); + if (content != null) { + start = offset(content, start); + end = offset(content, end); + } + return new FieldLocation(Status.AVAILABLE, value, start, end); + } + + private static Boolean booleanValue(ScalarNode scalar) { + if (!Tag.BOOL.equals(scalar.getTag())) return null; + return switch (scalar.getValue().toLowerCase(Locale.ROOT)) { + case "true", "yes", "on" -> Boolean.TRUE; + case "false", "no", "off" -> Boolean.FALSE; + default -> null; + }; + } + + private static Integer integerValue(ScalarNode scalar) { + if (!Tag.INT.equals(scalar.getTag()) || !scalar.getValue().matches("[-+]?(0|[1-9][0-9]*)")) return null; + try { + return Integer.valueOf(scalar.getValue()); + } catch (NumberFormatException failure) { + return null; + } + } + + private static String stringValue(ScalarNode scalar) { + if (!Tag.STR.equals(scalar.getTag()) || scalar.getScalarStyle() == DumperOptions.ScalarStyle.LITERAL + || scalar.getScalarStyle() == DumperOptions.ScalarStyle.FOLDED) return null; + return scalar.getValue(); + } + + private static Map validateFields(Map fields, boolean complete) { + if (fields == null || fields.isEmpty() || fields.size() > FIELDS.size() || (complete && fields.size() != FIELDS.size())) { + throw invalidOperation(); + } + LinkedHashMap checked = new LinkedHashMap<>(); + for (Map.Entry entry : fields.entrySet()) { + String name = entry.getKey(); + Object value = entry.getValue(); + if (name == null || !FIELD_SET.contains(name) || checked.put(name, value) != null || !validInput(name, value)) { + throw invalidOperation(); + } + } + if (complete && !checked.keySet().containsAll(FIELD_SET)) throw invalidOperation(); + return checked; + } + + private static boolean validInput(String name, Object value) { + if ("Enabled".equals(name) || "Hidden".equals(name)) return value instanceof Boolean; + if ("Priority".equals(name)) return value instanceof Integer; + if ("DisplayItem.Amount".equals(name)) return value instanceof Integer integer && integer >= 1 && integer <= 64; + if (!(value instanceof String text) || text.indexOf('\0') >= 0) return false; + return switch (name) { + case "Name" -> text.codePointCount(0, text.length()) <= 200; + case "ServiceSite" -> visibleSingleLine(text, 1, 2048); + case "VoteURL" -> visibleSingleLine(text, 1, 500); + case "VoteDelay" -> visibleSingleLine(text, 1, 64); + case "DisplayItem.Material" -> MATERIAL.matcher(text).matches(); + default -> false; + }; + } + + private static boolean visibleSingleLine(String value, int min, int max) { + int length = value.codePointCount(0, value.length()); + if (length < min || length > max || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0 + || value.indexOf('\t') >= 0 || value.indexOf('|') >= 0) return false; + for (int i = 0; i < value.length();) { + int point = value.codePointAt(i); + if (Character.isISOControl(point)) return false; + i += Character.charCount(point); + } + return true; + } + + private static String renderFields(Map fields, int indent) { + LinkedHashMap direct = new LinkedHashMap<>(); + LinkedHashMap display = new LinkedHashMap<>(); + for (String field : FIELDS) { + if (field.startsWith("DisplayItem.")) display.put(field, fields.get(field)); + else direct.put(field, fields.get(field)); + } + return renderProperties(direct, indent) + spaces(indent) + "DisplayItem:\n" + renderDisplayProperties(display, indent + 2); + } + + private static String renderProperties(Map fields, int indent) { + StringBuilder result = new StringBuilder(); + for (String field : FIELDS) { + if (field.startsWith("DisplayItem.") || !fields.containsKey(field)) continue; + result.append(spaces(indent)).append(field).append(": ").append(renderValue(field, fields.get(field))).append('\n'); + } + return result.toString(); + } + + private static String renderDisplayProperties(Map fields, int indent) { + StringBuilder result = new StringBuilder(); + for (String field : List.of("DisplayItem.Material", "DisplayItem.Amount")) { + if (fields.containsKey(field)) result.append(spaces(indent)).append(field.substring("DisplayItem.".length())) + .append(": ").append(renderValue(field, fields.get(field))).append('\n'); + } + return result.toString(); + } + + private static String renderValue(String field, Object value) { + return value instanceof String text ? yamlQuote(text) : String.valueOf(value); + } + + private static String yamlQuote(String value) { + StringBuilder result = new StringBuilder("\""); + for (int i = 0; i < value.length();) { + int point = value.codePointAt(i); + switch (point) { + case '"' -> result.append("\\\""); + case '\\' -> result.append("\\\\"); + case '\n' -> result.append("\\n"); + case '\r' -> result.append("\\r"); + case '\t' -> result.append("\\t"); + default -> { + if (Character.isISOControl(point)) result.append(String.format("\\u%04X", point)); + else result.appendCodePoint(point); + } + } + i += Character.charCount(point); + } + return result.append('"').toString(); + } + + private static String apply(String content, List replacements) { + if (replacements.isEmpty()) return content; + replacements.sort((left, right) -> Integer.compare(right.startOffset(), left.startOffset())); + StringBuilder result = new StringBuilder(content); + int previous = content.length(); + for (Replacement replacement : replacements) { + if (replacement.startOffset() < 0 || replacement.startOffset() > replacement.endOffset() + || replacement.endOffset() > previous) throw invalidOperation(); + result.replace(replacement.startOffset(), replacement.endOffset(), replacement.value()); + previous = replacement.startOffset(); + } + return result.toString(); + } + + private static void rejectAnchorsAliasesAndMerges(Node node) { + if (node.getAnchor() != null || node instanceof AnchorNode) throw invalidDocument(); + if (node instanceof MappingNode mapping) { + if (mapping.isMerged()) throw invalidDocument(); + for (NodeTuple tuple : mapping.getValue()) { + if (tuple.getKeyNode() instanceof ScalarNode scalar && "<<".equals(scalar.getValue())) throw invalidDocument(); + rejectAnchorsAliasesAndMerges(tuple.getKeyNode()); + rejectAnchorsAliasesAndMerges(tuple.getValueNode()); + } + } else if (node instanceof SequenceNode sequence) { + for (Node item : sequence.getValue()) rejectAnchorsAliasesAndMerges(item); + } + } + + private static String scalarKey(Node node) { + if (!(node instanceof ScalarNode scalar) || scalar.getAnchor() != null) throw invalidDocument(); + return scalar.getValue(); + } + + private static boolean hasContent(NodeTuple tuple) { + if (tuple == null) return false; + Node node = tuple.getValueNode(); + if (node instanceof MappingNode mapping) return !mapping.getValue().isEmpty(); + if (node instanceof SequenceNode sequence) return !sequence.getValue().isEmpty(); + return node instanceof ScalarNode scalar && !scalar.getValue().trim().isEmpty() && !Tag.NULL.equals(scalar.getTag()); + } + + private static boolean isBlock(MappingNode node) { + return DumperOptions.FlowStyle.BLOCK.equals(node.getFlowStyle()); + } + + private static boolean safeKey(String key) { + return key != null && SAFE_KEY.matcher(key).matches(); + } + + private static int column(Node node, String content) { + int column = node.getStartMark().getColumn(); + if (column < 0 || column > content.length()) throw invalidDocument(); + return column; + } + + private static int childIndent(MappingNode mapping, int parentIndent, int fallback, String content) { + if (mapping.getValue().isEmpty()) return fallback; + int indent = column(mapping.getValue().get(0).getKeyNode(), content); + if (indent <= parentIndent) throw invalidDocument(); + return indent; + } + + private static int offset(String content, int codePoints) { + int count = content.codePointCount(0, content.length()); + if (codePoints < 0 || codePoints > count) throw invalidDocument(); + return content.offsetByCodePoints(0, codePoints); + } + + private static int blockEnd(String content, int start, int parentIndent) { + int offset = start; + int result = content.length(); + while (offset < content.length()) { + int next = content.indexOf('\n', offset); + int end = next < 0 ? content.length() : next + 1; + int first = offset; + while (first < end && content.charAt(first) == ' ') first++; + if (first < end && content.charAt(first) == '\t') throw invalidOperation(); + if (first < end && content.charAt(first) != '\n' && content.charAt(first) != '\r' && content.charAt(first) != '#') { + int indent = first - offset; + if (indent <= parentIndent) return offset; + } + offset = end; + } + return result; + } + + private static int meaningfulBlockEnd(String content, int start, int boundary) { + int cursor = start; + int last = start; + while (cursor < boundary) { + int next = content.indexOf('\n', cursor); + int end = next < 0 || next >= boundary ? boundary : next + 1; + int first = cursor; + while (first < end && content.charAt(first) == ' ') first++; + if (first < end && content.charAt(first) != '#' && content.charAt(first) != '\n' && content.charAt(first) != '\r') { + last = end; + } + cursor = end; + } + return last; + } + + private static int lineStart(String content, int offset) { + int index = Math.min(offset, content.length()); + while (index > 0 && content.charAt(index - 1) != '\n') index--; + return index; + } + + private static int afterLine(String content, int offset) { + int newline = content.indexOf('\n', lineStart(content, offset)); + return newline < 0 ? content.length() : newline + 1; + } + + private static String insertionPrefix(String content, int at) { + return at > 0 && content.charAt(at - 1) != '\n' ? "\n" : ""; + } + + private static String spaces(int count) { + return " ".repeat(count); + } + + private static IllegalArgumentException invalidDocument() { + return new IllegalArgumentException("vote sites document is invalid"); + } + + private static IllegalArgumentException invalidOperation() { + return new IllegalArgumentException("vote sites operation is invalid"); + } + + public enum Status { AVAILABLE, MISSING, UNSUPPORTED } + + public record Field(Status status, Object value) { + public Field { + if (status == null || (status == Status.AVAILABLE) != (value != null)) throw new IllegalArgumentException("field is invalid"); + } + } + + public record Site(String key, boolean editable, Map fields, boolean rewardsConfigured) { + public Site { + if (key == null || fields == null) throw new IllegalArgumentException("site is invalid"); + } + } + + public record Inventory(List sites) { + public Inventory { + if (sites == null || sites.size() > MAX_SITES) throw new IllegalArgumentException("inventory is invalid"); + } + } + + private record Parsed(List sites, Map byKey, int voteSitesIndent, + int voteSitesKeyStart, int siteIndent) { } + private record SiteInfo(String key, MappingNode node, Map properties, int keyIndent, + int propertyIndent, int keyStartOffset, boolean rewardsConfigured) { } + private record FieldLocation(Status status, Object value, int startOffset, int endOffset) { } + private record Replacement(int startOffset, int endOffset, String value) { } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index 435b6acf..a32050ac 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -91,6 +91,12 @@ public final class ControlHttpServer implements AutoCloseable { "/", new WebResource("/web/index.html", "text/html; charset=utf-8"), "/index.html", new WebResource("/web/index.html", "text/html; charset=utf-8"), "/app.js", new WebResource("/web/app.js", "text/javascript; charset=utf-8"), + "/workspace.js", new WebResource("/web/workspace.js", "text/javascript; charset=utf-8"), + "/configuration-state.js", new WebResource("/web/configuration-state.js", "text/javascript; charset=utf-8"), + "/general-settings.js", new WebResource("/web/general-settings.js", "text/javascript; charset=utf-8"), + "/vote-sites-state.js", new WebResource("/web/vote-sites-state.js", "text/javascript; charset=utf-8"), + "/vote-sites.js", new WebResource("/web/vote-sites.js", "text/javascript; charset=utf-8"), + "/rewards.js", new WebResource("/web/rewards.js", "text/javascript; charset=utf-8"), "/app.css", new WebResource("/web/app.css", "text/css; charset=utf-8")); private static final int MAX_AUTH_FAILURES_PER_MINUTE = 100; private static final int MAX_PASSWORD_ATTEMPTS_PER_CLIENT = 8; @@ -567,6 +573,80 @@ private void route(HttpExchange exchange) throws IOException { send(exchange, 202, configurationOperations.createRead(request.nodeIds(), request.configuration())); return; } + if ((CONFIGURATION + "/general-settings/state").equals(path)) { + requireMethod(exchange, "POST"); + authenticateAdmin(exchange, true); + ConfigurationRequests.SettingsState request = read(exchange, ConfigurationRequests.SettingsState.class); + requireRequest(request); + send(exchange, 200, configurationOperations.generalSettingsState(request.readOperationId(), request.nodeId())); + return; + } + if ((CONFIGURATION + "/general-settings/discard-preview").equals(path)) { + requireMethod(exchange, "POST"); + authenticateAdmin(exchange, true); + ConfigurationRequests.SettingsDiscard request = read(exchange, ConfigurationRequests.SettingsDiscard.class); + requireRequest(request); + send(exchange, 200, configurationOperations.discardGeneralSettingsPreview(request.previewOperationId(), request.approvalToken())); + return; + } + if ((CONFIGURATION + "/general-settings/preview").equals(path)) { + requireMethod(exchange, "POST"); + authenticateAdmin(exchange, true); + ConfigurationRequests.SettingsPreview request = read(exchange, ConfigurationRequests.SettingsPreview.class); + requireRequest(request); + send(exchange, 202, configurationOperations.createGeneralSettingsPreview(request.readOperationId(), request.nodeId(), request.overrides())); + return; + } + if ((CONFIGURATION + "/vote-sites/state").equals(path)) { + requireMethod(exchange, "POST"); + authenticateAdmin(exchange, true); + ConfigurationRequests.VoteSitesState request = read(exchange, ConfigurationRequests.VoteSitesState.class); + requireRequest(request); + send(exchange, 200, configurationOperations.voteSitesState(request.readOperationId(), request.nodeId())); + return; + } + if ((CONFIGURATION + "/vote-sites/discard-preview").equals(path)) { + requireMethod(exchange, "POST"); + authenticateAdmin(exchange, true); + ConfigurationRequests.VoteSitesDiscard request = read(exchange, ConfigurationRequests.VoteSitesDiscard.class); + requireRequest(request); + send(exchange, 200, configurationOperations.discardVoteSitesPreview(request.previewOperationId(), request.approvalToken())); + return; + } + if ((CONFIGURATION + "/vote-sites/preview").equals(path)) { + requireMethod(exchange, "POST"); + authenticateAdmin(exchange, true); + ConfigurationRequests.VoteSitesPreview request = read(exchange, ConfigurationRequests.VoteSitesPreview.class); + requireRequest(request); + send(exchange, 202, configurationOperations.createVoteSitesPreview(request.readOperationId(), request.nodeId(), + request.action(), request.siteKey(), request.fields())); + return; + } + if ((CONFIGURATION + "/rewards/state").equals(path)) { + requireMethod(exchange, "POST"); + authenticateAdmin(exchange, true); + ConfigurationRequests.RewardsState request = read(exchange, ConfigurationRequests.RewardsState.class); + requireRequest(request); + send(exchange, 200, configurationOperations.rewardsState(request.readOperationId(), request.nodeId(), request.fileName())); + return; + } + if ((CONFIGURATION + "/rewards/discard-preview").equals(path)) { + requireMethod(exchange, "POST"); + authenticateAdmin(exchange, true); + ConfigurationRequests.RewardsDiscard request = read(exchange, ConfigurationRequests.RewardsDiscard.class); + requireRequest(request); + send(exchange, 200, configurationOperations.discardRewardsPreview(request.previewOperationId(), request.approvalToken())); + return; + } + if ((CONFIGURATION + "/rewards/preview").equals(path)) { + requireMethod(exchange, "POST"); + authenticateAdmin(exchange, true); + ConfigurationRequests.RewardsPreview request = read(exchange, ConfigurationRequests.RewardsPreview.class); + requireRequest(request); + send(exchange, 202, configurationOperations.createRewardsPreview(request.readOperationId(), request.nodeId(), + request.fileName(), request.rewardPath(), request.action(), request.field(), request.value())); + return; + } if ((CONFIGURATION + "/preview").equals(path)) { requireMethod(exchange, "POST"); authenticateAdmin(exchange, true); @@ -921,6 +1001,13 @@ private T read(HttpExchange exchange, Class type) throws IOException { String text = StandardCharsets.UTF_8.newDecoder() .onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT) .decode(ByteBuffer.wrap(bytes)).toString(); + if (type == ConfigurationRequests.SettingsState.class || type == ConfigurationRequests.SettingsPreview.class + || type == ConfigurationRequests.SettingsDiscard.class || type == ConfigurationRequests.VoteSitesState.class + || type == ConfigurationRequests.VoteSitesPreview.class || type == ConfigurationRequests.VoteSitesDiscard.class + || type == ConfigurationRequests.RewardsState.class || type == ConfigurationRequests.RewardsPreview.class + || type == ConfigurationRequests.RewardsDiscard.class) { + return json.readerFor(type).with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(text); + } return json.readValue(text, type); } diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationRequests.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationRequests.java index a1b63a9c..1de8ad4f 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationRequests.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationRequests.java @@ -1,6 +1,7 @@ package com.bencodez.votingplugin.control.protocol; import java.util.List; +import java.util.Map; import java.util.UUID; public final class ConfigurationRequests { @@ -20,5 +21,27 @@ public Preview(List nodeIds, ProxyRoutingConfiguration configuration) { public record Apply(UUID previewOperationId, String approvalToken) { } + public record SettingsState(UUID readOperationId, String nodeId) { } + + /** Raw value types are checked explicitly; JSON strings/numbers are not boolean edits. */ + public record SettingsPreview(UUID readOperationId, String nodeId, Map overrides) { } + + public record SettingsDiscard(UUID previewOperationId, String approvalToken) { } + + public record VoteSitesState(UUID readOperationId, String nodeId) { } + + /** A bounded typed site mutation; fields are revalidated against the operation kind. */ + public record VoteSitesPreview(UUID readOperationId, String nodeId, String action, String siteKey, + Map fields) { } + + public record VoteSitesDiscard(UUID previewOperationId, String approvalToken) { } + + public record RewardsState(UUID readOperationId, String nodeId, String fileName) { } + + public record RewardsPreview(UUID readOperationId, String nodeId, String fileName, String rewardPath, + String action, String field, Object value) { } + + public record RewardsDiscard(UUID previewOperationId, String approvalToken) { } + public record Claim(UUID sessionId) { } } diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/InspectionQuery.java b/src/main/java/com/bencodez/votingplugin/control/protocol/InspectionQuery.java index 8e8a1836..672ffe2c 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/InspectionQuery.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/InspectionQuery.java @@ -8,13 +8,17 @@ /** A bounded, typed, read-only question that a VotingPlugin node can answer. */ public record InspectionQuery(String kind, Map filters) { public static final String CAPABILITY = "data.inspect.v1"; + public static final String REWARD_FILE_CAPABILITY = ManagedConfiguration.REWARD_FILE_CAPABILITY; public static final int MAX_REWARD_PROPOSAL = 64 * 1024; public static final Set KINDS = Set.of("overview", "player", "vote-site-health", "vote-log-summary", - "vote-log-search", "vote-trace", "vote-site-resolution", "reward-simulation", "diagnostics"); + "vote-log-search", "vote-trace", "vote-site-resolution", "reward-simulation", "diagnostics", + "reward-file-inventory"); public InspectionQuery { filters = filters == null ? Map.of() : Map.copyOf(new LinkedHashMap<>(filters)); if (!KINDS.contains(kind)) throw new IllegalArgumentException("inspection kind is unsupported"); + if ("reward-file-inventory".equals(kind) && !filters.isEmpty()) + throw new IllegalArgumentException("reward file inventory takes no filters"); if (filters.size() > 12 || filters.entrySet().stream().anyMatch(entry -> entry.getKey() == null || !entry.getKey().matches("[a-z][A-Za-z0-9]{0,39}") || entry.getValue() == null || invalidValue(kind, entry.getKey(), entry.getValue()))) { @@ -22,6 +26,8 @@ public record InspectionQuery(String kind, Map filters) { } } + public boolean requiresRewardFiles() { return "reward-file-inventory".equals(kind); } + private static boolean invalidValue(String kind, String key, String value) { int maximum = "reward-simulation".equals(kind) && "proposal".equals(key) ? MAX_REWARD_PROPOSAL : 500; return value.getBytes(StandardCharsets.UTF_8).length > maximum || value.indexOf('\0') >= 0; diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java index 92fee5a8..fb5d0533 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java @@ -13,6 +13,7 @@ public record ManagedConfiguration(String domain, Boolean sendVotesToAllServers, @JsonIgnore boolean redacted) { public static final String PROXY_ROUTING = "proxy-routing"; public static final String FILE = "file"; + public static final String REWARD_FILE_CAPABILITY = "config.reward-files.v1"; public static final String QUICK_SETUP = "quick-setup"; public static final String VOTE_SITES_SYNC = "sync-vote-sites"; public static final String COMMUNICATION_TEST = "communication-test"; @@ -130,7 +131,8 @@ public void validateProposal() { public String capability() { return switch (domain) { case PROXY_ROUTING -> "config.proxy-routing.v1"; - case FILE -> "bungeeconfig.yml".equals(fileName) ? "config.proxy-files.v1" : "config.files.v1"; + case FILE -> "bungeeconfig.yml".equals(fileName) ? "config.proxy-files.v1" + : fileName.startsWith("Rewards/") ? REWARD_FILE_CAPABILITY : "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) @@ -170,7 +172,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|bungeeconfig\\.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|Rewards/[A-Za-z0-9][A-Za-z0-9_-]{0,99}\\.yml")) { throw new IllegalArgumentException("configuration file name is not managed"); } } diff --git a/src/main/resources/web/app.css b/src/main/resources/web/app.css index d1e8951e..051f790c 100644 --- a/src/main/resources/web/app.css +++ b/src/main/resources/web/app.css @@ -319,3 +319,164 @@ li span { color: var(--muted); font-size: .9rem; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; } } + +/* Scope-first workspace shell. These rules only style the new chooser/context + surfaces; the established authenticated panels retain their original layout. */ +.app-shell.chooser-mode { display: block; } +.app-shell.chooser-mode .content-shell { width: 100%; } +.app-shell.chooser-mode main { width: 100%; } +.scope-bar { flex-wrap: wrap; } +#individual-overview { margin-bottom: 18px; } +#individual-overview > .card { margin-bottom: 18px; } +#metric-presence { font-size: 1.05rem; line-height: 1.4; } + +.scope-bar { position: sticky; z-index: 10; top: var(--topbar-height); display: flex; align-items: center; justify-content: space-between; gap: 18px; margin: -22px 0 26px; padding: 13px 16px; border: 1px solid #1d4f7d; border-radius: 12px; background: rgba(7, 20, 35, .96); box-shadow: 0 14px 36px rgba(0, 0, 0, .22); backdrop-filter: blur(14px); } +.scope-bar > div:first-child { min-width: 0; } +.scope-bar .eyebrow { margin-bottom: 4px; font-size: .62rem; } +.scope-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; } +.scope-actions .server-picker { grid-template-columns: auto minmax(170px, 240px); } +.scope-notice { flex-basis: 100%; margin: 0; color: var(--muted); font-size: .77rem; text-align: right; } +.scope-chips { display: flex; flex-wrap: wrap; gap: 7px; } +.scope-chip { display: inline-flex; align-items: center; gap: 7px; padding: 6px 10px; border: 1px solid #2b6092; border-radius: 999px; background: #0d2946; color: #c6e2ff; font-size: .76rem; font-weight: 750; } +.scope-chip:hover:not(:disabled) { border-color: var(--blue); background: #123b64; transform: none; } + +#home-panel .page-heading { margin-top: 4px; } +#home-panel .page-heading h2 { font-size: clamp(1.65rem, 3vw, 2.2rem); } +#home-panel .page-heading p:not(.eyebrow) { max-width: 720px; font-size: 1rem; } +.global-choice { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 16px; width: 100%; margin-bottom: 16px; padding: 18px 20px; border-color: #1d5a8e; background: linear-gradient(110deg, rgba(12, 43, 73, .9), rgba(9, 22, 38, .96)); text-align: left; } +.global-choice .setting-icon { flex: none; width: 48px; height: 48px; margin: 0; border-radius: 50%; font-size: 1.45rem; } +.global-choice strong, .global-choice small { display: block; } +.global-choice strong { margin-bottom: 4px; color: #f3f7ff; font-size: 1.1rem; } +.global-choice small { color: var(--muted); font-weight: 500; line-height: 1.45; } +.chooser-toolbar { display: flex; align-items: end; flex-wrap: wrap; gap: 9px; margin-bottom: 19px; padding: 10px 12px; border: 1px solid #173e63; border-radius: 11px; background: rgba(8, 22, 38, .82); } +.chooser-toolbar label { flex: 1 1 300px; min-width: 220px; margin: 0; color: var(--muted); font-size: .77rem; } +.chooser-toolbar input { display: block; width: 100%; margin-top: 5px; padding-block: 9px; } +.chooser-toolbar button { white-space: nowrap; } +#home-nodes { padding: 0; border: 0; background: transparent; text-align: initial; } +#home-nodes.empty { padding: 30px; border: 1px dashed var(--border-strong); background: rgba(9, 20, 33, .45); text-align: center; } +#home-nodes .node { min-height: 248px; padding: 18px; background: linear-gradient(155deg, #0b1c30, #091522); } +#home-nodes .node:hover { border-color: #356b9d; } +#home-nodes .node.selected { border-color: #2d9bff; box-shadow: inset 0 0 0 1px #2d9bff, 0 0 0 1px rgba(45, 155, 255, .2), 0 18px 42px rgba(23, 102, 180, .16); } +#home-nodes .node.selected::after { position: absolute; top: 14px; right: 14px; width: 8px; height: 8px; border-radius: 50%; background: var(--blue); box-shadow: 0 0 0 4px rgba(75, 156, 255, .13); content: ""; } +#home-nodes .node-header { padding-right: 8px; } +#home-nodes .node-header h3 { font-size: 1.05rem; } +#home-nodes .node-meta { margin: 11px 0; } +#home-nodes .node-select { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); } +.chooser-selection { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-top: 18px; padding: 13px 16px; border: 1px solid #1d4f7d; border-radius: 12px; background: rgba(7, 20, 35, .96); } +.chooser-selection strong { display: block; margin-bottom: 7px; } +.chooser-selection .scope-chips { max-width: 760px; } +.chooser-selection > button { min-width: 190px; } +.topology-link.inspected { border-color: var(--blue); box-shadow: inset 0 0 0 1px var(--blue), 0 0 0 3px rgba(75, 156, 255, .12); background: #102b49; } +.topology-link.inspected::after { color: #9ed0ff; content: "inspected"; font-size: .65rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } + +@media (max-width: 920px) { + .scope-bar { align-items: stretch; flex-direction: column; margin-top: -12px; } + .scope-actions { justify-content: flex-start; } + .scope-notice { text-align: left; } + .chooser-selection { align-items: stretch; flex-direction: column; } + .chooser-selection > button { width: 100%; } +} + +@media (max-width: 640px) { + .global-choice { grid-template-columns: auto minmax(0, 1fr); } + .global-choice > span:last-child { display: none; } + .chooser-toolbar { align-items: stretch; flex-direction: column; } + .chooser-toolbar label { min-width: 0; } + .chooser-toolbar button { width: 100%; } + #home-nodes { grid-template-columns: 1fr; } + .scope-actions, .scope-actions .server-picker { width: 100%; } + .scope-actions .server-picker { grid-template-columns: 1fr; } + .scope-actions .server-picker span { display: none; } + .scope-actions button { flex: 1 1 150px; } +} +/* Safe workspace visual editor: compact fields with a bounded sticky summary. */ +.general-settings-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 320px); gap: 18px; align-items: start; } +.general-settings-layout > div, .general-settings-layout > aside { min-width: 0; } +.general-setting-row { display: grid; grid-template-columns: minmax(0, 1fr) 150px auto; gap: 12px; padding: 16px 0; border-top: 1px solid var(--border, #223855); align-items: start; } +.general-setting-row label { margin: 0; font-weight: 600; } +.general-setting-row small { display: block; margin-top: 6px; color: var(--muted, #a3b6cc); font-weight: 400; line-height: 1.5; } +.general-setting-row details { grid-column: 1 / -1; overflow-wrap: anywhere; } +.general-setting-row summary { cursor: pointer; } +.change-summary { position: sticky; top: calc(var(--topbar-height, 76px) + var(--workspace-scope-height, 120px) + 16px); max-height: calc(100vh - var(--topbar-height, 76px) - var(--workspace-scope-height, 120px) - 32px); overflow-y: auto; overflow-wrap: anywhere; } +.stacked-actions { display: grid; gap: 10px; margin-top: 16px; } +.settings-targets { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; } +.settings-target { padding: 8px 12px; border: 1px solid #355070; border-radius: 9px; overflow-wrap: anywhere; } +.settings-target.loaded { border-color: #2b775c; } +.settings-target.warning { border-color: #aa7b38; } +.target-operation { padding: 12px 0; border-bottom: 1px solid #263b56; overflow-wrap: anywhere; } +#general-settings-panel[aria-busy="true"] .settings-targets { opacity: .65; } +@media (max-width: 1100px) { .general-settings-layout { grid-template-columns: minmax(0, 1fr); } .change-summary { position: static; max-height: none; } } +@media (max-width: 580px) { .general-setting-row { grid-template-columns: minmax(0, 1fr) auto; } .general-setting-row label { grid-column: 1 / -1; } } + +/* Multi-target Vote Sites editor. */ +.vote-sites-heading { align-items: flex-end; } +.vote-sites-layout { display: grid; grid-template-columns: minmax(250px, 320px) minmax(0, 1fr); gap: 18px; align-items: start; } +.vote-sites-list-card { position: sticky; top: calc(var(--topbar-height, 76px) + 132px); max-height: calc(100vh - var(--topbar-height, 76px) - 154px); overflow-y: auto; } +.vote-sites-list-card label { color: var(--muted); font-size: .78rem; } +.vote-sites-list-card input, .vote-sites-list-card select { display: block; width: 100%; margin-top: 6px; } +.vote-sites-list-card small { display: block; margin-top: 9px; color: var(--muted); line-height: 1.45; } +.vote-site-list { display: grid; gap: 8px; margin: 15px 0; } +.vote-site-list-item { width: 100%; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: #091522; color: inherit; text-align: left; } +.vote-site-list-item:hover:not(:disabled) { border-color: #3e7bb4; transform: none; } +.vote-site-list-item[aria-selected="true"] { border-color: var(--blue); box-shadow: inset 0 0 0 1px var(--blue); background: #102a47; } +.vote-site-list-item strong, .vote-site-list-item small { display: block; } +.vote-site-list-item small { margin-top: 4px; color: var(--muted); font-weight: 500; } +.vote-site-list-item .pills { margin-top: 8px; } +.vote-site-editor-column { display: grid; min-width: 0; gap: 18px; } +.vote-site-editor { min-width: 0; } +.site-identity { display: flex; flex-wrap: wrap; gap: 12px 28px; margin: 14px 0 18px; } +.site-identity div { min-width: 180px; } +.site-identity dt { color: var(--muted); font-size: .7rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; } +.site-identity dd { margin: 5px 0 0; overflow-wrap: anywhere; } +.vote-site-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } +.vote-site-fields label { min-width: 0; color: #e4effc; } +.vote-site-fields label small { display: block; min-height: 1.3em; margin: 4px 0 6px; color: var(--muted); font-weight: 500; } +.vote-site-fields input, .vote-site-fields select { width: 100%; margin: 0; } +.vote-site-fields [data-mixed="true"] { border-color: #a97835; background: #1a160f; } +.vote-site-fields [data-partial="true"] { border-color: #a97835; } +.vote-site-fields [data-dirty="true"] { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(75, 156, 255, .12); } +.vote-site-editor details { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); } +.vote-site-editor details summary { cursor: pointer; color: #bcd8f6; font-weight: 750; } +.vote-site-editor details button { margin: 10px 8px 0 0; } +.vote-site-dialog { width: min(820px, calc(100vw - 32px)); max-height: min(850px, calc(100vh - 32px)); padding: 22px; overflow-y: auto; border: 1px solid #315f8d; border-radius: 14px; background: #091522; color: #e8f2ff; box-shadow: 0 30px 100px rgba(0, 0, 0, .7); } +.vote-site-dialog::backdrop { background: rgba(2, 8, 15, .78); backdrop-filter: blur(3px); } +.vote-site-dialog .section-title button { min-width: 38px; } +.vote-site-dialog .check-row { color: #e8f2ff; } +.vote-site-dialog .check-row input { width: 18px; } +.danger { border-color: #8f4052 !important; color: #ffbbc7 !important; } +#vote-site-target-values { display: grid; gap: 7px; margin-top: 12px; } +#vote-site-target-values p { margin: 0; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; color: var(--muted); } +#vote-sites-panel[aria-busy="true"] .settings-targets { opacity: .65; } +@media (max-width: 1100px) { + .vote-sites-layout { grid-template-columns: 1fr; } + .vote-sites-list-card { position: static; max-height: none; } + .vote-site-list { grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); } +} +@media (max-width: 640px) { + .vote-sites-heading { align-items: stretch; } + .vote-sites-heading .action-row button { width: 100%; } + .vote-site-fields { grid-template-columns: 1fr; } + .vote-site-dialog { width: calc(100vw - 20px); padding: 16px; } +} +.rewards-layout { display: grid; grid-template-columns: minmax(220px, 300px) minmax(0, 1fr); gap: 16px; align-items: start; } +.rewards-list-card, .rewards-editor-column { min-width: 0; } +.rewards-editor-column { display: grid; gap: 16px; } +#rewards-scope-list { max-height: min(70vh, 760px); overflow-y: auto; } +#rewards-scope-list .vote-site-list-item { min-width: 0; overflow-wrap: anywhere; } +#rewards-detail .result-panel { max-height: 460px; overflow: auto; } +#rewards-detail .target-operation { overflow-wrap: anywhere; } +#rewards-advanced .reward-tree-depth-1 { padding-inline-start: 12px; } +#rewards-advanced .reward-tree-depth-2 { padding-inline-start: 24px; } +#rewards-advanced .reward-tree-depth-3 { padding-inline-start: 36px; } +#rewards-advanced .reward-tree-depth-4 { padding-inline-start: 48px; } +#rewards-advanced .reward-tree-depth-5 { padding-inline-start: 60px; } +#rewards-advanced .reward-tree-depth-6 { padding-inline-start: 72px; } +#rewards-advanced .reward-tree-depth-7 { padding-inline-start: 84px; } +#rewards-advanced .reward-tree-depth-8 { padding-inline-start: 96px; } +#rewards-editable { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 16px; margin-top: 16px; } +#rewards-editable h4, #rewards-editable p, #rewards-editable .operation-actions, #rewards-field-state { grid-column: 1 / -1; } +#rewards-editable[hidden] { display: none; } +#rewards-value { width: 100%; max-width: none; } +#rewards-summary, #rewards-preview-results, #rewards-results { overflow-wrap: anywhere; } +@media (max-width: 920px) { .rewards-layout { grid-template-columns: 1fr; } #rewards-scope-list { max-height: 260px; } } +@media (max-width: 520px) { #rewards-editable { grid-template-columns: 1fr; } } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 0545f2cd..25445af8 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -15,6 +15,15 @@ const logout = document.querySelector('#logout'); const message = document.querySelector('#message'); const welcome = document.querySelector('#welcome'); const appShell = document.querySelector('#app-shell'); +const workspace = new ControlWorkspace.Workspace(); +let settingsEditor = null; +let voteSitesEditor = null; +let rewardsEditor = null; +const scopeBar = document.querySelector('#scope-bar'); +const homeNodes = document.querySelector('#home-nodes'); +const homeSearch = document.querySelector('#home-server-search'); +let registryAvailable = false; +let activeNavigationHash = '#home'; const sidebarToggle = document.querySelector('#sidebar-toggle'); const primaryNavigation = document.querySelector('#primary-navigation'); const topbar = document.querySelector('.topbar'); @@ -342,6 +351,7 @@ function syncTopbarOffset() { const searchBounds = globalSearch.hidden ? topbarBounds : globalSearch.getBoundingClientRect(); const height = Math.ceil(Math.max(topbarBounds.bottom, searchBounds.bottom)); rootStyleRule?.['style'].setProperty('--topbar-height', `${height}px`); + rootStyleRule?.['style'].setProperty('--workspace-scope-height', `${scopeBar.hidden ? 0 : Math.ceil(scopeBar.getBoundingClientRect().height)}px`); } function scrollToAnchor(target) { @@ -351,7 +361,11 @@ function scrollToAnchor(target) { } syncTopbarOffset(); -if (typeof ResizeObserver === 'function') new ResizeObserver(syncTopbarOffset).observe(topbar); +if (typeof ResizeObserver === 'function') { + const chromeObserver = new ResizeObserver(syncTopbarOffset); + chromeObserver.observe(topbar); + chromeObserver.observe(scopeBar); +} window.addEventListener('resize', syncTopbarOffset); function text(element, value) { @@ -386,14 +400,16 @@ function connectedInspectionNodes() { } function selectedFileCapability(fileName = configurationFile.value) { - return fileName === 'bungeeconfig.yml' ? 'config.proxy-files.v1' : 'config.files.v1'; + return fileName === 'bungeeconfig.yml' ? 'config.proxy-files.v1' + : /^Rewards\/[A-Za-z0-9][A-Za-z0-9_-]{0,99}\.yml$/.test(fileName) ? 'config.reward-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) + return workspace.managementScope === 'GLOBAL' && selected?.online && isProxy(selected) && selected.acceptedCapabilities.includes(capability) ? [selected.nodeId] : []; } return targets(capability).filter(nodeId => isBackend(nodeIndex.get(nodeId))); @@ -1295,6 +1311,13 @@ async function loadSetupState() { } function applyAuthenticatedSession(body) { + settingsEditor?.clear(); + voteSitesEditor?.clear(); + rewardsEditor?.clear(); + workspace.login(); + workspace.clearPersisted(); + registryAvailable = false; + window.history.replaceState(null, '', '#home'); authenticated = true; csrfToken = body.csrfToken; approvedPreview = null; @@ -1352,12 +1375,12 @@ function applyAuthenticatedSession(body) { authCard.hidden = true; welcome.hidden = true; appShell.hidden = false; - serverPickerLabel.hidden = false; + serverPickerLabel.hidden = true; enrollmentCard.hidden = false; pageOffset = 0; renderOperationHistory(); populateProfilePicker(); - setActiveTab(tabFromHash()); + setActiveTab('home'); } function isProxy(node) { @@ -1365,11 +1388,11 @@ function isProxy(node) { } function isBackend(node) { - return !isProxy(node); + return String(node?.platform || '').toUpperCase() === 'BUKKIT'; } function roleLabel(node) { - return isProxy(node) ? 'Proxy' : 'Backend'; + return isProxy(node) ? 'Proxy' : isBackend(node) ? 'Backend' : 'Unknown role'; } function platformLabel(platform) { @@ -1433,7 +1456,7 @@ function backendCard(backend, reporterOnline) { function nodeCard(node) { const article = document.createElement('article'); - article.className = `node${selectedServerId === node.nodeId ? ' selected' : ''}`; + article.className = `node${workspace.selectedTargetIds.has(node.nodeId) ? ' selected' : ''}`; article.dataset.nodeId = node.nodeId; const header = document.createElement('div'); header.className = 'node-header'; @@ -1447,7 +1470,7 @@ function nodeCard(node) { const meta = document.createElement('div'); meta.className = 'node-meta'; - [roleLabel(node), platformLabel(node.platform), `VotingPlugin ${node.pluginVersion}`, + [roleLabel(node), platformLabel(node.platform), `VotingPlugin ${node.pluginVersion || 'version unknown'}`, ...managedCapabilities(node)].forEach(value => { const pill = text(document.createElement('span'), value); pill.className = 'pill neutral'; @@ -1469,32 +1492,21 @@ 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.proxy-files.v1', - 'config.quick-setup.v1', 'config.quick-setup.v2', 'config.proxy-method.v2'] - .some(capability => node.acceptedCapabilities.includes(capability)); - checkbox.disabled = !node.online || !controllable || node.nodeId === selectedServerId; - checkbox.checked = selectedNodes.has(node.nodeId); - if (node.nodeId === selectedServerId) selector.title = 'The primary server remains included in configuration changes.'; + checkbox.disabled = !isBackend(node); + checkbox.checked = workspace.selectedTargetIds.has(node.nodeId); + selector.title = isBackend(node) ? 'Select a management target. Selection never writes configuration.' + : 'Proxy nodes are managed through Global Settings, not Bukkit configuration.'; checkbox.addEventListener('change', () => { - const previousQuickCapability = quickSetupCapability(); - if (checkbox.checked && selectedNodes.size >= MAX_CONFIGURATION_TARGETS) { + if (checkbox.checked && workspace.selectedTargetIds.size >= MAX_CONFIGURATION_TARGETS) { checkbox.checked = false; text(operationStatus, `At most ${MAX_CONFIGURATION_TARGETS} servers can be configured at once.`); return; } - if (checkbox.checked) selectedNodes.add(node.nodeId); else selectedNodes.delete(node.nodeId); - approvedPreview = null; - approvedFilePreview = null; - approvedQuickPreview = null; - dedicatedSetupApprovals.clear(); - inputGeneration++; - reloadVotePartyWhenTargetCapabilityChanges(previousQuickCapability); - updatePluginSuggestions(); - renderSelectedServer(); - updateConfigurationButtons(); - updateExtendedButtons(); + if (!changeWorkspaceTargets(() => workspace.toggleTarget(node.nodeId))) { + checkbox.checked = workspace.selectedTargetIds.has(node.nodeId); + } }); - selector.append(checkbox, document.createTextNode('Include in configuration changes')); + selector.append(checkbox, document.createTextNode(isBackend(node) ? 'Select backend workspace target' : 'Global / Network only')); const list = document.createElement('ul'); list.className = 'node-backends'; @@ -1511,13 +1523,260 @@ function nodeCard(node) { ? `Reported by ${proxies.map(proxy => proxy.displayName).join(', ')}.` : 'No connected proxy reports this backend ID.')); } - article.append(header, meta, detail, list, selector); + const seen = text(document.createElement('p'), `Last seen: ${node.lastSeen ? new Date(node.lastSeen).toLocaleString() : 'Unknown'} · ${nodePresence(node)}`); + seen.className = 'node-detail'; + article.append(header, meta, detail, seen, list, selector); + if (!managedCapabilities(node).length) { + article.append(text(document.createElement('p'), 'No supported management capability reported. Configuration tools are unavailable.')); + } + if (isBackend(node)) { + const inspect = text(document.createElement('button'), 'Inspect server overview'); + inspect.type = 'button'; + inspect.className = 'secondary compact'; + inspect.addEventListener('click', () => inspectWorkspaceServer(node.nodeId)); + article.append(inspect); + } return article; } +// Workspace selection is independent from the source of legacy single-server tools. +function ordinaryTargetIds() { + if (!registryAvailable || !selectedServerId) return []; + const source = nodeIndex.get(selectedServerId); + if (!source?.online) return []; + if (workspace.managementScope === 'GLOBAL') return isProxy(source) ? [selectedServerId] : []; + return workspace.selectedTargetIds.has(selectedServerId) ? [selectedServerId] : []; +} + +function comparisonTargetIds() { + return [...workspace.selectedTargetIds].filter(id => nodeIndex.get(id)?.online + && isBackend(nodeIndex.get(id)) && nodeCapabilities.get(id)?.includes('config.files.v1')); +} + +function isWorkspaceOverview() { + return workspace.managementScope === 'GLOBAL' + || workspace.managementScope === 'MULTI_SERVER' && !workspace.inspectedServerId; +} + +function nodePresence(node) { + const reports = proxyReportsFor(node.nodeId).filter(proxy => proxy.online) + .flatMap(proxy => proxy.backends.filter(backend => backend.backendId === node.nodeId && backend.presenceKnown)); + if (!reports.length) return 'Minecraft presence unknown'; + if (reports.some(report => report.available !== reports[0].available || report.playerCount !== reports[0].playerCount)) { + return 'Minecraft presence conflicting between proxies'; + } + return reports[0].available ? `${reports[0].playerCount} players · proxy-reported` + : 'Minecraft unavailable · proxy-reported'; +} + +function workspaceHash(tab) { + if (tab === 'home') return '#home'; + if (tab === 'access' || tab === 'servers') return `#${tab}`; + if (workspace.managementScope === 'GLOBAL') { + return `#global/${tab === 'quick-setup' ? 'synchronization' : tab === 'configurations' ? 'configuration' : tab === 'overview' ? 'network' : tab}`; + } + if (tab === 'overview' && workspace.inspectedServerId) return `#servers/${encodeURIComponent(workspace.inspectedServerId)}/overview`; + const section = tab === 'general-settings' ? 'settings' : tab === 'quick-setup' + ? quickPreset.value === 'sync-vote-sites' ? 'sync' : 'quick-setup' : tab === 'configurations' ? 'configuration' : tab; + return `#workspace/${section}`; +} + +function renderChips(element) { + element.replaceChildren(); + workspace.selectedTargetIds.forEach(id => { + const chip = text(document.createElement('button'), `${nodeIndex.get(id)?.displayName || id} ×`); + chip.type = 'button'; + chip.className = 'scope-chip secondary compact'; + chip.setAttribute('aria-label', `Remove ${nodeIndex.get(id)?.displayName || id} from workspace`); + chip.addEventListener('click', () => changeWorkspaceTargets(() => workspace.toggleTarget(id))); + element.append(chip); + }); +} + +function renderHomeChooser() { + const filter = homeSearch.value.trim().toLocaleLowerCase(); + const items = allNodeItems.filter(node => `${node.displayName} ${node.nodeId} ${node.platform}`.toLocaleLowerCase().includes(filter)); + homeNodes.replaceChildren(); + // Registry scans are already bounded; render one page at a time in the chooser. + items.slice(0, 100).forEach(node => homeNodes.append(nodeCard(node))); + if (!items.length) text(homeNodes, registryAvailable ? 'No registered servers match this filter.' : 'Network data unavailable. Refresh to retry.'); + if (items.length > 100) homeNodes.append(text(document.createElement('p'), 'Showing the first 100 matches. Refine your search to find more nodes.')); + homeNodes.classList.toggle('empty', !items.length); + text(document.querySelector('#home-selection-count'), `${workspace.selectedTargetIds.size} backend${workspace.selectedTargetIds.size === 1 ? '' : 's'} selected`); + renderChips(document.querySelector('#home-selection-chips')); + document.querySelector('#home-continue').disabled = !registryAvailable || !workspace.selectedTargetIds.size; + document.querySelector('#home-restore').hidden = workspace.managementScope !== 'GLOBAL' || !workspace.previousTargetIds.size; +} + +function renderWorkspaceChrome(tab) { + settingsEditor?.scopeChanged(); + voteSitesEditor?.scopeChanged(); + rewardsEditor?.scopeChanged(); + const global = workspace.managementScope === 'GLOBAL'; + const chooser = tab === 'home' || !workspace.managementScope; + appShell.classList.toggle('chooser-mode', chooser); + primaryNavigation.hidden = chooser; + sidebarToggle.hidden = !authenticated || chooser; + scopeBar.hidden = !authenticated || chooser; + document.querySelectorAll('[data-server-nav]').forEach(element => { element.hidden = global; }); + document.querySelectorAll('[data-global-nav]').forEach(element => { element.hidden = !global; }); + text(document.querySelector('#scope-kind'), global ? 'Global Settings / Network' : 'Managing backend workspace'); + renderChips(document.querySelector('#scope-chips')); + if (global) text(document.querySelector('#scope-chips'), 'Network tools · targets are chosen explicitly for each operation'); + serverPickerLabel.hidden = chooser || tab === 'overview' || tab === 'general-settings' || tab === 'vote-sites' || tab === 'rewards'; + text(serverPickerLabel.querySelector('span'), global ? 'Tool source (proxy or diagnostics backend)' : 'Single-server tool source'); + const source = nodeIndex.get(selectedServerId); + text(document.querySelector('#scope-editing-notice'), global + ? 'Global mode is not “all servers”. Proxy switches and synchronization retain their explicit preview and target rules.' + : tab === 'general-settings' ? 'General Settings reads each workspace target independently. Only explicit edits are proposed, with per-target revision protection.' + : tab === 'vote-sites' ? 'Vote Sites reads each workspace target independently. Site keys are identity; property editing never invokes synchronization or rewrites rewards.' + : tab === 'rewards' ? 'Rewards reads each workspace target independently. Only explicit bounded operations can be previewed and applied.' + : `Legacy tools: rewards, guided presets and YAML use only ${source?.displayName || 'a selected source server'} (${selectedServerId || 'not selected'}). They do not copy its configuration to the workspace.${source && !workspace.selectedTargetIds.has(source.nodeId) ? ' This inspected server is outside the workspace; its configuration tools are disabled.' : ''}`); + document.querySelector('#scope-overview').hidden = global; +} + +function changeWorkspaceTargets(change) { + if (!confirmDiscardWorkspaceDrafts('changing workspace targets')) return false; + resetServerContextValues('Workspace changed. Loading the selected source when needed.'); + change(); + clearApprovals(); + if (!workspace.selectedTargetIds.has(selectedServerId) && workspace.managementScope !== 'GLOBAL') { + selectPrimaryServer([...workspace.selectedTargetIds][0] || ''); + workspace.inspect(''); + } + renderNodeViews(); + updateConfigurationButtons(); + return true; +} + +function inspectWorkspaceServer(id) { + if (!isBackend(nodeIndex.get(id))) return; + if (workspace.managementScope === 'GLOBAL') { + if (!confirmDiscardUnsavedConfiguration('returning to the server workspace')) return; + resetServerContextValues('Returning to server tools.'); + workspace.returnToServers(); + clearApprovals(); + } + if (!workspace.managementScope) workspace.setTargets([id]); + if (selectPrimaryServer(id) === false) return; + workspace.inspect(id); + setActiveTab('overview', true); +} + +function openScopeOverview() { + if (!workspace.selectedTargetIds.size) return setActiveTab('home', true); + if (!workspace.selectedTargetIds.has(selectedServerId) && selectPrimaryServer([...workspace.selectedTargetIds][0]) === false) return; + workspace.inspect(workspace.managementScope === 'SERVER' ? [...workspace.selectedTargetIds][0] : ''); + setActiveTab('overview', true); +} + +function enterGlobalWorkspace() { + if (!confirmDiscardWorkspaceDrafts('opening Global Settings')) return; + resetServerContextValues('Opening global tools.'); + workspace.enterGlobal(); + clearApprovals(); + selectPrimaryServer(allNodeItems.find(node => isProxy(node) && node.online)?.nodeId || ''); + workspace.inspect(''); + setActiveTab('network', true); +} + +function applyNavigationRoute() { + if (!authenticated) return; + const route = ControlWorkspace.parseRoute(window.location.hash); + const cancelNavigation = () => window.history.replaceState(null, '', activeNavigationHash); + if (route.scope === 'GLOBAL' && workspace.managementScope !== 'GLOBAL') { + if (!confirmDiscardWorkspaceDrafts('opening Global Settings')) return cancelNavigation(); + resetServerContextValues('Opening global tools.'); + workspace.enterGlobal(); + clearApprovals(); + selectPrimaryServer(allNodeItems.find(node => isProxy(node) && node.online)?.nodeId || ''); + } else if ((route.scope === 'WORKSPACE' || route.inspectedServerId) && workspace.managementScope === 'GLOBAL') { + if (!confirmDiscardUnsavedConfiguration('returning to the server workspace')) return cancelNavigation(); + resetServerContextValues('Returning to server tools.'); + workspace.returnToServers(); + clearApprovals(); + selectPrimaryServer([...workspace.selectedTargetIds][0] || ''); + } + if (route.scope === 'WORKSPACE' && !workspace.managementScope) { + window.history.replaceState(null, '', '#home'); + return setActiveTab('home'); + } + if (route.inspectedServerId) { + if (!isBackend(nodeIndex.get(route.inspectedServerId))) { + window.history.replaceState(null, '', '#home'); + return setActiveTab('home'); + } + if (!workspace.managementScope) workspace.setTargets([route.inspectedServerId]); + if (selectPrimaryServer(route.inspectedServerId) === false) return cancelNavigation(); + workspace.inspect(route.inspectedServerId); + } else if (route.page === 'overview') workspace.inspect(''); + const preset = route.section === 'sync' ? 'sync-vote-sites' : ''; + if (preset && preset !== quickPreset.value) { + quickPreset.value = preset; + loadedQuickSetup = null; + quickSetupDirty = false; + clearApprovals(); + } + if (route.page === 'quick-setup') updateQuickFields(); + setActiveTab(route.page); + if (route.section === 'rewards') scrollToAnchor(document.querySelector('#reward-builder-card')); +} + +function normalizeWorkspaceRouteAfterNodeRefresh(previousWorkspaceTargets, previousInspectedServerId) { + if (previousWorkspaceTargets && !workspace.managementScope) { + setActiveTab(tabFromHash()); + return; + } + const route = ControlWorkspace.parseRoute(window.location.hash); + if (previousInspectedServerId && previousInspectedServerId !== workspace.inspectedServerId + && route.inspectedServerId === previousInspectedServerId) { + const tab = workspace.managementScope ? 'overview' : 'home'; + window.history.replaceState(null, '', workspaceHash(tab)); + setActiveTab(tab); + } +} + +function renderScopeOverview() { + const aggregate = isWorkspaceOverview(); + const source = nodeIndex.get(selectedServerId); + text(document.querySelector('#overview-title'), aggregate ? workspace.managementScope === 'GLOBAL' + ? 'Network Overview' : `${workspace.selectedTargetIds.size} Servers — Workspace Overview` + : `${source?.displayName || 'Selected server'} — Server Overview`); + text(document.querySelector('#overview-eyebrow'), aggregate ? 'Management workspace' : 'Individual server inspection'); + document.querySelector('#individual-overview').hidden = aggregate; + voteActivity.closest('section').hidden = aggregate; + const grid = document.querySelector('#workspace-overview-targets'); + grid.hidden = !aggregate; + grid.replaceChildren(); + if (aggregate) { + const ids = workspace.managementScope === 'GLOBAL' ? allNodeItems.map(node => node.nodeId) : [...workspace.selectedTargetIds]; + ids.slice(0, 100).forEach(id => { + const node = nodeIndex.get(id); + if (node) grid.append(nodeCard(node)); + }); + grid.prepend(text(document.createElement('p'), 'Registry connectivity and capabilities only. Individual health requires a server inspection; unknown is not healthy.')); + } + text(document.querySelector('#metric-presence'), source ? nodePresence(source) : 'Unknown'); + text(document.querySelector('#metric-presence-detail'), 'Known only when a connected proxy reports it'); + const recent = overviewOperations()[0]; + text(document.querySelector('#metric-last-operation'), recent ? operationPhase(recent) : '—'); + text(document.querySelector('#metric-last-operation-detail'), recent ? `${operationLabel(recent)} · ${recent.createdAt ? new Date(recent.createdAt).toLocaleString() : 'Time unknown'}` : 'No retained operation for this scope'); + const health = document.querySelector('#overview-site-health'); + health.replaceChildren(); + const sites = dashboardLoadedContext === dashboardContext() ? dashboardVoteSiteHealth?.sites : null; + if (!Array.isArray(sites)) text(health, 'Vote-site inspection unavailable or not loaded. Refresh or use Vote Sites.'); + else sites.slice(0, 100).forEach(site => health.append(text(document.createElement('p'), `${site.displayName || site.siteKey || site.serviceSite || 'Site'} — ${site.status || 'Unknown'}`))); + if (Array.isArray(sites) && !sites.length) text(health, 'No configured sites reported.'); +} + +function overviewOperations() { + const ids = isWorkspaceOverview() ? workspace.selectedTargetIds : new Set(selectedServerId ? [selectedServerId] : []); + return operationHistoryItems.filter(operation => workspace.managementScope === 'GLOBAL' + || Object.keys(operation.nodeStates || {}).some(id => ids.has(id))); +} + function tabFromHash() { - const requested = window.location.hash.replace(/^#/, ''); - return tabPanels.some(panel => panel.dataset.panel === requested) ? requested : 'overview'; + return ControlWorkspace.parseRoute(window.location.hash).page; } function closeSidebar() { @@ -1531,6 +1790,10 @@ function closeSidebar() { function updateHeaderAction(tab) { const actions = { + home: ['Refresh servers', () => loadNodes()], + 'general-settings': ['Read Current', () => void settingsEditor?.read(true)], + 'vote-sites': ['Read Vote Sites', () => void voteSitesEditor?.read(true)], + rewards: ['Read Rewards', () => void rewardsEditor?.read(true)], overview: ['Refresh dashboard', () => refreshDashboard()], servers: ['Refresh servers', () => loadNodes()], network: ['Run Network Doctor', () => runNetworkDoctor.click()], @@ -1558,13 +1821,32 @@ function updateHeaderAction(tab) { } function setActiveTab(tab, updateHash = false) { - if (!tabPanels.some(panel => panel.dataset.panel === tab)) tab = 'overview'; + const requestedTab = tab; + const previousTab = tabPanels.find(panel => !panel.hidden)?.dataset.panel; + if (!tabPanels.some(panel => panel.dataset.panel === tab)) tab = 'home'; + if (workspace.managementScope === 'GLOBAL' && tab === 'overview') tab = 'network'; + if (workspace.managementScope === 'GLOBAL' && tab === 'general-settings') tab = 'network'; + if (workspace.managementScope === 'GLOBAL' && tab === 'vote-sites') tab = 'network'; + if (workspace.managementScope === 'GLOBAL' && tab === 'rewards') tab = 'network'; + if (authenticated && !workspace.managementScope && tab !== 'home' && tab !== 'access') tab = 'home'; navigationButtons.forEach(button => button.removeAttribute('aria-current')); tabButtons.forEach(button => { if (button.dataset.tab === tab) button.setAttribute('aria-current', 'page'); }); tabPanels.forEach(panel => { panel.hidden = panel.dataset.panel !== tab; }); - if (updateHash && window.location.hash !== `#${tab}`) window.history.replaceState(null, '', `#${tab}`); + if ((tab === 'general-settings' || tab === 'vote-sites' || tab === 'rewards') && previousTab !== tab) window.scrollTo({top: 0, behavior: 'instant'}); + if (updateHash || tab !== requestedTab) { + const hash = workspaceHash(tab); + if (window.location.hash !== hash) { + if (updateHash && tab === requestedTab) window.history.pushState(null, '', hash); + else window.history.replaceState(null, '', hash); + } + } + activeNavigationHash = window.location.hash || '#home'; + if (authenticated) workspace.setRoute(activeNavigationHash); + renderWorkspaceChrome(tab); + renderScopeOverview(); + renderOverviewActivity(); closeSidebar(); updateHeaderAction(tab); void autoLoadTab(tab); @@ -1630,6 +1912,28 @@ function confirmDiscardUnsavedConfiguration(context) { return window.confirm(`Discard unsaved ${configurationDirty && routingDirty ? 'YAML and routing' : configurationDirty ? 'YAML' : 'routing'} changes before ${context}?`); } +function confirmDiscardWorkspaceDrafts(context) { + if (!confirmDiscardUnsavedConfiguration(context)) return false; + if (settingsEditor?.model.dirty.size && !window.confirm(`Discard explicit General Settings edits before ${context}?`)) return false; + if (voteSitesHasDraft() && !window.confirm(`Discard explicit Vote Site edits before ${context}?`)) return false; + if (rewardsEditor?.edit && !window.confirm(`Discard explicit Rewards edits before ${context}?`)) return false; + return true; +} + +function clearSessionRewardFileOptions() { + const selected = configurationFile.value; + let removedSelected = false; + for (const option of [...(configurationFile.options || [])]) { + if (option.dataset?.sessionRewardFile !== 'true') continue; + removedSelected ||= option.value === selected; + option.remove(); + } + if (removedSelected) { + configurationFile.value = 'Config.yml'; + configurationFileSelection = 'Config.yml'; + } +} + function syncFileSelection() { const selected = nodeIndex.get(selectedServerId); const proxyFile = [...configurationFile.options].find(option => option.value === 'bungeeconfig.yml'); @@ -1658,6 +1962,14 @@ function finishAutoLoad(tab) { async function autoLoadTab(tab) { if (!authenticated) return; + if (tab === 'general-settings') { await settingsEditor?.read(false); return; } + if (tab === 'vote-sites') { + await voteSitesEditor?.read(false); + if (voteSiteHealthLoadedContext !== voteSitesContext()) void refreshVoteSiteObservations(); + return; + } + if (tab === 'rewards') { await rewardsEditor?.read(false); return; } + if (tab === 'overview' && isWorkspaceOverview()) return; if (autoLoadInFlight.has(tab)) { autoLoadPending.add(tab); return; @@ -1740,7 +2052,8 @@ function chooseDefaultServer(items) { function renderServerPicker() { const previousValue = selectedServerId; - const ordered = [...allNodeItems].sort((left, right) => { + const ordered = allNodeItems.filter(node => workspace.managementScope === 'GLOBAL' + || isBackend(node) && (workspace.selectedTargetIds.has(node.nodeId) || node.nodeId === workspace.inspectedServerId)).sort((left, right) => { const roleOrder = Number(isProxy(left)) - Number(isProxy(right)); return roleOrder || left.displayName.localeCompare(right.displayName); }); @@ -1753,7 +2066,7 @@ function renderServerPicker() { return option; })); if (!nodeIndex.has(previousValue)) { - selectedServerId = chooseDefaultServer(ordered)?.nodeId || ''; + selectedServerId = ''; if (previousValue) { resetServerContextValues('The selected server is no longer available. Load current values from its replacement.', true); } @@ -1784,7 +2097,7 @@ function renderSelectedServer() { const relationshipText = relationships.length ? ` Reported by ${relationships.map(proxy => proxy.displayName).join(', ')}.` : ''; text(selectedServerSummary, - `${roleLabel(selected)} · ${platformLabel(selected.platform)} · VotingPlugin ${selected.pluginVersion}.${relationshipText}`); + `${roleLabel(selected)} · ${platformLabel(selected.platform)} · VotingPlugin ${selected.pluginVersion || 'version unknown'}.${relationshipText} Last seen: ${selected.lastSeen ? new Date(selected.lastSeen).toLocaleString() : 'unknown'}. ${nodePresence(selected)}.`); text(configurationContext, `${selected.displayName} (${selected.nodeId}) · ${roleLabel(selected)} · ${selected.online ? 'Control connected' : 'Control disconnected'}`); const fileTargets = fileTargetsForSelection(); @@ -1805,6 +2118,7 @@ function topologyLink(backend, reporterOnline) { const registered = nodeIndex.get(backend.backendId); const link = document.createElement('span'); link.className = `topology-link ${registered?.online ? 'online' : 'warning'}`; + link.classList.toggle('inspected', backend.backendId === workspace.inspectedServerId); link.append(text(document.createElement('strong'), backend.displayName)); link.append(text(document.createElement('small'), !enrollmentsLoaded ? 'Control enrollment unavailable' @@ -2364,17 +2678,24 @@ function renderAttention(issues, total = issues.length) { if (total > 8) attentionFeed.append(text(document.createElement('small'), `${total - 8} additional issues are available in their related pages.`)); } -function addQuickAction(label, tab, scrollTarget = '', preset = '') { +function addQuickAction(label, tab, scrollTarget = '', preset = '', configView = '') { const button = text(document.createElement('button'), label); button.type = 'button'; button.className = 'secondary'; - button.addEventListener('click', () => openWorkspace(tab, scrollTarget, preset)); + button.addEventListener('click', () => { + openWorkspace(tab, scrollTarget, preset); + if (configView) setConfigView(configView); + }); overviewQuickActions.append(button); } function renderQuickActions() { overviewQuickActions.replaceChildren(); const selected = nodeIndex.get(selectedServerId); + if (workspace.selectedTargetIds.size) addQuickAction('General Settings', 'general-settings'); + if (inspectionCapableNode()) addQuickAction('Vote Sites', 'data', 'site-health-card'); + if (fileTargetsForSelection().length) addQuickAction('Configuration', 'configurations', '', '', 'yaml'); + if (comparisonTargetIds().length >= 2) addQuickAction('Compare Configuration', 'configurations', '', '', 'compare'); if (selected?.online && isBackend(selected) && selected.acceptedCapabilities.includes('config.quick-setup.v1')) { addQuickAction('Add Vote Site', 'quick-setup', 'quick-setup-card', 'vote-site'); addQuickAction('Build Reward', 'quick-setup', 'reward-builder-card'); @@ -2434,11 +2755,12 @@ function operationLabel(operation) { function renderOverviewActivity() { overviewActivity.replaceChildren(); - if (operationHistoryItems.length === 0) { + const relevantOperations = overviewOperations(); + if (relevantOperations.length === 0) { text(overviewActivity, 'No retained configuration operations.'); return; } - operationHistoryItems.slice(0, 5).forEach(operation => { + relevantOperations.slice(0, 5).forEach(operation => { const item = document.createElement('article'); item.className = 'activity-item'; const results = Object.values(operation.results || {}); @@ -2494,6 +2816,7 @@ function renderMetrics() { renderQuickActions(); renderVoteActivity(); renderOverviewActivity(); + renderScopeOverview(); } function finiteCount(value) { @@ -2771,7 +3094,7 @@ 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').filter(nodeId => isBackend(nodeIndex.get(nodeId))); + const fileTargets = comparisonTargetIds(); const driftReady = authenticated && fileTargets.length >= 2 && configurationOperationsInFlight === 0; refreshDashboardButton.disabled = !authenticated || inspectionInFlight || dashboardLoading; runNetworkDoctor.disabled = !inspectionReady; @@ -2792,7 +3115,7 @@ function updateExtendedButtons() { applyAutoSites.disabled = !quickReady || !dedicatedSetupApprovals.get('auto-create-vote-sites'); selectAllAutoSitesTargets.disabled = !authenticated || allQuickBackends.length === 0 || configurationOperationsInFlight > 0; - text(autoSitesTargetCount, `${backendTargets.length} selected ${backendTargets.length === 1 ? 'backend' : 'backends'}`); + text(autoSitesTargetCount, `${backendTargets.length} source ${backendTargets.length === 1 ? 'backend' : 'backends'} (not a bulk editor)`); loadVoteLogging.disabled = !quickReady; previewVoteLogging.disabled = !quickReady || voteLoggingState.textContent === 'Not loaded'; applyVoteLogging.disabled = !quickReady || !dedicatedSetupApprovals.get('vote-logging'); @@ -2812,6 +3135,8 @@ function updateExtendedButtons() { } function renderNodeViews() { + renderServerPicker(); + renderHomeChooser(); nodes.replaceChildren(); nodes.classList.toggle('empty', visibleNodeItems.length === 0); if (visibleNodeItems.length === 0) { @@ -2827,6 +3152,7 @@ function renderNodeViews() { renderProxyMethod(); renderDeploymentEligibility(); updateExtendedButtons(); + renderWorkspaceChrome(tabFromHash()); } function deploymentTargets() { @@ -2915,6 +3241,7 @@ function invalidateGuidedSetupReads() { } function resetServerContextValues(reason, preserveDirtyDrafts = false) { + if (!preserveDirtyDrafts || !configurationDirty) clearSessionRewardFileOptions(); dedicatedSetupApprovals.clear(); pendingDetectedVoteSite = null; lastFileReadOperation = null; @@ -2964,23 +3291,24 @@ function resetServerContextValues(reason, preserveDirtyDrafts = false) { } function selectPrimaryServer(nodeId) { - if (nodeId && !nodeIndex.has(nodeId)) return; + if (nodeId && !nodeIndex.has(nodeId)) return false; if (nodeId && nodeId === selectedServerId) { serverPicker.value = selectedServerId; - return; + return true; } if (nodeId !== selectedServerId && !confirmDiscardUnsavedConfiguration('switching servers')) { serverPicker.value = selectedServerId; - return; + return false; } selectedServerId = nodeId; + workspace.inspect(nodeId); serverPicker.value = nodeId; - selectedNodes.clear(); - if (nodeId) selectedNodes.add(nodeId); + selectedNodes = new Set(nodeId ? [nodeId] : []); resetServerContextValues('Server changed. Load current values before continuing.'); updatePluginSuggestions(); renderNodeViews(); void autoLoadTab(tabFromHash()); + return true; } function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 || proxyMethodWorkflowInFlight) { @@ -3030,7 +3358,7 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 | } function targets(capability) { - return [...selectedNodes].filter(node => nodeCapabilities.get(node)?.includes(capability)); + return ordinaryTargetIds().filter(node => nodeCapabilities.get(node)?.includes(capability)); } function backendQuickTargets() { @@ -3055,7 +3383,7 @@ function votePartyUsesV2() { } function selectedVotePartyBackends() { - return [...selectedNodes].filter(nodeId => nodeIndex.has(nodeId) && isBackend(nodeIndex.get(nodeId))); + return ordinaryTargetIds().filter(nodeId => nodeIndex.has(nodeId) && isBackend(nodeIndex.get(nodeId))); } function votePartyCommonCapability() { @@ -3252,6 +3580,11 @@ async function waitForDeployment(operation, generation) { } function discardAuthenticationState(reason) { + settingsEditor?.clear(); + voteSitesEditor?.clear(); + rewardsEditor?.clear(); + workspace.logout(); + registryAvailable = false; authenticationGeneration++; authenticated = false; csrfToken = ''; @@ -3323,6 +3656,19 @@ function discardAuthenticationState(reason) { nodePlugins.clear(); configurationForm.reset(); fileConfigurationForm.reset(); + clearSessionRewardFileOptions(); + document.querySelector('#vote-site-form').reset(); + document.querySelector('#vote-site-add-form').reset(); + const voteSiteAddDialog = document.querySelector('#vote-site-add-dialog'); + if (voteSiteAddDialog.open) voteSiteAddDialog.close(); + document.querySelector('#vote-site-search').value = ''; + document.querySelector('#vote-site-filter').value = 'all'; + document.querySelector('#vote-site-ack').checked = false; + document.querySelector('#rewards-search').value = ''; + document.querySelector('#rewards-operation').value = 'APPEND_LIST_ENTRY'; + document.querySelector('#rewards-field').value = 'Commands'; + document.querySelector('#rewards-value').value = ''; + document.querySelector('#rewards-ack').checked = false; configurationFileSelection = configurationFile.value; configurationContentPresent = false; configurationDirty = false; @@ -3485,6 +3831,18 @@ function operationContextCurrent(context) { } function invalidateConfigurationReads() { + settingsEditor?.invalidateReads(); + voteSitesEditor?.invalidateReads(); + if (authenticated && tabFromHash() === 'general-settings' && !settingsEditor?.state.busy) { + window.setTimeout(() => void settingsEditor?.read(false), 0); + } + if (authenticated && tabFromHash() === 'vote-sites' && !voteSitesEditor?.state.busy) { + window.setTimeout(() => void voteSitesEditor?.read(false), 0); + } + rewardsEditor?.invalidateReads(); + if (authenticated && tabFromHash() === 'rewards' && !rewardsEditor?.state.busy) { + window.setTimeout(() => void rewardsEditor?.read(false), 0); + } fileReadCache.clear(); lastFileReadOperation = null; clearApprovals(); @@ -3531,6 +3889,16 @@ async function waitForOperation(operation, statusElement = operationStatus, cont } async function startConfigurationOperation(path, body, statusElement = operationStatus) { + // Legacy forms remain source-only; coordinated workflows keep explicit targets. + if (path.endsWith('/preview') && path !== '/api/v1/configuration/general-settings/preview' + && path !== '/api/v1/configuration/vote-sites/preview' + && path !== '/api/v1/configuration/rewards/preview' + && !['proxy-method', 'sync-vote-sites', 'communication-test'].includes(body.configuration?.preset)) { + const allowed = ordinaryTargetIds(); + if (!Array.isArray(body.nodeIds) || body.nodeIds.length !== 1 || !allowed.includes(body.nodeIds[0])) { + throw new Error('Choose a workspace source server. This form previews only that server, not the whole workspace.'); + } + } const applyOperation = path.endsWith('/apply'); if (applyOperation) { approvedPreview = null; @@ -3688,6 +4056,12 @@ async function loadNodesOnce() { backendTopologyTruncated = registry.truncated; backendTopologyTruncatedNodeIds = registry.truncatedNodeIds; nodeIndex = new Map(registry.items.map(node => [node.nodeId, node])); + registryAvailable = true; + const previousWorkspaceTargets = [...workspace.selectedTargetIds].join('\u0000'); + const previousInspectedServerId = workspace.inspectedServerId; + // The node registry is complete here; truncated refers only to proxy topology. + workspace.reconcile(registry.items); + const workspaceTargetsChanged = previousWorkspaceTargets !== [...workspace.selectedTargetIds].join('\u0000'); const primarySessionChanged = selectedServerId && previousNodeIndex.get(selectedServerId)?.sessionId && previousNodeIndex.get(selectedServerId)?.sessionId !== nodeIndex.get(selectedServerId)?.sessionId; const selectedSessionChanged = primarySessionChanged || [...selectedNodes].some(node => previousNodeIndex.get(node)?.sessionId @@ -3762,16 +4136,19 @@ async function loadNodesOnce() { inputGeneration++; text(operationStatus, routingDraftStatus('The selected nodes changed during refresh. Preview again before apply.')); } + if (workspaceTargetsChanged) clearApprovals(); selectedNodes = filteredSelection; // A registry refresh can change the effective Vote Party contract without a // user selection event. Clear the old v2/v1 form before the normal tab // auto-load runs so a delayed or failed READ cannot expose stale values. reloadVotePartyWhenTargetCapabilityChanges(previousQuickCapability, false); renderNodeViews(); + normalizeWorkspaceRouteAfterNodeRefresh(previousWorkspaceTargets, previousInspectedServerId); updatePluginSuggestions(); updateConfigurationButtons(); if (suppressNodeAutoLoad === 0) void autoLoadTab(tabFromHash()); } catch (error) { + registryAvailable = false; visibleNodeItems = []; allNodeItems = []; nodePageMetadata = new Map(); @@ -3878,6 +4255,8 @@ logout.addEventListener('click', async () => { async function restoreSession() { const restoreGeneration = authenticationGeneration; + let savedWorkspace = null; + try { savedWorkspace = window.sessionStorage.getItem(ControlWorkspace.SESSION_KEY); } catch (_) { /* unavailable */ } try { const response = await fetch('/api/v1/auth/session', {cache: 'no-store', credentials: 'same-origin'}); if (!response.ok || response.status === 204) return; @@ -3885,6 +4264,12 @@ async function restoreSession() { if (restoreGeneration !== authenticationGeneration) return; applyAuthenticatedSession(body); await Promise.all([loadEnrollments(), loadNodes(), loadOperationHistory(), loadSnapshots()]); + if (restoreGeneration === authenticationGeneration && savedWorkspace) { + try { window.sessionStorage.setItem(ControlWorkspace.SESSION_KEY, savedWorkspace); } catch (_) { /* unavailable */ } + workspace.restore(allNodeItems); + window.history.replaceState(null, '', workspace.currentRoute); + applyNavigationRoute(); + } } catch (_) { /* The login form remains available. */ } } @@ -4689,24 +5074,8 @@ loadAutoSites.addEventListener('click', () => loadDedicatedSetup('auto-create-vo previewAutoSites.addEventListener('click', () => previewDedicatedSetup('auto-create-vote-sites')); applyAutoSites.addEventListener('click', () => applyDedicatedSetup('auto-create-vote-sites')); selectAllAutoSitesTargets.addEventListener('click', () => { - const previousQuickCapability = quickSetupCapability(); - const available = allNodeItems.filter(node => isBackend(node) && node.online - && node.acceptedCapabilities.includes('config.quick-setup.v1')); - const candidates = available - .sort((left, right) => Number(right.nodeId === selectedServerId) - Number(left.nodeId === selectedServerId)) - .slice(0, MAX_CONFIGURATION_TARGETS); - selectedNodes = new Set(candidates.map(node => node.nodeId)); - dedicatedSetupApprovals.clear(); - approvedPreview = null; - approvedFilePreview = null; - approvedQuickPreview = null; - inputGeneration++; - reloadVotePartyWhenTargetCapabilityChanges(previousQuickCapability); - renderNodeViews(); - updatePluginSuggestions(); - updateConfigurationButtons(); - text(autoSitesStatus, `${candidates.length} online ${candidates.length === 1 ? 'backend is' : 'backends are'} selected${available.length > candidates.length - ? ` (limited to ${MAX_CONFIGURATION_TARGETS} per operation)` : ''}. Choose enabled or disabled, then preview every target.`); + setActiveTab('home', true); + text(autoSitesStatus, 'Choose workspace targets on Home. This form edits only its identified source server until mixed-aware editing is available.'); }); loadVoteLogging.addEventListener('click', () => loadDedicatedSetup('vote-logging')); previewVoteLogging.addEventListener('click', () => previewDedicatedSetup('vote-logging')); @@ -4742,6 +5111,12 @@ async function refreshOverview(target = dataOverview) { } async function refreshDashboard() { + if (isWorkspaceOverview()) { + await Promise.all([loadNodes(), loadOperationHistory()]); + renderScopeOverview(); + renderOverviewActivity(); + return; + } if (dashboardLoading || inspectionInFlight) { renderMetrics(); return; @@ -4905,7 +5280,7 @@ downloadNetworkDiagnostics.addEventListener('click', () => { runDriftCheck.addEventListener('click', async () => { setConfigView('compare'); - const nodeIds = targets('config.files.v1').filter(nodeId => isBackend(nodeIndex.get(nodeId))); + const nodeIds = comparisonTargetIds(); const selectedFile = driftFile.value; const requestAuthenticationGeneration = authenticationGeneration; const requestInputGeneration = inputGeneration; @@ -4916,7 +5291,7 @@ runDriftCheck.addEventListener('click', async () => { const operation = await startConfigurationOperation('/api/v1/configuration/read', { nodeIds, configuration: {domain: 'file', fileName: selectedFile} }, driftResults); - const currentTargets = targets('config.files.v1').filter(nodeId => isBackend(nodeIndex.get(nodeId))); + const currentTargets = comparisonTargetIds(); 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 @@ -5407,6 +5782,16 @@ quickPreset.addEventListener('input', () => { void autoLoadTab('quick-setup'); }); serverPicker.addEventListener('change', () => selectPrimaryServer(serverPicker.value)); +homeSearch.addEventListener('input', renderHomeChooser); +document.querySelector('#home-refresh').addEventListener('click', () => loadNodes()); +document.querySelector('#home-select-all').addEventListener('click', () => changeWorkspaceTargets(() => workspace.selectEligible(allNodeItems, MAX_CONFIGURATION_TARGETS))); +document.querySelector('#home-clear').addEventListener('click', () => changeWorkspaceTargets(() => workspace.clearTargets())); +document.querySelector('#home-continue').addEventListener('click', openScopeOverview); +document.querySelector('#home-restore').addEventListener('click', () => { + if (changeWorkspaceTargets(() => workspace.returnToServers())) openScopeOverview(); +}); +document.querySelector('#choose-global').addEventListener('click', enterGlobalWorkspace); +document.querySelector('#scope-overview').addEventListener('click', openScopeOverview); deploymentJar.addEventListener('change', renderDeploymentEligibility); deployPlugin.addEventListener('click', async () => { const file = deploymentJar.files?.[0]; @@ -5521,6 +5906,7 @@ deployPlugin.addEventListener('click', async () => { }); tabButtons.forEach(button => button.addEventListener('click', () => { if (button.dataset.configShortcut) setConfigView(button.dataset.configShortcut); + if (button.dataset.tab === 'overview' && workspace.managementScope !== 'GLOBAL') return openScopeOverview(); setActiveTab(button.dataset.tab, true); })); configViewButtons.forEach(button => button.addEventListener('click', () => setConfigView(button.dataset.configView))); @@ -5535,7 +5921,8 @@ document.querySelectorAll('[data-open-config-view]').forEach(button => button.ad configurationFile.dispatchEvent(new Event('input')); } })); -window.addEventListener('hashchange', () => setActiveTab(tabFromHash())); +window.addEventListener('hashchange', applyNavigationRoute); +window.addEventListener('popstate', applyNavigationRoute); sidebarToggle.addEventListener('click', () => { const open = document.body.classList.toggle('sidebar-open'); sidebarToggle.setAttribute('aria-expanded', String(open)); @@ -5571,7 +5958,10 @@ document.addEventListener('click', event => { function populateGlobalSearch() { const values = ['Add Vote Site', 'Vote Sites', 'Rewards', 'Setup', 'Settings', 'Vote logging', 'Servers', 'Proxy & Routing', 'Sync Vote Sites', 'Votes & Data', 'Activity', 'Network Doctor', - 'Configuration Compare', 'Access', ...SETTINGS_SCHEMA.map(setting => setting.key), + 'Configuration Compare', 'Access', ...GENERAL_SETTING_FIELDS.map(setting => setting.path), + ...SETTINGS_SCHEMA.map(setting => setting.key), + ...new Set([...(voteSitesEditor?.model?.targets?.values?.() || [])].flatMap(target => + (target.sites || []).map(site => site.siteKey))), ...allNodeItems.flatMap(node => [node.displayName, node.nodeId])]; const unique = [...new Set(values.filter(Boolean))].slice(0, 300); globalSearchOptions.replaceChildren(...unique.map(value => { @@ -5582,11 +5972,11 @@ function populateGlobalSearch() { } const GLOBAL_PAGE_SHORTCUTS = new Map([ - ['add vote site', {tab: 'quick-setup', scrollTarget: 'quick-setup-card', preset: 'vote-site'}], - ['vote sites', {tab: 'data', scrollTarget: 'site-health-card'}], - ['rewards', {tab: 'quick-setup', scrollTarget: 'reward-builder-card'}], + ['add vote site', {tab: 'vote-sites', action: 'add-site'}], + ['vote sites', {tab: 'vote-sites'}], + ['rewards', {tab: 'rewards'}], ['setup', {tab: 'quick-setup', scrollTarget: 'quick-setup-card'}], - ['settings', {tab: 'quick-setup', scrollTarget: 'settings-catalog-card'}], + ['settings', {tab: 'general-settings'}], ['vote logging', {tab: 'quick-setup', scrollTarget: 'quick-setup-card', preset: 'vote-logging'}], ['servers', {tab: 'servers'}], ['proxy & routing', {tab: 'network'}], @@ -5601,6 +5991,7 @@ const GLOBAL_PAGE_SHORTCUTS = new Map([ function openGlobalShortcut(destination) { if (destination.configView) setConfigView(destination.configView); openWorkspace(destination.tab, destination.scrollTarget || '', destination.preset || ''); + if (destination.action === 'add-site' && tabFromHash() === 'vote-sites') openVoteSiteAdd(); } globalSearch.addEventListener('submit', event => { @@ -5629,14 +6020,20 @@ globalSearch.addEventListener('submit', event => { globalSearchInput.value = ''; return; } - const setting = SETTINGS_SCHEMA.find(item => Object.values(item).join(' ').toLowerCase().includes(normalized)); + const setting = GENERAL_SETTING_FIELDS.find(item => Object.values(item).join(' ').toLowerCase().includes(normalized)); + const site = voteSitesEditor?.model && [...voteSitesEditor.model.targets.values()] + .flatMap(target => target.sites || []).find(candidate => candidate.siteKey.toLowerCase() === normalized); if (setting) { - settingsFilter.value = query; - renderSettingsCatalog(); - openWorkspace('quick-setup', 'settings-catalog-card'); + openWorkspace('general-settings'); + } else if (site) { + if (voteSitesHasDraft() && voteSitesEditor.model.selectedSiteKey !== site.siteKey + && !window.confirm('Discard the current Vote Site draft and inspect another site?')) return; + openWorkspace('vote-sites'); + voteSitesEditor.selectSite(site.siteKey); + renderVoteSites(); } else if (normalized.includes('sync')) openWorkspace('quick-setup', 'quick-setup-card', 'sync-vote-sites'); - else if (normalized.includes('reward')) openWorkspace('quick-setup', 'reward-builder-card'); - else if (normalized.includes('vote site') || normalized.includes('service') || normalized.includes('planet')) openWorkspace('data', 'site-health-card'); + else if (normalized.includes('reward')) openWorkspace('rewards'); + else if (normalized.includes('vote site') || normalized.includes('service') || normalized.includes('planet')) openWorkspace('vote-sites'); else if (normalized.includes('vote logging')) openWorkspace('quick-setup', 'quick-setup-card', 'vote-logging'); else if (normalized.includes('doctor') || normalized.includes('diagnostic')) openWorkspace('network', 'network-doctor-card'); else if (['proxy', 'routing', 'redis', 'mqtt', 'mysql', 'sockets', 'transport'].some(value => normalized.includes(value))) openWorkspace('network'); @@ -5655,10 +6052,844 @@ globalSearch.addEventListener('submit', event => { globalSearchInput.value = ''; }); window.addEventListener('beforeunload', event => { - if (!configurationDirty && !routingDirty) return; + if (!configurationDirty && !routingDirty && !settingsEditor?.model.dirty.size && !voteSitesHasDraft() && !rewardsEditor?.edit) return; event.preventDefault(); event.returnValue = ''; }); +const VOTE_SITE_FIELDS = [ + {path: 'Enabled', label: 'Enabled', control: '#vote-site-enabled', type: 'boolean'}, + {path: 'Name', label: 'Display name', control: '#vote-site-name', type: 'string'}, + {path: 'ServiceSite', label: 'Service site', control: '#vote-site-service', type: 'string'}, + {path: 'VoteURL', label: 'Vote URL', control: '#vote-site-url', type: 'string'}, + {path: 'VoteDelay', label: 'Vote delay', control: '#vote-site-delay', type: 'string'}, + {path: 'Priority', label: 'Priority', control: '#vote-site-priority', type: 'integer'}, + {path: 'DisplayItem.Material', label: 'Display material', control: '#vote-site-material', type: 'string'}, + {path: 'DisplayItem.Amount', label: 'Display amount', control: '#vote-site-amount', type: 'integer'}, + {path: 'Hidden', label: 'Hidden', control: '#vote-site-hidden', type: 'boolean'} +]; +let voteSiteHealthByNode = new Map(); +let voteSiteHealthLoadedContext = ''; +let voteSiteHealthFlight = null; + +function voteSitesHasDraft() { + const model = voteSitesEditor?.model; + return Boolean(model && (model.dirty.size || Object.keys(model.addFields || {}).length + || model.workflow === 'REMOVE')); +} + +function voteSitesContext() { + return JSON.stringify([authenticated, authenticationGeneration, workspace.managementScope, + [...workspace.selectedTargetIds].map(id => [id, nodeIndex.get(id)?.sessionId || '', + nodeIndex.get(id)?.online === true, nodeIndex.get(id)?.acceptedCapabilities?.includes('config.files.v1') === true])]); +} + +function voteSiteKeys(model) { + const keys = new Set(); + model.targets.forEach(target => (target.sites || []).forEach(site => keys.add(site.siteKey))); + return [...keys].sort((left, right) => left.localeCompare(right, undefined, {sensitivity: 'base'})); +} + +function voteSiteValueLabel(value) { + if (value === true) return 'On'; + if (value === false) return 'Off'; + return value == null ? 'Unavailable' : String(value); +} + +function voteSiteFieldInput(field, aggregate) { + const control = document.querySelector(field.control); + const model = voteSitesEditor.model; + const dirty = model.dirty.has(field.path); + const addDraft = model.workflow === 'CREATE_MISSING' && Object.hasOwn(model.addFields, field.path); + const requested = dirty ? model.dirty.get(field.path) : addDraft ? model.addFields[field.path] : undefined; + // Missing sites do not erase the value shared by sites that exist. Failed or + // unsupported reads, however, must never look like a fully-known workspace. + const same = aggregate.supportedState === 'SAME' && !['ERROR', 'UNSUPPORTED'].includes(aggregate.state); + const value = requested !== undefined ? requested : same ? aggregate.value : undefined; + control.title = aggregate.state === 'MISSING' && same + ? `Same on ${aggregate.targets.filter(target => target.status === 'AVAILABLE').length} present sites; other workspace targets are missing this site.` : ''; + control.dataset.dirty = String(dirty || addDraft); + control.dataset.mixed = String(!dirty && !addDraft && aggregate.supportedState === 'MIXED'); + control.dataset.partial = String(!dirty && !addDraft && aggregate.state === 'MISSING' && same); + if (field.type === 'boolean') { + control.options[0].textContent = aggregate.supportedState === 'MIXED' ? '— Mixed values —' + : aggregate.state === 'MISSING' ? 'Missing on one or more targets' : aggregate.state; + control.value = typeof value === 'boolean' ? String(value) : ''; + } else { + control.value = value === undefined ? '' : String(value); + control.placeholder = aggregate.supportedState === 'MIXED' ? 'Mixed values' + : aggregate.state === 'MISSING' ? 'Missing on one or more targets' : aggregate.state === 'SAME' ? '' : aggregate.state; + } + control.disabled = voteSitesEditor.state.busy || !aggregate.targets.some(target => target.status === 'AVAILABLE') && !addDraft; +} + +function voteSiteStatusForList(model, key) { + const aggregate = model.aggregate(key); + const enabled = model.aggregateField('Enabled', key); + if (aggregate.targets.some(target => target.status === 'ERROR')) return ['Read error', 'error']; + if (aggregate.targets.some(target => target.status === 'UNSUPPORTED')) return ['Unsupported target', 'warning']; + if (aggregate.presence === 'PARTIAL') return ['Missing on targets', 'warning']; + if (Object.values(aggregate.fields).some(field => field.supportedState === 'MIXED')) return ['Mixed', 'warning']; + if (enabled.supportedState === 'SAME' && enabled.value === false) return ['Disabled', 'neutral']; + return ['Configured', 'online']; +} + +function renderVoteSiteList(model) { + const list = document.querySelector('#vote-site-list'); + const query = document.querySelector('#vote-site-search').value.trim().toLocaleLowerCase(); + const filter = document.querySelector('#vote-site-filter').value; + list.replaceChildren(); + const keys = voteSiteKeys(model).filter(key => { + const aggregate = model.aggregate(key); + const haystack = [key, ...aggregate.targets.flatMap(target => target.site + ? [target.site.fields.Name?.value, target.site.fields.ServiceSite?.value] : [])] + .filter(value => typeof value === 'string').join(' ').toLocaleLowerCase(); + if (query && !haystack.includes(query)) return false; + const enabled = model.aggregateField('Enabled', key); + if (filter === 'enabled') return enabled.supportedState === 'SAME' && enabled.value === true; + if (filter === 'disabled') return enabled.supportedState === 'SAME' && enabled.value === false; + if (filter === 'mixed') return Object.values(aggregate.fields).some(field => field.supportedState === 'MIXED'); + if (filter === 'partial') return aggregate.presence === 'PARTIAL'; + return true; + }); + keys.forEach(key => { + const aggregate = model.aggregate(key); + const name = model.aggregateField('Name', key); + const service = model.aggregateField('ServiceSite', key); + const present = aggregate.targets.filter(target => target.site).length; + const [status, statusClass] = voteSiteStatusForList(model, key); + const button = document.createElement('button'); button.type = 'button'; button.className = 'vote-site-list-item'; + button.setAttribute('role', 'option'); button.setAttribute('aria-selected', String(model.selectedSiteKey === key)); + button.append(text(document.createElement('strong'), name.supportedState === 'SAME' && name.value ? name.value : key), + text(document.createElement('small'), key), + text(document.createElement('small'), service.supportedState === 'SAME' && service.value ? `Service: ${service.value}` : `ServiceSite: ${service.supportedState.toLowerCase()}`)); + const pills = document.createElement('div'); pills.className = 'pills'; + const state = text(document.createElement('span'), status); state.className = `pill ${statusClass}`; + pills.append(state, text(document.createElement('span'), `Targets ${present}/${model.targets.size}`)); + const observed = [...voteSiteHealthByNode.values()].flatMap(value => Array.isArray(value?.sites) ? value.sites : []) + .filter(site => site.siteKey === key).map(site => site.status).filter(Boolean); + if (observed.length) { const health = text(document.createElement('span'), [...new Set(observed)].join(', ')); health.className = 'pill neutral'; pills.append(health); } + button.append(pills); + button.addEventListener('click', () => { + if (voteSitesHasDraft() && model.selectedSiteKey !== key && !window.confirm('Discard the current Vote Site draft and inspect another site?')) return; + voteSitesEditor.selectSite(key); renderVoteSites(); + }); + list.append(button); + }); + if (!keys.length) text(list, voteSiteKeys(model).length ? 'No Vote Sites match this filter.' : 'No configured Vote Sites were returned.'); +} + +function renderVoteSites() { + if (!voteSitesEditor) return; + if (voteSiteHealthLoadedContext && voteSiteHealthLoadedContext !== voteSitesContext()) { + voteSiteHealthByNode.clear(); + voteSiteHealthLoadedContext = ''; + text(document.querySelector('#vote-sites-detected'), 'Workspace changed; loading fresh observations when available.'); + } + const model = voteSitesEditor.model; + const state = voteSitesEditor.state; + document.querySelector('#vote-sites-panel').setAttribute('aria-busy', String(Boolean(state.busy))); + text(document.querySelector('#vote-sites-status'), state.error || state.message || (state.busy ? 'Loading / processing…' : 'Only explicit site changes will be proposed.')); + const targets = document.querySelector('#vote-sites-read-targets'); targets.replaceChildren(); + model.targets.forEach(target => { + const item = text(document.createElement('div'), `${nodeIndex.get(target.nodeId)?.displayName || target.nodeId} · ${target.status === 'AVAILABLE' ? `${target.sites.length} sites loaded` : target.code || target.status}${target.message ? ` · ${target.message}` : ''}`); + item.className = `settings-target ${target.status === 'AVAILABLE' ? 'loaded' : 'warning'}`; targets.append(item); + }); + renderVoteSiteList(model); + const key = model.selectedSiteKey; + const editor = document.querySelector('#vote-site-form'); + document.querySelector('#vote-site-empty').hidden = Boolean(key); + editor.hidden = !key; + if (!key) { + document.querySelector('#vote-site-preview').disabled = true; + document.querySelector('#vote-site-apply').disabled = true; + return; + } + const aggregate = model.aggregate(key); + const present = aggregate.targets.filter(target => target.site).length; + const existingSites = aggregate.targets.map(target => target.site).filter(Boolean); + text(document.querySelector('#vote-site-title'), model.workflow === 'REMOVE' ? `Remove ${key}` : key); + text(document.querySelector('#vote-site-key'), key); + text(document.querySelector('#vote-site-presence'), `${aggregate.presence} · present on ${present} of ${model.targets.size} selected targets`); + const [status, statusClass] = voteSiteStatusForList(model, key); + text(document.querySelector('#vote-site-state'), status); document.querySelector('#vote-site-state').className = `pill ${statusClass}`; + const rewardStates = existingSites.map(site => site.rewardsConfigured); + text(document.querySelector('#vote-site-rewards'), !rewardStates.length ? 'Unavailable' + : rewardStates.every(Boolean) ? 'Configured on all present targets' + : rewardStates.some(Boolean) ? 'Mixed · configured on some targets' : 'Not configured'); + VOTE_SITE_FIELDS.forEach(field => voteSiteFieldInput(field, model.aggregateField(field.path, key))); + const details = document.querySelector('#vote-site-target-values'); details.replaceChildren(); + aggregate.targets.forEach(target => { + const values = target.site ? VOTE_SITE_FIELDS.map(field => { + const snapshot = target.site.fields[field.path]; + return `${field.label}: ${snapshot?.status === 'AVAILABLE' ? voteSiteValueLabel(snapshot.value) : snapshot?.status || 'MISSING'}`; + }).join(' · ') : target.status === 'MISSING' ? 'Site missing' : `${target.status}${target.message ? ` · ${target.message}` : ''}`; + details.append(text(document.createElement('p'), `${nodeIndex.get(target.nodeId)?.displayName || target.nodeId}: ${values}`)); + }); + const partialLabel = document.querySelector('#vote-site-partial-choice-label'); + partialLabel.hidden = aggregate.presence !== 'PARTIAL' || model.workflow === 'REMOVE'; + document.querySelector('#vote-site-partial-choice').value = model.partialPolicy || 'existing'; + document.querySelector('#vote-site-remove').disabled = state.busy || !existingSites.some(site => site.editable); + document.querySelector('#vote-site-reset').disabled = state.busy || !voteSitesHasDraft(); + const plans = model.plans(); const changed = plans.filter(plan => ['ADD', 'EDIT', 'REMOVE'].includes(plan.operation)); + const summary = document.querySelector('#vote-site-summary'); summary.replaceChildren( + text(document.createElement('p'), `Target servers: ${model.targets.size}`), + text(document.createElement('p'), `Site: ${key}`), + text(document.createElement('p'), `Explicit dirty properties: ${model.dirty.size}`), + text(document.createElement('p'), `Changed targets: ${changed.length}`), + text(document.createElement('p'), `Affected file: ${changed.length ? 'VoteSites.yml' : 'none'}`), + text(document.createElement('p'), `Runtime impact: ${changed.length ? 'VotingPlugin configuration reload required' : 'No runtime action required'}`), + text(document.createElement('p'), `Preview: ${model.previewCurrent() ? 'Ready' : state.previewState}`)); + plans.forEach(plan => { + const changes = plan.operation === 'EDIT' ? Object.entries(plan.changes).map(([path, value]) => `${path} → ${voteSiteValueLabel(value)}`).join(', ') + : plan.operation === 'ADD' ? 'Site will be created with the reviewed fields' + : plan.operation === 'REMOVE' ? `VoteSites.${key} will be removed` : plan.skipped.length ? plan.skipped.map(item => item.field ? `${item.field} (${item.status})` : item.status).join(', ') : 'No change'; + summary.append(text(document.createElement('p'), `${nodeIndex.get(plan.nodeId)?.displayName || plan.nodeId}: ${plan.operation} · ${changes}`)); + }); + const exclusions = plans.some(plan => plan.operation === 'SKIP' || plan.skipped.length); + document.querySelector('#vote-site-ack-label').hidden = !exclusions; + document.querySelector('#vote-site-ack').disabled = state.busy; + document.querySelector('#vote-sites-read').disabled = state.busy || !model.targets.size; + document.querySelector('#vote-sites-retry').disabled = state.busy || ![...model.targets.values()].some(target => target.status === 'ERROR'); + document.querySelector('#vote-site-preview').disabled = state.busy || !changed.length || model.exceedsChangedTargetLimit(8); + document.querySelector('#vote-site-apply').disabled = state.busy || !model.previewCurrent() || (exclusions && !document.querySelector('#vote-site-ack').checked); + const previewResults = document.querySelector('#vote-site-preview-results'); previewResults.replaceChildren(); + const previewItems = model.previewCurrent()?.items || state.previewItems || []; + if (!previewItems.length) text(previewResults, state.previewState === 'Stale' ? 'Stale — read/re-preview required.' : 'Not previewed.'); + previewItems.forEach(item => { + const plan = plans.find(candidate => candidate.nodeId === item.nodeId); + const section = document.createElement('section'); section.className = 'target-operation'; + section.append(text(document.createElement('h4'), nodeIndex.get(item.nodeId)?.displayName || item.nodeId), + text(document.createElement('p'), `${item.status}${item.operationId ? ` · operation ${item.operationId}` : ''}${item.message ? ` · ${item.message}` : ''}`)); + if (plan?.operation === 'EDIT') Object.entries(plan.changes).forEach(([path, value]) => { + const before = model.targets.get(item.nodeId)?.sites.find(site => site.siteKey === key)?.fields[path]?.value; + section.append(text(document.createElement('p'), `VoteSites.yml → VoteSites.${key}.${path}: ${voteSiteValueLabel(before)} → ${voteSiteValueLabel(value)}`)); + }); + if (plan?.operation === 'ADD') VOTE_SITE_FIELDS.forEach(field => section.append(text(document.createElement('p'), `VoteSites.${key}.${field.path}: ${voteSiteValueLabel(plan.fields[field.path])}`))); + if (plan?.operation === 'REMOVE') section.append(text(document.createElement('p'), `VoteSites.yml → remove VoteSites.${key}`)); + (item.skipped || []).forEach(skipped => section.append(text(document.createElement('p'), `Excluded: ${skipped.field || 'site'} (${skipped.status})`))); + previewResults.append(section); + }); + const results = document.querySelector('#vote-site-results'); results.replaceChildren(); + if (!model.results.size) text(results, 'No apply performed.'); + model.results.forEach((data, nodeId) => { + const result = data.result || data.operation?.results?.[nodeId] || data; + const section = document.createElement('section'); section.className = 'target-operation'; + section.append(text(document.createElement('h4'), nodeIndex.get(nodeId)?.displayName || nodeId), + text(document.createElement('p'), `${result.success ? '✓ written' : `✗ ${result.code || data.status || 'not applied'}: ${result.message || data.message || ''}`} · ${result.reloaded ? '✓ reloaded' : 'reload not confirmed'}${result.rolledBack ? ' · ✓ automatic local rollback' : ''}${data.operation?.operationId ? ` · operation ${data.operation.operationId}` : ''}`), + text(document.createElement('p'), data.confirmed ? '✓ Confirmed by a fresh VoteSites.yml read' : '✗ Requested site state was not confirmed; do not assume it is active.')); + results.append(section); + }); +} + +function voteSiteAddValues() { + return {Enabled: document.querySelector('#vote-site-add-enabled').checked, + Name: document.querySelector('#vote-site-add-name').value, + ServiceSite: document.querySelector('#vote-site-add-service').value, + VoteURL: document.querySelector('#vote-site-add-url').value, + VoteDelay: document.querySelector('#vote-site-add-delay').value, + Priority: ControlVoteSites.parseIntegerField('Priority', document.querySelector('#vote-site-add-priority').value), + Hidden: document.querySelector('#vote-site-add-hidden').checked, + 'DisplayItem.Material': document.querySelector('#vote-site-add-material').value, + 'DisplayItem.Amount': ControlVoteSites.parseIntegerField('DisplayItem.Amount', document.querySelector('#vote-site-add-amount').value)}; +} + +function updateVoteSiteAddConflicts() { + const key = document.querySelector('#vote-site-add-key').value.trim(); + const caseConflict = voteSiteKeys(voteSitesEditor.model).find(siteKey => siteKey !== key && siteKey.toLowerCase() === key.toLowerCase()); + const aggregate = key && voteSitesEditor ? voteSitesEditor.model.aggregate(key) : null; + const existing = aggregate ? aggregate.targets.filter(target => target.site).map(target => nodeIndex.get(target.nodeId)?.displayName || target.nodeId) : []; + const missing = aggregate ? aggregate.targets.filter(target => target.status === 'MISSING' && !target.site).map(target => nodeIndex.get(target.nodeId)?.displayName || target.nodeId) : []; + text(document.querySelector('#vote-site-add-conflicts'), caseConflict ? `A site named ${caseConflict} already exists; site keys differing only by case are ambiguous.` + : !key ? 'Enter a key to check the current workspace inventory.' + : `Existing: ${existing.join(', ') || 'none'} · Missing: ${missing.join(', ') || 'none'}${aggregate?.targets.some(target => !['AVAILABLE', 'MISSING'].includes(target.status)) ? ' · Some targets are unavailable or unsupported.' : ''}`); + document.querySelector('#vote-site-add-existing-label').hidden = !existing.length; + if (!existing.length) document.querySelector('#vote-site-add-existing').value = 'missing'; +} + +function openVoteSiteAdd(prefill = {}) { + const dialog = document.querySelector('#vote-site-add-dialog'); + document.querySelector('#vote-site-add-form').reset(); + document.querySelector('#vote-site-add-key').value = prefill.siteKey || ''; + document.querySelector('#vote-site-add-name').value = prefill.Name || prefill.siteKey || ''; + document.querySelector('#vote-site-add-service').value = prefill.ServiceSite || ''; + document.querySelector('#vote-site-add-url').value = prefill.VoteURL || ''; + document.querySelector('#vote-site-add-delay').value = prefill.VoteDelay || '24h'; + document.querySelector('#vote-site-add-priority').value = prefill.Priority ?? 5; + document.querySelector('#vote-site-add-material').value = prefill['DisplayItem.Material'] || 'DIAMOND'; + document.querySelector('#vote-site-add-amount').value = prefill['DisplayItem.Amount'] ?? 1; + document.querySelector('#vote-site-add-enabled').checked = prefill.Enabled !== false; + document.querySelector('#vote-site-add-hidden').checked = prefill.Hidden === true; + document.querySelector('#vote-site-add-existing').value = 'cancel'; + updateVoteSiteAddConflicts(); dialog.showModal(); +} + +async function refreshVoteSiteObservations() { + const context = voteSitesContext(); + if (voteSiteHealthFlight && voteSiteHealthFlight.context === context) return voteSiteHealthFlight.promise; + const eligible = [...workspace.selectedTargetIds].map(id => nodeIndex.get(id)).filter(node => node?.online + && node.acceptedCapabilities?.includes('data.inspect.v1')).slice(0, 8); + const container = document.querySelector('#vote-sites-detected'); + if (!eligible.length) { text(container, 'No selected backend currently supports read-only vote-site health inspection.'); return; } + text(container, 'Reading observed service names and configured-site health…'); + const flight = {context, promise: null}; + flight.promise = Promise.all(eligible.map(async node => { + try { return [node.nodeId, normalizeDashboardVoteSiteHealth((await runInspectionOnNode(node, 'vote-site-health', {days: '30'}, {manageBusy: false, contextCurrent: () => voteSitesContext() === context})).result).result]; } + catch (error) { return [node.nodeId, {error: error.message || 'Inspection failed'}]; } + })).then(entries => { + if (voteSitesContext() !== context) return; + voteSiteHealthByNode = new Map(entries); voteSiteHealthLoadedContext = context; container.replaceChildren(); + entries.forEach(([nodeId, result]) => { + const section = document.createElement('section'); section.className = 'target-operation'; + section.append(text(document.createElement('h4'), nodeIndex.get(nodeId)?.displayName || nodeId)); + if (result.error) section.append(text(document.createElement('p'), `Read unavailable: ${result.error}`)); + else { + section.append(text(document.createElement('p'), `${Array.isArray(result.sites) ? result.sites.length : 0} configured site health records. “No recent votes” is not an external website failure.`)); + (result.detectedUnconfiguredServices || []).forEach(service => { + const row = document.createElement('div'); row.className = 'detected-actions'; + row.append(text(document.createElement('span'), `${service} · observed but not configured`)); + const create = text(document.createElement('button'), 'Create Vote Site'); create.type = 'button'; create.className = 'secondary compact'; + create.addEventListener('click', () => openVoteSiteAdd({siteKey: String(service).replace(/[.\s]+/g, '_').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64), Name: String(service).slice(0, 200), ServiceSite: String(service).slice(0, 2048)})); + row.append(create); section.append(row); + }); + } + container.append(section); + }); + if (workspace.selectedTargetIds.size > eligible.length) container.append(text(document.createElement('p'), `Health inspection is bounded to 8 capable targets per refresh; configuration inventory still includes all ${workspace.selectedTargetIds.size} workspace targets.`)); + renderVoteSiteList(voteSitesEditor.model); + }).finally(() => { if (voteSiteHealthFlight === flight) voteSiteHealthFlight = null; }); + voteSiteHealthFlight = flight; return flight.promise; +} +const GENERAL_SETTING_FIELDS = [ + {path: 'ProcessRewards', label: 'Process rewards', category: 'Voting & rewards', defaultValue: true, description: 'Allow VotingPlugin to process vote rewards.'}, + {path: 'ExtraAllSitesCheck', label: 'Extra all-sites check', category: 'Voting & rewards', defaultValue: false, description: 'Enable the additional duplicate all-sites reward check.'}, + {path: 'CountFakeVotes', label: 'Count fake votes', category: 'Voting & rewards', defaultValue: true, description: 'Include fake votes in vote totals.'}, + {path: 'AutoCreateVoteSites', label: 'Auto-create vote sites', category: 'Vote sites', defaultValue: true, description: 'Automatically create a site when an unknown service sends a vote.'}, + {path: 'DisableNoServiceSiteMessage', label: 'Hide unknown service-site warnings', category: 'Vote sites', defaultValue: false, description: 'Suppress the missing service-site warning.'}, + {path: 'ExtraVoteShopCheck', label: 'Extra vote shop check', category: 'Vote shop', defaultValue: true, description: 'Apply the additional vote shop purchase check.'}, + {path: 'UseVoteGUIMainCommand', label: 'Open vote GUI from main command', category: 'Interface', defaultValue: false, description: 'Use the vote GUI for the main vote command.'}, + {path: 'CloseInventoryOnVote', label: 'Close inventory when voting', category: 'Interface', defaultValue: true, description: 'Close the open vote inventory after a vote.'}, + {path: 'DisableUpdateChecking', label: 'Disable update checking', category: 'Maintenance', defaultValue: false, restart: true, description: 'Disable update checks. A backend restart is required to reconcile the update-check scheduler lifecycle.'} +]; + +function generalSettingsContext() { + return JSON.stringify([authenticated, authenticationGeneration, workspace.managementScope, + [...workspace.selectedTargetIds].map(id => [id, nodeIndex.get(id)?.sessionId || '', + nodeIndex.get(id)?.online === true, nodeIndex.get(id)?.acceptedCapabilities?.includes('config.files.v1') === true])]); +} + +function settingValueLabel(value) { return value === true ? 'On' : value === false ? 'Off' : 'Unavailable'; } + +function renderGeneralSettings() { + if (!settingsEditor) return; + const model = settingsEditor.model; + const state = settingsEditor.state; + const plans = model.plans(); + const preview = model.previewCurrent(); + const container = document.querySelector('#general-settings-fields'); + // Keep focused controls stable during refreshes and status updates. + if (!container.children.length) { + [...new Set(GENERAL_SETTING_FIELDS.map(field => field.category))].forEach(category => { + const card = document.createElement('section'); + card.className = 'card stacked-card'; + card.append(text(document.createElement('h3'), category)); + GENERAL_SETTING_FIELDS.filter(field => field.category === category).forEach(field => { + const row = document.createElement('div'); row.className = 'general-setting-row'; + const label = text(document.createElement('label'), field.label); + label.htmlFor = `general-setting-${field.path}`; + label.append(text(document.createElement('small'), `${field.description} Config.yml → ${field.path}. Documented default: ${settingValueLabel(field.defaultValue)} (not substituted for missing values).`)); + const select = document.createElement('select'); select.id = label.htmlFor; + ['', 'true', 'false'].forEach(value => { + const option = document.createElement('option'); option.value = value; + option.textContent = value ? value === 'true' ? 'On' : 'Off' : 'Read required'; + option.disabled = !value; select.append(option); + }); + select.addEventListener('change', () => { + if (select.value === 'true' || select.value === 'false') { + document.querySelector('#general-settings-ack').checked = false; + settingsEditor.edit(field.path, select.value === 'true'); + } + }); + const reset = text(document.createElement('button'), 'Reset'); reset.type = 'button'; reset.className = 'secondary compact'; + reset.id = `general-setting-reset-${field.path}`; + reset.addEventListener('click', () => { document.querySelector('#general-settings-ack').checked = false; settingsEditor.reset(field.path); }); + const details = document.createElement('details'); + details.append(text(document.createElement('summary'), 'Per-server values')); + const values = document.createElement('div'); values.id = `general-setting-values-${field.path}`; details.append(values); + row.append(label, select, reset, details); card.append(row); + }); + container.append(card); + }); + } + GENERAL_SETTING_FIELDS.forEach(field => { + const aggregate = model.aggregate(field.path); + const dirty = model.dirty.has(field.path); + const select = document.querySelector(`#general-setting-${field.path}`); + select.options[0].textContent = aggregate.state === 'SAME' ? 'Current value' + : aggregate.state === 'MIXED' ? '— Mixed values —' : `${aggregate.state} · ${aggregate.supportedState === 'SAME' ? `supported targets ${settingValueLabel(aggregate.value)}` : aggregate.supportedState.toLowerCase()}`; + select.value = dirty ? String(model.dirty.get(field.path)) : aggregate.state === 'SAME' ? String(aggregate.value) : ''; + select.disabled = state.busy || !aggregate.targets.some(target => target.status === 'AVAILABLE'); + select.setAttribute('aria-describedby', `general-setting-values-${field.path}`); + const reset = document.querySelector(`#general-setting-reset-${field.path}`); reset.disabled = state.busy || !dirty; + const values = document.querySelector(`#general-setting-values-${field.path}`); + values.replaceChildren(...aggregate.targets.map(target => text(document.createElement('p'), + `${nodeIndex.get(target.id)?.displayName || target.id}: ${target.status === 'AVAILABLE' ? settingValueLabel(target.value) : target.status}${dirty ? ` · requested ${settingValueLabel(model.dirty.get(field.path))}` : ''}`))); + }); + text(document.querySelector('#general-settings-status'), state.error || state.message || (state.busy ? 'Loading / processing…' : 'Only explicit changes will be proposed.')); + document.querySelector('#general-settings-panel').setAttribute('aria-busy', String(Boolean(state.busy))); + const targetList = document.querySelector('#general-settings-targets'); + targetList.replaceChildren(...[...model.targets.values()].map(target => { + const item = text(document.createElement('div'), `${nodeIndex.get(target.id)?.displayName || target.id} · ${target.status === 'AVAILABLE' ? 'Loaded' : target.status}${target.code ? ` · ${target.code}` : ''}${target.message ? ` · ${target.message}` : ''}`); + item.className = `settings-target ${target.status === 'AVAILABLE' ? 'loaded' : 'warning'}`; + const chip = [...document.querySelectorAll('#scope-chips button')].find(button => button.textContent.startsWith(nodeIndex.get(target.id)?.displayName || target.id)); + if (chip) { + chip.title = `Configuration: ${target.status}`; + text(chip, `${nodeIndex.get(target.id)?.displayName || target.id} · Config ${target.status === 'AVAILABLE' ? 'loaded' : target.code || target.status} ×`); + } + return item; + })); + const summary = document.querySelector('#general-settings-summary'); + summary.replaceChildren(text(document.createElement('p'), `Target servers: ${model.targets.size}`), + text(document.createElement('p'), `Explicit dirty settings: ${model.dirty.size}`), + text(document.createElement('p'), `Affected files: ${model.dirty.size ? 'Config.yml' : 'none'}`), + text(document.createElement('p'), `Preview: ${preview ? 'Ready' : state.previewState || 'Not previewed'}`)); + model.dirty.forEach((value, path) => { + const field = GENERAL_SETTING_FIELDS.find(item => item.path === path); + const aggregate = model.aggregate(path); + summary.append(text(document.createElement('p'), `${field?.label || path}: ${aggregate.state === 'SAME' ? settingValueLabel(aggregate.value) : aggregate.state} → ${settingValueLabel(value)}`)); + }); + plans.forEach(plan => { + const keys = Object.keys(plan.overrides); + const impact = keys.some(path => GENERAL_SETTING_FIELDS.find(field => field.path === path)?.restart) + ? 'Config reload + full backend restart required (restart is not performed by this editor)' : keys.length ? 'VotingPlugin configuration reload required' : 'No runtime action required'; + summary.append(text(document.createElement('p'), `${plan.id}: ${impact}${plan.skipped.length ? ` · excluded: ${plan.skipped.map(item => `${item.field} (${item.status})`).join(', ')}` : ''}`)); + }); + const exclusions = plans.some(plan => plan.skipped.length); + document.querySelector('#general-settings-ack-label').hidden = !exclusions; + document.querySelector('#general-settings-ack').disabled = state.busy; + document.querySelector('#general-settings-read').disabled = state.busy || !model.targets.size; + document.querySelector('#general-settings-retry').disabled = state.busy || ![...model.targets.values()].some(target => target.status === 'ERROR'); + document.querySelector('#general-settings-reset').disabled = state.busy || !model.dirty.size; + document.querySelector('#general-settings-preview').disabled = state.busy || !plans.some(plan => Object.keys(plan.overrides).length); + document.querySelector('#general-settings-apply').disabled = state.busy || !preview || (exclusions && !document.querySelector('#general-settings-ack').checked); + const previewResults = document.querySelector('#general-settings-preview-results'); + previewResults.replaceChildren(); + const previewItems = preview?.items || state.previewItems || []; + if (!previewItems.length) text(previewResults, state.previewState === 'Stale' ? 'Stale — read/re-preview required.' : 'Not previewed.'); + else previewItems.forEach(item => { + const section = document.createElement('section'); section.className = 'target-operation'; + section.append(text(document.createElement('h4'), item.id)); + const operation = item.operation; + const result = operation?.results?.[item.id]; + section.append(text(document.createElement('p'), result ? `${result.success ? 'Config.yml · preview ready' : `${result.code} · ${result.message}`}${operation?.operationId ? ` · operation ${operation.operationId}` : ''}` : `${item.status || 'Unchanged / excluded'}${item.message ? ` · ${item.message}` : ''}`)); + (item.skipped || []).forEach(skipped => section.append(text(document.createElement('p'), `${skipped.field}: excluded (${skipped.status})`))); + const plan = plans.find(plan => plan.id === item.id); + if (plan) Object.entries(plan.overrides).forEach(([path, value]) => section.append(text(document.createElement('p'), + `Config.yml → ${path}: ${settingValueLabel(model.targets.get(item.id)?.fields[path]?.value)} → ${settingValueLabel(value)}`))); + (result?.changes || []).forEach(change => section.append(text(document.createElement('p'), change))); + previewResults.append(section); + }); + const results = document.querySelector('#general-settings-results'); results.replaceChildren(); + if (!model.results.size) text(results, 'No apply performed.'); + model.results.forEach((data, id) => { + const result = data.result || data.operation?.results?.[id] || data; + const current = model.targets.get(id); + const section = document.createElement('section'); section.className = 'target-operation'; + if (data.status === 'UNCHANGED' || data.status === 'EXCLUDED') { + section.append(text(document.createElement('h4'), id), text(document.createElement('p'), data.status === 'UNCHANGED' ? '— unchanged' : '— excluded / unsupported or unavailable; not applied')); + results.append(section); return; + } + section.append(text(document.createElement('h4'), id), text(document.createElement('p'), + `${result.success ? '✓ written' : `✗ ${result.code || data.status || 'not applied'}: ${result.message || data.message || ''}`} · ${result.reloaded ? '✓ reloaded' : 'reload not confirmed'}${result.rolledBack ? ' · ✓ automatic local rollback' : ''}${data.operation?.operationId ? ` · operation ${data.operation.operationId}` : ''}`), + text(document.createElement('p'), data.confirmed === true ? '✓ Confirmed requested persisted configuration after apply (not proof of a backend restart)' + : current?.status === 'AVAILABLE' ? 'Current configuration re-read; requested values are not confirmed as successfully applied.' : '✗ Confirmed read unavailable; do not assume requested values are active.')); + results.append(section); + }); +} + +settingsEditor = ControlGeneralSettings.create({ + targets: () => workspace.managementScope === 'GLOBAL' || !authenticated ? [] : [...workspace.selectedTargetIds].map(id => { + const node = nodeIndex.get(id); + return {id, sessionId: node?.sessionId || '', online: node?.online === true, + supported: isBackend(node) && node?.acceptedCapabilities?.includes('config.files.v1') === true}; + }), + context: generalSettingsContext, + active: () => authenticated && tabFromHash() === 'general-settings', + operation: (path, body) => startConfigurationOperation(path, body, document.querySelector('#general-settings-status')), + request: (path, body) => authorized(path, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}), + changed: renderGeneralSettings +}); +document.querySelector('#general-settings-read').addEventListener('click', () => void settingsEditor.read(true)); +document.querySelector('#general-settings-retry').addEventListener('click', () => void settingsEditor.read(true, true)); +document.querySelector('#general-settings-preview').addEventListener('click', () => { document.querySelector('#general-settings-ack').checked = false; void settingsEditor.preview(); }); +document.querySelector('#general-settings-apply').addEventListener('click', () => void settingsEditor.apply(document.querySelector('#general-settings-ack').checked)); +document.querySelector('#general-settings-reset').addEventListener('click', () => { document.querySelector('#general-settings-ack').checked = false; settingsEditor.resetAll(); }); +document.querySelector('#general-settings-ack').addEventListener('change', renderGeneralSettings); + +voteSitesEditor = ControlVoteSitesEditor.create({ + targets: () => workspace.managementScope === 'GLOBAL' || !authenticated ? [] : [...workspace.selectedTargetIds].map(id => { + const node = nodeIndex.get(id); + return {nodeId: id, sessionId: node?.sessionId || '', online: node?.online === true, + supported: isBackend(node) && node?.acceptedCapabilities?.includes('config.files.v1') === true}; + }), + context: voteSitesContext, + active: () => authenticated && tabFromHash() === 'vote-sites', + operation: (path, body) => startConfigurationOperation(path, body, document.querySelector('#vote-sites-status')), + request: (path, body) => authorized(path, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}), + changed: renderVoteSites +}); +document.querySelector('#vote-sites-read').addEventListener('click', () => void voteSitesEditor.read(true)); +document.querySelector('#vote-sites-retry').addEventListener('click', () => void voteSitesEditor.read(true, true)); +document.querySelector('#vote-site-search').addEventListener('input', () => renderVoteSiteList(voteSitesEditor.model)); +document.querySelector('#vote-site-filter').addEventListener('change', () => renderVoteSiteList(voteSitesEditor.model)); +document.querySelector('#vote-site-add').addEventListener('click', () => openVoteSiteAdd()); +document.querySelector('#vote-site-add-key').addEventListener('input', updateVoteSiteAddConflicts); +document.querySelector('#vote-site-add-cancel').addEventListener('click', () => document.querySelector('#vote-site-add-dialog').close()); +document.querySelector('#vote-site-add-close').addEventListener('click', () => document.querySelector('#vote-site-add-dialog').close()); +document.querySelector('#vote-site-add-form').addEventListener('submit', event => { + event.preventDefault(); + const dialog = document.querySelector('#vote-site-add-dialog'); + const key = document.querySelector('#vote-site-add-key').value.trim(); + if (!/^[A-Za-z0-9_-]{1,64}$/.test(key)) { text(document.querySelector('#vote-site-add-conflicts'), 'Use 1–64 letters, digits, underscores or hyphens; dots and spaces are unsafe site keys.'); return; } + if (voteSiteKeys(voteSitesEditor.model).some(siteKey => siteKey !== key && siteKey.toLowerCase() === key.toLowerCase())) { + text(document.querySelector('#vote-site-add-conflicts'), 'A site key differing only by letter case exists. Choose another key.'); return; + } + const inventory = voteSitesEditor.model.aggregate(key); + const existing = inventory.targets.filter(target => target.site); + const missing = inventory.targets.filter(target => target.status === 'MISSING' && !target.site); + const policy = existing.length ? document.querySelector('#vote-site-add-existing').value : 'missing'; + if ((existing.length && policy === 'cancel') || !missing.length && policy === 'missing' + || existing.length && missing.length && !window.confirm(`Site ${key} exists on ${existing.length} target(s) and is missing on ${missing.length}. Explicit choice: ${policy}. Continue to preview only?`)) return; + if (!missing.length && policy !== 'existing' && policy !== 'both') return; + const fields = voteSiteAddValues(); + if (fields.Priority === null || fields['DisplayItem.Amount'] === null) { + text(document.querySelector('#vote-site-add-conflicts'), 'Priority must be a signed 32-bit integer and DisplayItem.Amount must be 1–64.'); return; + } + const invalidText = VOTE_SITE_FIELDS.find(field => field.type === 'string' + && ControlVoteSites.parseStringField(field.path, fields[field.path]) === null); + if (invalidText) { + text(document.querySelector('#vote-site-add-conflicts'), `${invalidText.label} is outside its supported text format.`); return; + } + voteSitesEditor.beginAdd(key, fields, policy); + document.querySelector('#vote-site-partial-choice').value = policy === 'cancel' ? 'existing' : policy; + document.querySelector('#vote-site-ack').checked = false; + dialog.close(); renderVoteSites(); +}); +document.querySelector('#vote-site-partial-choice').addEventListener('change', event => { + const policy = event.target.value; + if (policy !== 'existing' && !Object.keys(voteSitesEditor.model.addFields || {}).length) { + openVoteSiteAdd({siteKey: voteSitesEditor.model.selectedSiteKey}); event.target.value = 'existing'; return; + } + document.querySelector('#vote-site-ack').checked = false; + voteSitesEditor.setPartialPolicy(policy); +}); +VOTE_SITE_FIELDS.forEach(field => { + const control = document.querySelector(field.control); + control.addEventListener('change', () => { + let value = control.value; + if (field.type === 'boolean') { + if (value !== 'true' && value !== 'false') return; + value = value === 'true'; + } else if (field.type === 'integer') { + value = ControlVoteSites.parseIntegerField(field.path, value); + if (value === null) { + voteSiteFieldInput(field, voteSitesEditor.model.aggregateField(field.path)); + text(document.querySelector('#vote-sites-status'), `${field.label} is outside its supported integer range; the last staged value was retained.`); return; + } + } else { + value = ControlVoteSites.parseStringField(field.path, value); + if (value === null) { + voteSiteFieldInput(field, voteSitesEditor.model.aggregateField(field.path)); + text(document.querySelector('#vote-sites-status'), `${field.label} is outside its supported text format; the last staged value was retained.`); return; + } + } + document.querySelector('#vote-site-ack').checked = false; + voteSitesEditor.edit(field.path, value); + }); +}); +document.querySelector('#vote-site-reset').addEventListener('click', () => { document.querySelector('#vote-site-ack').checked = false; voteSitesEditor.cancelDraft(); }); +document.querySelector('#vote-site-remove').addEventListener('click', () => { + const key = voteSitesEditor.model.selectedSiteKey; + const present = voteSitesEditor.model.aggregate(key).targets.filter(target => target.site).map(target => target.nodeId); + if (!present.length || !window.confirm(`Prepare removal of VoteSites.${key} on: ${present.join(', ')}? This also removes that site's inline Rewards on affected targets. Other sites and separate reward files are not touched. No configuration changes occur until an exact preview is approved and applied.`)) return; + document.querySelector('#vote-site-ack').checked = false; + voteSitesEditor.remove(key); +}); +document.querySelector('#vote-site-preview').addEventListener('click', () => { document.querySelector('#vote-site-ack').checked = false; void voteSitesEditor.preview(); }); +document.querySelector('#vote-site-apply').addEventListener('click', () => void voteSitesEditor.apply(document.querySelector('#vote-site-ack').checked)); +document.querySelector('#vote-site-ack').addEventListener('change', renderVoteSites); +document.querySelector('#vote-site-open-yaml').addEventListener('click', () => { + if (configurationFile.value !== 'VoteSites.yml') { + configurationFile.value = 'VoteSites.yml'; + configurationFile.dispatchEvent(new Event('input')); + } + setActiveTab('configurations', true); setConfigView('yaml'); +}); +document.querySelector('#vote-site-edit-rewards').addEventListener('click', () => { + const key = voteSitesEditor.model.selectedSiteKey; + if (!chooseRewardScope('VoteSites.yml', `VoteSites.${key}.Rewards`)) return; + openWorkspace('rewards'); + renderRewards(); +}); + +function chooseRewardScope(fileName, rewardPath) { + const current = rewardsEditor.selected; + if ((current.fileName !== fileName || current.rewardPath !== rewardPath) && rewardsEditor.edit + && !window.confirm('Discard the staged Rewards edit and inspect another reward scope?')) return false; + rewardsEditor.select(fileName, rewardPath); + return true; +} + +function rewardsContext() { + return JSON.stringify({generation: authenticationGeneration, scope: workspace.managementScope, + targets: [...workspace.selectedTargetIds].map(id => { + const node = nodeIndex.get(id); + const capabilities = node?.acceptedCapabilities || []; + return [id, node?.sessionId || '', node?.online === true, isBackend(node), + capabilities.includes('config.files.v1'), capabilities.includes('config.reward-files.v1'), + capabilities.includes('data.inspect.v1')]; + })}); +} + +async function inspectNamedRewardFiles(target) { + const captured = rewardsContext(); + const sessionId = target.sessionId; + const request = {nodeId: target.nodeId, query: {kind: 'reward-file-inventory', filters: {}}}; + let inspection = await authorized('/api/v1/inspections', { + method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(request) + }); + const deadline = Date.now() + 30_000; + while (inspection.state === 'RUNNING') { + if (Date.now() >= deadline) throw new Error('Named reward inventory is still running; retry later'); + await new Promise(resolve => window.setTimeout(resolve, 250)); + inspection = await authorized(`/api/v1/inspections/${inspection.inspectionId}`); + } + if (captured !== rewardsContext() || nodeIndex.get(target.nodeId)?.sessionId !== sessionId) { + throw new Error('Target reconnected or workspace changed; read again'); + } + if (inspection.state !== 'SUCCEEDED' || inspection.result?.success !== true) { + throw new Error(inspection.result?.message || inspection.result?.code || 'Named reward inventory failed'); + } + let envelope = inspection.result.data; + if (typeof envelope === 'string') envelope = JSON.parse(envelope); + if (envelope?.schemaVersion !== 1 || envelope.kind !== 'reward-file-inventory' + || !Array.isArray(envelope.result?.files)) throw new Error('Named reward inventory schema is unsupported'); + return envelope.result; +} + +function rewardText(value) { + if (value === undefined) return 'Missing'; + if (Array.isArray(value)) return value.length ? value.join(' · ') : 'Empty list'; + return String(value); +} + +function renderRewards() { + if (!rewardsEditor) return; + const state = rewardsEditor.state; + const selected = rewardsEditor.selected; + const targets = rewardsEditor.targets(); + const targetBox = document.querySelector('#rewards-targets'); targetBox.replaceChildren(); + targets.forEach(target => { + const files = rewardsEditor.files().map(file => rewardsEditor.record(target.nodeId, file)); + const loaded = files.filter(item => item?.status === 'AVAILABLE').length; + const failed = files.filter(item => item?.status === 'ERROR').length; + const named = !target.rewardFilesSupported ? ' · named files unsupported' + : !target.rewardFileInventorySupported ? ' · named file discovery unsupported' : ''; + targetBox.append(text(document.createElement('span'), `${nodeIndex.get(target.nodeId)?.displayName || target.nodeId}: ${loaded}/${files.length} files loaded${failed ? ` · ${failed} failed` : ''}${named}`)); + }); + text(document.querySelector('#rewards-status'), state.error || state.message || (state.busy ? 'Reading…' : '')); + const list = document.querySelector('#rewards-scope-list'); list.replaceChildren(); + const search = document.querySelector('#rewards-search').value.trim().toLowerCase(); + const scopes = rewardsEditor.scopes().filter(scope => `${scope.fileName} ${scope.path}`.toLowerCase().includes(search)); + scopes.forEach(scope => { + const button = document.createElement('button'); button.type = 'button'; + button.className = 'vote-site-list-item'; button.setAttribute('role', 'option'); + button.setAttribute('aria-selected', String(scope.fileName === selected.fileName && scope.path === selected.rewardPath)); + const present = targets.filter(target => rewardsEditor.record(target.nodeId, scope.fileName)?.scopes?.some(item => item.path === scope.path && item.status === 'PRESENT')).length; + button.append(text(document.createElement('strong'), scope.path), text(document.createElement('small'), `${scope.fileName} · ${present}/${targets.length} configured`)); + button.addEventListener('click', () => { + if (!chooseRewardScope(scope.fileName, scope.path)) return; + renderRewards(); void rewardsEditor.read(false); + }); + list.append(button); + }); + if (!scopes.length) text(list, state.busy ? 'Reading reward scopes…' : 'No reward scopes match, or no successful reads are available.'); + const hasSelection = Boolean(selected.rewardPath); + document.querySelector('#rewards-empty').hidden = hasSelection; + document.querySelector('#rewards-detail').hidden = !hasSelection; + if (hasSelection) { + text(document.querySelector('#rewards-title'), selected.rewardPath === '$' + ? selected.fileName.slice('Rewards/'.length) : selected.rewardPath.split('.').at(-2) || selected.rewardPath); + text(document.querySelector('#rewards-path'), `${selected.fileName} → ${selected.rewardPath}`); + const details = targets.map(target => rewardsEditor.targetScope(target)); + const present = details.filter(item => item.scope?.status === 'PRESENT').length; + text(document.querySelector('#rewards-presence'), `${present}/${targets.length} configured`); + const values = document.querySelector('#rewards-values'); values.replaceChildren(); + details.forEach(info => { + const section = document.createElement('section'); section.className = 'target-operation'; + section.append(text(document.createElement('h4'), nodeIndex.get(info.nodeId)?.displayName || info.nodeId), + text(document.createElement('p'), info.status !== 'AVAILABLE' ? `${info.status}: ${info.message || 'Read unavailable'}` : info.scope?.status || 'Missing')); + if (info.status === 'AVAILABLE' && info.scope) Object.entries(info.scope.fields || {}).forEach(([field, value]) => { + section.append(text(document.createElement('p'), `${field}: ${rewardText(value)}`)); + }); + values.append(section); + }); + const advanced = document.querySelector('#rewards-advanced'); advanced.replaceChildren(); + details.forEach(info => { + const section = document.createElement('section'); section.className = 'target-operation'; + section.append(text(document.createElement('h4'), nodeIndex.get(info.nodeId)?.displayName || info.nodeId), + text(document.createElement('p'), info.scope?.advancedKeys?.length ? `Advanced keys preserved: ${info.scope.advancedKeys.join(', ')}` : 'No advanced keys reported')); + (info.scope?.structurePaths || []).forEach(path => { + const row = text(document.createElement('p'), path); + row.className = `reward-tree-depth-${Math.min(8, path.split('.').length - 1)}`; + section.append(row); + }); + advanced.append(section); + }); + const editable = selected.fileName === 'VoteSites.yml' && /^VoteSites\.[A-Za-z0-9_-]{1,64}\.Rewards$/.test(selected.rewardPath) + || /^Rewards\/[A-Za-z0-9][A-Za-z0-9_-]{0,99}\.yml$/.test(selected.fileName) && selected.rewardPath === '$'; + document.querySelector('#rewards-editable').hidden = !editable; + if (editable) { + const fieldSelect = document.querySelector('#rewards-field'); + const priorField = fieldSelect.value; + const itemFields = [...new Set(details.flatMap(info => Object.keys(info.scope?.fields || {})))] + .filter(field => /^Items\.[A-Za-z0-9_-]{1,64}\.(Material|Amount)$/.test(field)).sort(); + [...fieldSelect.querySelectorAll('option[data-item-field]')].forEach(option => option.remove()); + itemFields.forEach(field => { + const option = document.createElement('option'); option.value = field; + option.dataset.itemField = 'true'; option.textContent = field; fieldSelect.append(option); + }); + fieldSelect.value = [...fieldSelect.options].some(option => option.value === priorField) ? priorField : 'Commands'; + const field = fieldSelect.value; + const aggregate = rewardsEditor.aggregate(field); + const fieldState = document.querySelector('#rewards-field-state'); fieldState.replaceChildren(); + fieldState.append(text(document.createElement('strong'), `${field}: ${aggregate.status}${aggregate.status === 'SAME' ? ` · ${rewardText(aggregate.value)}` : ''}`)); + if (aggregate.status !== 'SAME') aggregate.targets.forEach(target => fieldState.append(text(document.createElement('p'), + `${nodeIndex.get(target.nodeId)?.displayName || target.nodeId}: ${target.status === 'AVAILABLE' ? rewardText(target.value) : target.status}`))); + } + } + const plans = rewardsEditor.plan(); const ready = plans.filter(item => item.status === 'READY'); + const summary = document.querySelector('#rewards-summary'); summary.replaceChildren(); + if (!rewardsEditor.edit) text(summary, 'No explicit reward edit. Reading or selecting a scope never stages a change.'); + else { + summary.append(text(document.createElement('p'), `${rewardsEditor.edit.operation} · ${selected.fileName} → ${selected.rewardPath}${rewardsEditor.edit.field ? `.${rewardsEditor.edit.field}` : ''}${rewardsEditor.edit.value != null ? ` · ${rewardText(rewardsEditor.edit.value)}` : ''}`), + text(document.createElement('p'), `${ready.length} target${ready.length === 1 ? '' : 's'} would change · VotingPlugin reload required`)); + plans.forEach(item => summary.append(text(document.createElement('p'), `${nodeIndex.get(item.nodeId)?.displayName || item.nodeId}: ${item.status}`))); + } + const excluded = plans.some(item => item.status !== 'READY'); + document.querySelector('#rewards-ack-label').hidden = !rewardsEditor.edit || !excluded; + document.querySelector('#rewards-read').disabled = state.busy || !targets.length; + document.querySelector('#rewards-retry').disabled = state.busy || !targets.some(target => rewardsEditor.files().some(file => rewardsEditor.record(target.nodeId, file)?.status === 'ERROR')); + document.querySelector('#rewards-preview').disabled = state.busy || !ready.length || ready.length > 8; + document.querySelector('#rewards-apply').disabled = state.busy || state.previewState !== 'Ready' || excluded && !document.querySelector('#rewards-ack').checked; + const preview = document.querySelector('#rewards-preview-results'); preview.replaceChildren(); + if (!state.previews.length) text(preview, state.previewState === 'Stale' ? 'Stale — read and preview again.' : 'Not previewed.'); + state.previews.forEach(item => { + const section = document.createElement('section'); section.className = 'target-operation'; + section.append(text(document.createElement('h4'), nodeIndex.get(item.nodeId)?.displayName || item.nodeId), + text(document.createElement('p'), `${item.status}${item.operationId ? ` · operation ${item.operationId}` : ''}${item.message ? ` · ${item.message}` : ''}`)); + if (item.status === 'READY') { + const change = rewardsEditor.edit; + const scope = rewardsEditor.targetScope({nodeId: item.nodeId}).scope; + const before = scope?.fields?.[change?.field]; + const after = change?.operation === 'APPEND_LIST_ENTRY' && Array.isArray(before) ? [...before, change.value] + : change?.operation === 'REMOVE_LIST_ENTRY' && Array.isArray(before) ? before.filter(value => value !== change.value) + : change?.operation === 'REPLACE_LIST' ? change.value + : change?.operation === 'SET_SCALAR' ? change.value : change?.operation === 'REMOVE_REWARD' ? 'Removed' : 'Created'; + section.append(text(document.createElement('p'), `${selected.fileName} → ${selected.rewardPath}${change?.field ? `.${change.field}` : ''}: ${rewardText(before)} → ${rewardText(after)}`)); + } else if (item.status === 'SKIP') section.append(text(document.createElement('p'), `Excluded: ${item.reason || 'unavailable'}`)); + preview.append(section); + }); + const results = document.querySelector('#rewards-results'); results.replaceChildren(); + if (!state.results.length) text(results, 'No apply performed.'); + state.results.forEach(item => { + const section = document.createElement('section'); section.className = 'target-operation'; + section.append(text(document.createElement('h4'), nodeIndex.get(item.nodeId)?.displayName || item.nodeId), + text(document.createElement('p'), `${item.status} · ${item.confirmed ? 'confirmed by fresh READ' : 'not confirmed'}${item.result?.rolledBack ? ' · previous file restored' : ''}${item.result?.code ? ` · ${item.result.code}` : ''}${item.result?.message ? ` · ${item.result.message}` : ''}${item.message ? ` · ${item.message}` : ''}`)); + results.append(section); + }); +} + +rewardsEditor = ControlRewardsEditor.create({ + targets: () => workspace.managementScope === 'GLOBAL' || !authenticated ? [] : [...workspace.selectedTargetIds].map(id => { + const node = nodeIndex.get(id); + return {nodeId: id, sessionId: node?.sessionId || '', online: node?.online === true, + supported: isBackend(node) && node?.acceptedCapabilities?.includes('config.files.v1') === true, + rewardFilesSupported: isBackend(node) && node?.acceptedCapabilities?.includes('config.reward-files.v1') === true, + rewardFileInventorySupported: isBackend(node) && node?.acceptedCapabilities?.includes('config.reward-files.v1') === true + && node?.acceptedCapabilities?.includes('data.inspect.v1') === true}; + }), + context: rewardsContext, + active: () => authenticated && tabFromHash() === 'rewards', + operation: (path, body) => startConfigurationOperation(path, body, document.querySelector('#rewards-status')), + request: (path, body) => authorized(path, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}), + inspect: inspectNamedRewardFiles, + changed: renderRewards +}); +document.querySelector('#rewards-read').addEventListener('click', () => void rewardsEditor.read(true)); +document.querySelector('#rewards-retry').addEventListener('click', () => void rewardsEditor.read(true, true)); +document.querySelector('#rewards-add').addEventListener('click', () => { + const selected = rewardsEditor.selected; + if (!selected.rewardPath || selected.fileName !== 'VoteSites.yml' || !/^VoteSites\.[A-Za-z0-9_-]{1,64}\.Rewards$/.test(selected.rewardPath)) { + text(document.querySelector('#rewards-status'), 'Choose a Vote Site reward scope first. Only an absent inline Rewards section can be created here.'); return; + } + if (!rewardsEditor.targets().some(target => rewardsEditor.targetScope(target).scope?.status === 'MISSING')) { + text(document.querySelector('#rewards-status'), 'This reward already exists on the selected targets. Use an explicit edit operation instead.'); return; + } + document.querySelector('#rewards-operation').value = 'CREATE_REWARD'; + document.querySelector('#rewards-field').value = 'Commands'; + document.querySelector('#rewards-value').focus(); + text(document.querySelector('#rewards-status'), 'Enter one console command, then stage and preview creation for missing targets. Existing rewards will be excluded.'); +}); +document.querySelector('#rewards-search').addEventListener('input', renderRewards); +document.querySelector('#rewards-field').addEventListener('change', renderRewards); +document.querySelector('#rewards-reset').addEventListener('click', () => { document.querySelector('#rewards-ack').checked = false; rewardsEditor.reset(); }); +document.querySelector('#rewards-stage').addEventListener('click', () => { + const operation = document.querySelector('#rewards-operation').value; + const field = document.querySelector('#rewards-field').value; + const raw = document.querySelector('#rewards-value').value; + if (['APPEND_LIST_ENTRY', 'REMOVE_LIST_ENTRY', 'REPLACE_LIST'].includes(operation) && field !== 'Commands' + || operation === 'SET_SCALAR' && field === 'Commands') { + text(document.querySelector('#rewards-status'), 'Choose a field supported by this operation. Commands use append/remove; existing messages and numbers use Set.'); return; + } + if (operation === 'REMOVE_REWARD' && !window.confirm(`Stage removal of ${rewardsEditor.selected.rewardPath} from present targets? The site and named reward files remain.`)) return; + if (operation !== 'REMOVE_REWARD' && operation !== 'REPLACE_LIST' && !raw.trim()) { text(document.querySelector('#rewards-status'), 'Enter an explicit value.'); return; } + if (operation === 'REPLACE_LIST' && !window.confirm('Replace the entire Commands list on every eligible target? Existing target-specific commands will be removed. Preview each proposal before applying.')) return; + const lines = operation === 'REPLACE_LIST' ? raw.split(/\r?\n/).map(line => line.trim()).filter(Boolean) : null; + if (lines && (lines.length > 100 || lines.some(line => line.length > 500))) { text(document.querySelector('#rewards-status'), 'Use at most 100 commands of 500 characters each.'); return; } + if (['APPEND_LIST_ENTRY', 'REMOVE_LIST_ENTRY', 'CREATE_REWARD'].includes(operation) + || operation === 'SET_SCALAR' && ['Messages.Player', 'Messages.Broadcast'].includes(field)) { + if (raw.length > 500) { text(document.querySelector('#rewards-status'), 'Use at most 500 characters.'); return; } + } + if (['APPEND_LIST_ENTRY', 'REMOVE_LIST_ENTRY', 'CREATE_REWARD'].includes(operation) && raw.includes('\n')) { text(document.querySelector('#rewards-status'), 'Enter exactly one command.'); return; } + const numericScalar = operation === 'SET_SCALAR' && (['Money', 'Chance'].includes(field) || /^Items\.[A-Za-z0-9_-]{1,64}\.Amount$/.test(field)); + const value = operation === 'REMOVE_REWARD' ? null : operation === 'REPLACE_LIST' ? lines + : numericScalar ? Number(raw) : raw; + if (numericScalar && (!Number.isFinite(value) || value < 0)) { text(document.querySelector('#rewards-status'), 'Enter a nonnegative finite number.'); return; } + if (operation === 'SET_SCALAR' && /^Items\.[A-Za-z0-9_-]{1,64}\.Amount$/.test(field) + && (!Number.isInteger(value) || value < 1 || value > 64)) { + text(document.querySelector('#rewards-status'), 'Item Amount must be a whole number from 1 to 64.'); return; + } + if (operation === 'SET_SCALAR' && /^Items\.[A-Za-z0-9_-]{1,64}\.Material$/.test(field) + && !/^[A-Z0-9_]{1,80}$/.test(raw)) { + text(document.querySelector('#rewards-status'), 'Enter the exact current Minecraft material name (uppercase letters, digits and underscores).'); return; + } + document.querySelector('#rewards-ack').checked = false; + rewardsEditor.setEdit(operation, operation === 'REMOVE_REWARD' ? null : operation === 'CREATE_REWARD' ? 'Commands' : field, value); +}); +document.querySelector('#rewards-preview').addEventListener('click', () => { document.querySelector('#rewards-ack').checked = false; void rewardsEditor.preview(); }); +document.querySelector('#rewards-apply').addEventListener('click', () => void rewardsEditor.apply(document.querySelector('#rewards-ack').checked)); +document.querySelector('#rewards-ack').addEventListener('change', renderRewards); +document.querySelector('#rewards-open-yaml').addEventListener('click', () => { + const selected = rewardsEditor.selected; + if (selected.fileName.startsWith('Rewards/') && ![...configurationFile.options].some(option => option.value === selected.fileName)) { + const option = document.createElement('option'); option.value = selected.fileName; + option.textContent = selected.fileName; option.dataset.sessionRewardFile = 'true'; configurationFile.append(option); + } + setActiveTab('configurations', true); setConfigView('yaml'); + if (configurationFile.value !== selected.fileName) { configurationFile.value = selected.fileName; configurationFile.dispatchEvent(new Event('input')); } +}); +document.querySelector('#vote-sites-health-refresh').addEventListener('click', () => void refreshVoteSiteObservations()); + refresh.addEventListener('click', loadNodes); refreshDashboardButton.addEventListener('click', () => refreshDashboard()); previousPage.addEventListener('click', () => { diff --git a/src/main/resources/web/configuration-state.js b/src/main/resources/web/configuration-state.js new file mode 100644 index 00000000..2e343ea6 --- /dev/null +++ b/src/main/resources/web/configuration-state.js @@ -0,0 +1,224 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + root.ControlConfiguration = api; +}(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + const READ_STATUSES = new Set(['AVAILABLE', 'ERROR', 'UNSUPPORTED']); + const FIELD_STATUSES = new Set(['AVAILABLE', 'MISSING', 'UNSUPPORTED']); + + function string(value) { return typeof value === 'string' ? value : ''; } + + // Shared across boolean Settings and typed site fields. Structural site + // presence remains the Vote Sites layer's responsibility. + function aggregateSnapshots(entries, equals) { + const values = entries.filter(function (entry) { return entry.status === 'AVAILABLE'; }) + .map(function (entry) { return entry.value; }); + const supportedState = !values.length ? 'MISSING' + : values.every(function (value) { return equals(value, values[0]); }) ? 'SAME' : 'MIXED'; + const state = entries.some(function (entry) { return entry.status === 'ERROR'; }) ? 'ERROR' + : entries.some(function (entry) { return entry.status === 'UNSUPPORTED'; }) ? 'UNSUPPORTED' + : entries.some(function (entry) { return entry.status === 'MISSING'; }) || !values.length + ? 'MISSING' : supportedState; + return {state, supportedState, value: supportedState === 'SAME' ? values[0] : undefined, targets: entries}; + } + + function fieldSnapshot(fields) { + const result = {}; + if (!fields || typeof fields !== 'object') return result; + Object.keys(fields).forEach(function (path) { + const source = fields[path] || {}; + result[path] = { + status: FIELD_STATUSES.has(source.status) ? source.status : 'MISSING', + value: source.value === true + }; + }); + return result; + } + + function sameFields(left, right) { + const leftKeys = Object.keys(left).sort(); + const rightKeys = Object.keys(right).sort(); + return leftKeys.length === rightKeys.length && leftKeys.every(function (key, index) { + const value = rightKeys[index]; + return key === value && left[key].status === right[key].status && left[key].value === right[key].value; + }); + } + + function targetRecord(target) { + return { + id: target.id, + sessionId: string(target.sessionId), + status: 'MISSING', + revision: '', + fields: {}, + code: '', + message: '' + }; + } + + class MultiTargetState { + constructor(options) { + this.targets = new Map(); + this.dirty = new Map(); + this.results = new Map(); + this.preview = null; + const targets = Array.isArray(options) ? options : options && options.targets; + if (targets) this.setTargets(targets); + } + + setTargets(targets) { + const next = new Map(); + if (Array.isArray(targets)) { + targets.forEach(function (target) { + if (!target || typeof target.id !== 'string' || !target.id) return; + const sessionId = string(target.sessionId); + const current = this.targets.get(target.id); + next.set(target.id, current && current.sessionId === sessionId ? current : targetRecord({id: target.id, sessionId})); + }, this); + } + let changed = next.size !== this.targets.size; + if (!changed) { + next.forEach(function (record, id) { + const previous = this.targets.get(id); + if (previous !== record) changed = true; + }, this); + } + this.targets = next; + if (changed) { + this.dirty.clear(); + this.results.clear(); + this.invalidatePreview(); + } + return this; + } + + setRead(id, read) { + const target = this.targets.get(id); + if (!target || !read || typeof read !== 'object') return this; + const status = READ_STATUSES.has(read.status) ? read.status : 'ERROR'; + const revision = string(read.revision); + const sessionId = string(read.sessionId); + const fields = fieldSnapshot(read.fields); + const changedIdentity = target.status !== status || target.revision !== revision || target.sessionId !== sessionId; + const changedFields = !sameFields(target.fields, fields); + target.status = status; + target.revision = revision; + target.sessionId = sessionId; + target.fields = fields; + target.code = string(read.code); + target.message = string(read.message); + if (changedIdentity || changedFields) this.invalidatePreview(); + return this; + } + + edit(field, value) { + if (typeof field !== 'string' || !field || typeof value !== 'boolean') return this; + this.dirty.set(field, value); + this.invalidatePreview(); + return this; + } + + reset(field) { + if (typeof field === 'string' && field) this.dirty.delete(field); + this.invalidatePreview(); + return this; + } + + resetAll() { + this.dirty.clear(); + this.invalidatePreview(); + return this; + } + + aggregate(field) { + const entries = []; + this.targets.forEach(function (target) { + let status = target.status === 'ERROR' ? 'ERROR' + : target.status === 'UNSUPPORTED' ? 'UNSUPPORTED' : 'MISSING'; + let value; + const snapshot = target.fields[field]; + if (target.status === 'AVAILABLE' && snapshot) { + status = snapshot.status; + if (status === 'AVAILABLE') value = snapshot.value === true; + } + entries.push({id: target.id, status, value, revision: target.revision, sessionId: target.sessionId, + code: target.code, message: target.message}); + }); + return aggregateSnapshots(entries, function (left, right) { return left === right; }); + } + + plans() { + const plans = []; + this.targets.forEach(function (target) { + const overrides = {}; + const skipped = []; + this.dirty.forEach(function (requested, field) { + const snapshot = target.status === 'AVAILABLE' ? target.fields[field] : null; + if (snapshot && snapshot.status === 'AVAILABLE') { + if (snapshot.value !== requested) overrides[field] = requested; + } else { + skipped.push({field, status: target.status === 'ERROR' ? 'ERROR' + : target.status === 'UNSUPPORTED' ? 'UNSUPPORTED' : snapshot ? snapshot.status : 'MISSING'}); + } + }); + plans.push({id: target.id, revision: target.revision, sessionId: target.sessionId, + overrides, skipped, status: target.status}); + }, this); + return plans; + } + + signature() { + const targets = []; + this.targets.forEach(function (target) { + const fields = Object.keys(target.fields).sort().map(function (path) { + const field = target.fields[path]; + return [path, field.status, field.value]; + }); + targets.push([target.id, target.sessionId, target.status, target.revision, fields]); + }); + const dirty = Array.from(this.dirty.entries()).sort(function (a, b) { return a[0].localeCompare(b[0]); }); + return JSON.stringify([targets, dirty]); + } + + beginPreview() { + this.invalidatePreview(); + return this.signature(); + } + + finishPreview(signature, items) { + if (signature !== this.signature() || !Array.isArray(items)) return false; + this.preview = {signature, items}; + return true; + } + + previewCurrent() { + return this.preview && this.preview.signature === this.signature() ? this.preview : null; + } + + invalidatePreview() { + this.preview = null; + return this; + } + + setApplyResult(id, data) { + if (typeof id === 'string' && id) this.results.set(id, data); + return this; + } + + clearConfirmedDirty() { + if (!this.targets.size) return this; + this.dirty.forEach(function (requested, field) { + const confirmed = Array.from(this.targets.values()).every(function (target) { + const snapshot = target.status === 'AVAILABLE' && target.fields[field]; + return snapshot && snapshot.status === 'AVAILABLE' && snapshot.value === requested; + }); + if (confirmed) this.dirty.delete(field); + }, this); + return this; + } + } + + return {MultiTargetState, aggregateSnapshots}; +})); diff --git a/src/main/resources/web/general-settings.js b/src/main/resources/web/general-settings.js new file mode 100644 index 00000000..4ad8552d --- /dev/null +++ b/src/main/resources/web/general-settings.js @@ -0,0 +1,296 @@ +(function (root, factory) { + const api = factory(root.ControlConfiguration || (typeof require === 'function' ? require('./configuration-state.js') : null)); + if (typeof module === 'object' && module.exports) module.exports = api; + root.ControlGeneralSettings = api; +}(typeof globalThis !== 'undefined' ? globalThis : this, function (configuration) { + 'use strict'; + + if (!configuration || !configuration.MultiTargetState) throw new Error('ControlConfiguration.MultiTargetState is required'); + const CACHE_MILLIS = 30000; + const MAX_CHANGED_TARGETS = 8; + + function now() { return Date.now(); } + function resultFor(operation, id) { + const results = operation && operation.results; + if (Array.isArray(results)) return results.find(function (item) { return item.nodeId === id || item.id === id; }); + return results && results[id]; + } + function errorText(error) { return error && (error.message || error.code) || 'Request failed'; } + function targetList(adapter) { return Array.isArray(adapter.targets && adapter.targets()) ? adapter.targets() : []; } + function eligible(target) { return target && target.online === true && target.supported === true; } + + function create(adapter) { + if (!adapter || typeof adapter.targets !== 'function' || typeof adapter.context !== 'function' + || typeof adapter.operation !== 'function' || typeof adapter.request !== 'function') throw new Error('A settings adapter is required'); + const model = new configuration.MultiTargetState(); + const reads = new Map(); + let flight = null; + let queuedRead = null; + let queuedForce = false; + let queuedOnlyFailed = false; + let approval = null; + let generation = 0; + let busyCount = 0; + let applying = false; + let knownContext = context(); + let state = {busy: false, error: '', message: '', previewState: 'Not previewed', ackRequired: false, previewItems: []}; + + function active() { return typeof adapter.active !== 'function' || adapter.active(); } + function context() { return String(adapter.context()); } + function notify() { if (typeof adapter.changed === 'function') adapter.changed(); } + function setState(values) { state = Object.assign({}, state, values); notify(); } + function current(captured, capturedGeneration) { return context() === captured && generation === capturedGeneration; } + function selected() { return targetList(adapter); } + function syncTargets() { model.setTargets(selected().map(function (target) { return {id: target.id, sessionId: target.sessionId}; })); } + function releasePreviews(items) { + return Promise.all((items || []).map(function (item) { + if (!item.operationId || !item.approvalToken) return Promise.resolve(); + // Best-effort approval disposal only: no task is queued and no node configuration changes. + return Promise.resolve().then(function () { return adapter.request('/api/v1/configuration/general-settings/discard-preview', + {previewOperationId: item.operationId, approvalToken: item.approvalToken}); }).catch(function () {}); + })); + } + function syncContext() { + const nextContext = context(); + syncTargets(); + if (nextContext !== knownContext) { + releasePreviews(approval && approval.items); + knownContext = nextContext; + generation++; + queuedRead = null; queuedForce = false; queuedOnlyFailed = false; + reads.clear(); approval = null; model.invalidatePreview(); + busyCount = 0; applying = false; + setState({busy: false, error: '', message: '', previewState: 'Not previewed', ackRequired: false, previewItems: []}); + } + } + function clearApproval(previewState) { + if (!applying) releasePreviews(approval && approval.items); + approval = null; + model.invalidatePreview(); + setState({previewState: previewState || 'Stale', ackRequired: false, previewItems: [], error: '', message: 'Preview invalidated; read/re-preview required'}); + } + function setBusy(delta) { busyCount = Math.max(0, busyCount + delta); setState({busy: busyCount > 0}); } + function readError(target, message, code) { + reads.delete(target.id); + const unavailable = target && target.supported !== true; + model.setRead(target.id, {status: unavailable ? 'UNSUPPORTED' : 'ERROR', sessionId: target.sessionId, + revision: '', fields: {}, code: unavailable ? 'UNSUPPORTED' : code || 'READ_FAILED', message: message || ''}); + } + function cacheCurrent(target) { + const cached = reads.get(target.id); + return cached && cached.sessionId === target.sessionId && now() - cached.loadedAt < CACHE_MILLIS; + } + + async function loadStates(operation, targets, captured, capturedGeneration) { + await Promise.all(targets.map(async function (target) { + if (!current(captured, capturedGeneration)) return; + const item = resultFor(operation, target.id); + if (!item || item.success !== true) { + readError(target, item && item.message || item && item.code || 'Read did not succeed', item && item.code); + return; + } + try { + const typed = await adapter.request('/api/v1/configuration/general-settings/state', + {readOperationId: operation.operationId, nodeId: target.id}); + if (!current(captured, capturedGeneration) || !model.targets.has(target.id)) return; + if (typed.nodeId !== target.id || typed.sessionId !== target.sessionId || typed.revision !== item.revision) { + readError(target, 'Read identity changed; read the target again', 'TARGET_CHANGED'); return; + } + reads.set(target.id, {readOperationId: operation.operationId, sessionId: target.sessionId, + revision: typed.revision || '', loadedAt: now()}); + model.setRead(target.id, {status: 'AVAILABLE', sessionId: typed.sessionId || target.sessionId, + revision: typed.revision, fields: typed.fields || {}}); + } catch (error) { if (current(captured, capturedGeneration)) readError(target, errorText(error), error && error.code); } + })); + } + + function read(force, onlyFailed) { + syncContext(); + const captured = context(); + const capturedGeneration = generation; + const key = captured + '\u0000' + capturedGeneration; + if (flight && flight.key === key) { + if (!force && !onlyFailed) return flight.promise; + queuedForce = queuedForce || force === true; + queuedOnlyFailed = queuedOnlyFailed || onlyFailed === true; + if (!queuedRead) { + queuedRead = flight.promise.catch(function () {}).then(function () { + if (!current(captured, capturedGeneration)) return model; + const nextForce = queuedForce; + const nextOnlyFailed = !nextForce && queuedOnlyFailed; + queuedRead = null; queuedForce = false; queuedOnlyFailed = false; + return read(nextForce, nextOnlyFailed); + }); + } + return queuedRead; + } + const all = selected(); + const wanted = all.filter(function (target) { + if (!eligible(target)) { readError(target, target.online ? 'General Settings is unsupported' : 'Target is offline', target.online ? 'UNSUPPORTED' : 'OFFLINE'); return false; } + if (onlyFailed) return model.targets.get(target.id).status !== 'AVAILABLE'; + return force || !cacheCurrent(target); + }); + if (!wanted.length) { notify(); return Promise.resolve(model); } + setBusy(1); setState({error: '', message: 'Reading General Settings…'}); + const currentFlight = {key: key, promise: null}; + currentFlight.promise = (async function () { + try { + const operation = await adapter.operation('/api/v1/configuration/read', { + nodeIds: wanted.map(function (target) { return target.id; }), configuration: {domain: 'file', fileName: 'Config.yml'} + }); + if (!current(captured, capturedGeneration)) return model; + await loadStates(operation, wanted, captured, capturedGeneration); + if (current(captured, capturedGeneration) && approval && !model.previewCurrent() && !applying) clearApproval('Stale'); + if (current(captured, capturedGeneration)) setState({message: 'General Settings loaded'}); + } catch (error) { + if (current(captured, capturedGeneration)) { + wanted.forEach(function (target) { readError(target, errorText(error), error && error.code); }); + setState({error: errorText(error), message: ''}); + } + } finally { + if (current(captured, capturedGeneration)) setBusy(-1); + if (flight === currentFlight) flight = null; + } + return model; + }()); + flight = currentFlight; + return currentFlight.promise; + } + + async function preview() { + syncContext(); + if (state.busy) return false; + const cleanupContext = context(); const cleanupGeneration = generation; + setBusy(1); + await releasePreviews(approval && approval.items); approval = null; + if (!current(cleanupContext, cleanupGeneration)) return false; + syncTargets(); + const captured = context(); + const capturedGeneration = generation; + const signature = model.beginPreview(); + const plans = model.plans(); + const changed = plans.filter(function (plan) { return Object.keys(plan.overrides).length > 0; }); + if (changed.length > MAX_CHANGED_TARGETS) { + setBusy(-1); + setState({error: 'At most ' + MAX_CHANGED_TARGETS + ' changed targets can be previewed at once', previewState: 'Stale', previewItems: []}); + return false; + } + setState({error: '', message: 'Previewing General Settings…', previewState: 'Not previewed', ackRequired: false, previewItems: []}); + const items = plans.map(function (plan) { return {id: plan.id, status: plan.skipped.length ? plan.skipped[0].status : 'UNCHANGED', skipped: plan.skipped}; }); + let failed = false; + await Promise.all(changed.map(async function (plan) { + const cached = reads.get(plan.id); + if (!cached) { + failed = true; + const index = items.findIndex(function (candidate) { return candidate.id === plan.id; }); + items[index] = {id: plan.id, status: 'ERROR', skipped: plan.skipped, message: 'A successful read is required'}; + return; + } + try { + const operation = await adapter.operation('/api/v1/configuration/general-settings/preview', { + readOperationId: cached.readOperationId, nodeId: plan.id, overrides: plan.overrides + }); + const item = resultFor(operation, plan.id); + const index = items.findIndex(function (candidate) { return candidate.id === plan.id; }); + items[index] = {id: plan.id, status: operation.state === 'SUCCEEDED' && operation.approvalToken + && item && item.success === true && item.revision === cached.revision ? 'READY' : 'ERROR', + operationId: operation.operationId, approvalToken: operation.approvalToken, operation, skipped: plan.skipped}; + if (items[index].status !== 'READY') failed = true; + } catch (error) { + failed = true; + const index = items.findIndex(function (candidate) { return candidate.id === plan.id; }); + items[index] = {id: plan.id, status: 'ERROR', skipped: plan.skipped, message: errorText(error)}; + } + })); + if (current(captured, capturedGeneration)) setBusy(-1); + if (!current(captured, capturedGeneration) || signature !== model.signature() || failed || !model.finishPreview(signature, items)) { + releasePreviews(items); + if (current(captured, capturedGeneration)) setState({error: failed ? 'One or more previews failed' : 'Settings changed while previewing', previewState: 'Stale', previewItems: items}); + return false; + } + approval = {signature, context: captured, generation: capturedGeneration, items: items.filter(function (item) { return item.status === 'READY'; })}; + const ackRequired = items.some(function (item) { return item.skipped && item.skipped.length; }); + setState({message: 'Preview ready', previewState: 'Ready', ackRequired, previewItems: items}); + return true; + } + + async function apply(acknowledged) { + if (state.busy || applying) return false; + if (!approval || !model.previewCurrent() || approval.signature !== model.signature() || !current(approval.context, approval.generation)) { + clearApproval('Stale'); setState({error: 'Preview is stale; preview again'}); return false; + } + if (state.ackRequired && acknowledged !== true) { setState({error: 'Acknowledge excluded targets before applying'}); return false; } + const approved = {signature: approval.signature, context: approval.context, generation: approval.generation, items: approval.items.slice()}; + applying = true; + await read(true); + if (approved.signature !== model.signature() || !current(approved.context, approved.generation)) { + applying = false; + clearApproval('Stale'); setState({error: 'Target revisions changed; preview again'}); return false; + } + const captured = approved.context; + setBusy(1); setState({error: '', message: 'Applying approved previews…'}); + const planned = model.plans(); + planned.forEach(function (plan) { + if (!approved.items.some(function (item) { return item.id === plan.id; })) { + model.setApplyResult(plan.id, {operation: null, result: null, + status: plan.skipped.length ? 'EXCLUDED' : 'UNCHANGED', confirmed: false}); + } + }); + for (const item of approved.items) { + if (!current(captured, approved.generation)) break; + try { + const operation = await adapter.operation('/api/v1/configuration/apply', + {previewOperationId: item.operationId, approvalToken: item.approvalToken}); + if (!current(captured, approved.generation)) return false; + const result = resultFor(operation, item.id); + model.setApplyResult(item.id, {operation, result, status: result && result.success === true ? 'APPLIED' : 'ERROR', confirmed: false}); + } catch (error) { + if (!current(captured, approved.generation)) return false; + model.setApplyResult(item.id, {operation: null, result: null, status: 'ERROR', message: errorText(error), confirmed: false}); + } + } + if (!current(captured, approved.generation)) { applying = false; setBusy(-1); return false; } + approval = null; + const postReadStarted = now(); + await read(true); + if (!current(captured, approved.generation)) return false; + approved.items.forEach(function (item) { + const outcome = model.results.get(item.id); + const plan = planned.find(function (candidate) { return candidate.id === item.id; }); + const target = model.targets.get(item.id); + const read = reads.get(item.id); + if (outcome) outcome.confirmed = outcome.status === 'APPLIED' && !!plan && !!read && read.loadedAt >= postReadStarted + && Object.keys(plan.overrides).every(function (field) { + const snapshot = target && target.status === 'AVAILABLE' && target.fields[field]; + return snapshot && snapshot.status === 'AVAILABLE' && snapshot.value === plan.overrides[field]; + }); + }); + model.clearConfirmedDirty(); + model.invalidatePreview(); + applying = false; + setBusy(-1); + const failures = Array.from(model.results.values()).filter(function (result) { return result.status === 'ERROR'; }).length; + const unconfirmed = Array.from(model.results.values()).filter(function (result) { return result.status === 'APPLIED' && !result.confirmed; }).length; + setState({previewState: 'Not previewed', ackRequired: false, previewItems: [], + message: failures || unconfirmed ? 'Apply completed with ' + failures + ' failed target(s) and ' + unconfirmed + ' unconfirmed target(s)' : 'Apply completed; requested persisted values confirmed'}); + return true; + } + + const editor = { + get model() { return model; }, + get state() { return state; }, + scopeChanged: function () { + syncContext(); + return Promise.resolve(model); + }, + read, preview, apply, + edit: function (path, value) { model.edit(path, value); clearApproval('Stale'); return model; }, + reset: function (path) { model.reset(path); clearApproval('Stale'); return model; }, + resetAll: function () { model.resetAll(); clearApproval('Stale'); return model; }, + invalidateReads: function () { reads.clear(); if (!applying) clearApproval('Stale'); return model; }, + clear: function () { releasePreviews(approval && approval.items); generation++; knownContext = context(); queuedRead = null; queuedForce = false; queuedOnlyFailed = false; reads.clear(); approval = null; applying = false; model.setTargets([]); state = {busy: false, error: '', message: '', previewState: 'Not previewed', ackRequired: false, previewItems: []}; busyCount = 0; notify(); return model; } + }; + return editor; + } + return {create}; +})); diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 465cfe05..a732ce66 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -10,7 +10,7 @@
- + VotingPluginControl @@ -31,12 +31,6 @@ - Connecting… @@ -88,18 +82,19 @@

Sign in to your network

+ + + + + + diff --git a/src/main/resources/web/rewards.js b/src/main/resources/web/rewards.js new file mode 100644 index 00000000..07e9e33b --- /dev/null +++ b/src/main/resources/web/rewards.js @@ -0,0 +1,327 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + root.ControlRewardsEditor = api; +}(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + const FILES = ['VoteSites.yml', 'SpecialRewards.yml', 'Config.yml']; + const MAX_CHANGED = 8; + const CACHE_MS = 30000; + const STATE = '/api/v1/configuration/rewards/state'; + const PREVIEW = '/api/v1/configuration/rewards/preview'; + const DISCARD = '/api/v1/configuration/rewards/discard-preview'; + const REWARD_DIR = 'Rewards/'; + function id(target) { return target && (target.nodeId || target.id); } + function resultFor(operation, nodeId) { + const results = operation && operation.results; + return Array.isArray(results) ? results.find(item => item.nodeId === nodeId) : results && results[nodeId]; + } + function same(a, b) { return JSON.stringify(a) === JSON.stringify(b); } + function errorText(error) { return error && (error.message || error.code) || 'Request failed'; } + function create(adapter) { + if (!adapter || ['targets', 'context', 'operation', 'request'].some(name => typeof adapter[name] !== 'function')) throw new Error('Rewards adapter is required'); + const records = new Map(); + const inventories = new Map(); + const inventoryFlights = new Map(); + let context = String(adapter.context()); let generation = 0; let flight = null; let queuedRead = null; + let queuedForce = false; let queuedOnlyFailed = false; let approval = null; let applying = false; + let selected = {fileName: 'VoteSites.yml', rewardPath: ''}; let edit = null; + let state = {busy: false, error: '', message: '', previewState: 'Not previewed', previews: [], results: [], ackRequired: false}; + function notify(values) { state = Object.assign({}, state, values); if (adapter.changed) adapter.changed(); } + function targets() { return (adapter.targets() || []).filter(target => typeof id(target) === 'string' && id(target)); } + function current(captured, serial) { return String(adapter.context()) === captured && generation === serial; } + function key(nodeId, fileName) { return nodeId + ':' + fileName; } + function namedFile(fileName) { return typeof fileName === 'string' && fileName.startsWith(REWARD_DIR); } + // Listing named files uses the read-only inspection lane in addition to the + // reward-file capability. Older adapters omit this field, where the + // reward-file flag remains the backwards-compatible indication. + function namedInventorySupported(target) { + return target.rewardFilesSupported === true && target.rewardFileInventorySupported !== false; + } + function displayFile(fileName) { return namedFile(fileName) ? fileName.slice(REWARD_DIR.length) : fileName; } + function validNamedFile(fileName) { return /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}\.yml$/.test(fileName); } + function release(items) { return Promise.all((items || []).filter(item => item.operationId && item.approvalToken).map(item => + Promise.resolve(adapter.request(DISCARD, {previewOperationId: item.operationId, approvalToken: item.approvalToken})).catch(() => {}))); } + function invalidate(message) { + const prior = approval; approval = null; if (prior && !applying) void release(prior.items); + notify({previewState: prior ? 'Stale' : 'Not previewed', previews: [], ackRequired: false, message: message || ''}); + } + function sync() { + const next = String(adapter.context()); + if (next !== context) { const prior = approval; approval = null; if (prior) void release(prior.items); context = next; generation++; records.clear(); inventories.clear(); inventoryFlights.clear(); flight = null; queuedRead = null; queuedForce = false; queuedOnlyFailed = false; edit = null; selected = {fileName: 'VoteSites.yml', rewardPath: ''}; notify({busy: false, error: '', message: '', previewState: 'Not previewed', previews: [], results: [], ackRequired: false}); } + const live = new Set(targets().map(id)); + [...records.keys()].forEach(recordKey => { if (!live.has(recordKey.split(':')[0])) records.delete(recordKey); }); + [...inventories.keys()].forEach(nodeId => { if (!live.has(nodeId)) inventories.delete(nodeId); }); + } + function record(nodeId, fileName) { return records.get(key(nodeId, fileName)); } + function signature() { + return JSON.stringify({context, selected, edit, targets: targets().map(target => [id(target), target.sessionId, + files().map(fileName => { const item = record(id(target), fileName); return [fileName, item && item.status, item && item.revision]; })])}); + } + function files() { + const names = new Set(FILES); + inventories.forEach(item => (item.files || []).forEach(fileName => names.add(REWARD_DIR + fileName))); + return [...names]; + } + async function inventory(target, force) { + sync(); + const nodeId = id(target); if (!nodeId) return {status: 'ERROR', files: [], message: 'Target has no node ID'}; + if (!namedInventorySupported(target)) { + const unsupported = {status: 'UNSUPPORTED', files: [], message: 'Named reward file discovery is unsupported by this connector', sessionId: target.sessionId}; + inventories.set(nodeId, unsupported); return unsupported; + } + const cached = inventories.get(nodeId); + if (!force && cached && cached.sessionId === target.sessionId && cached.status === 'AVAILABLE' && Date.now() - cached.loadedAt <= CACHE_MS) return cached; + if (inventoryFlights.has(nodeId)) return inventoryFlights.get(nodeId); + if (typeof adapter.inspect !== 'function') { + const unavailable = {status: 'UNSUPPORTED', files: [], message: 'Named reward file inventory is unavailable', sessionId: target.sessionId}; + inventories.set(nodeId, unavailable); return unavailable; + } + const captured = context; const serial = generation; + const run = (async () => { + try { + const response = await adapter.inspect(target); + const raw = Array.isArray(response) ? response : response && response.files; + if (!Array.isArray(raw) || raw.length > 100 || raw.some(fileName => typeof fileName !== 'string' || !validNamedFile(fileName))) + throw new Error('Named reward file inventory is invalid'); + const listed = [...new Set(raw)]; + const item = {status: 'AVAILABLE', files: listed, sessionId: target.sessionId, loadedAt: Date.now()}; + if (!current(captured, serial)) return {status: 'ERROR', files: [], message: 'Workspace changed while reading named reward files'}; + inventories.set(nodeId, item); return item; + } catch (error) { + const failed = {status: 'ERROR', files: [], message: errorText(error), sessionId: target.sessionId}; + if (!current(captured, serial)) return failed; + inventories.set(nodeId, failed); return failed; + } + })(); + inventoryFlights.set(nodeId, run); + try { return await run; } finally { if (inventoryFlights.get(nodeId) === run) inventoryFlights.delete(nodeId); } + } + async function loadInventories(captured, serial, force) { + await Promise.all(targets().map(target => inventory(target, force))); + if (!current(captured, serial)) return false; + const namedFiles = files().filter(namedFile); + for (const recordKey of [...records.keys()]) { + if (recordKey.includes(':' + REWARD_DIR) && !namedFiles.some(fileName => recordKey.endsWith(':' + fileName))) records.delete(recordKey); + } + for (const fileName of namedFiles) for (const target of targets()) { + const nodeId = id(target); const inv = inventories.get(nodeId); const recordKey = key(nodeId, fileName); + const present = inv && inv.status === 'AVAILABLE' && inv.files.includes(displayFile(fileName)); + if (!present) { + records.set(recordKey, {status: inv && inv.status === 'AVAILABLE' ? 'MISSING' : inv ? inv.status : 'ERROR', + files: [], scopes: [], message: inv && inv.message, sessionId: target.sessionId}); + } else if (!records.has(recordKey) || records.get(recordKey).sessionId !== target.sessionId) { + records.set(recordKey, {status: 'UNLOADED', files: [], scopes: [], message: 'Select this file to read its contents', sessionId: target.sessionId}); + } + } + return current(captured, serial); + } + function scopes() { + const values = new Map(); + for (const fileName of files()) for (const target of targets()) { + const item = record(id(target), fileName); + for (const scope of item && item.status === 'AVAILABLE' ? item.scopes : []) values.set(fileName + ':' + scope.path, {fileName, path: scope.path}); + if (namedFile(fileName)) { + const inv = inventories.get(id(target)); + if (inv && inv.status === 'AVAILABLE' && inv.files.includes(displayFile(fileName))) values.set(fileName + ':$', {fileName, path: '$'}); + } + } + return [...values.values()]; + } + function targetScope(target) { + const item = record(id(target), selected.fileName); + const scope = item && item.scopes && item.scopes.find(value => value.path === selected.rewardPath); + return {nodeId: id(target), status: item ? item.status : 'MISSING', revision: item && item.revision, + message: item && item.message, scope: scope || null}; + } + function aggregate(field) { + const entries = targets().map(target => { + const info = targetScope(target); + return {nodeId: info.nodeId, status: info.status !== 'AVAILABLE' ? info.status : !info.scope ? 'MISSING' + : Object.prototype.hasOwnProperty.call(info.scope.fields || {}, field) ? 'AVAILABLE' : 'MISSING', + value: info.scope && info.scope.fields && info.scope.fields[field]}; + }); + const values = entries.filter(item => item.status === 'AVAILABLE'); + return {status: entries.some(item => item.status === 'ERROR') ? 'ERROR' + : entries.some(item => item.status === 'UNSUPPORTED') ? 'UNSUPPORTED' + : !values.length ? 'MISSING' : entries.some(item => item.status === 'MISSING') ? 'MISSING' + : values.every(item => same(item.value, values[0].value)) ? 'SAME' : 'MIXED', + value: values.length && values.every(item => same(item.value, values[0].value)) ? values[0].value : undefined, targets: entries}; + } + async function read(force, onlyFailed) { + sync(); const captured = context; const serial = generation; + if (flight) { + if (!force && !onlyFailed) return flight; + queuedForce = queuedForce || force === true; + queuedOnlyFailed = queuedOnlyFailed || onlyFailed === true; + if (!queuedRead) { + const pending = flight; + queuedRead = pending.catch(() => {}).then(() => { + if (!current(captured, serial)) return records; + const nextForce = queuedForce; const nextOnlyFailed = !nextForce && queuedOnlyFailed; + queuedRead = null; queuedForce = false; queuedOnlyFailed = false; + return read(nextForce, nextOnlyFailed); + }); + } + return queuedRead; + } + const run = (async () => { + if (!(await loadInventories(captured, serial, force === true))) return records; + const allFiles = FILES.concat(namedFile(selected.fileName) ? [selected.fileName] : []); + const requested = allFiles.flatMap(fileName => targets().filter(target => { + const item = record(id(target), fileName); + if (namedFile(fileName)) { + const inv = inventories.get(id(target)); + if (!inv || inv.status === 'UNSUPPORTED' || inv.status === 'ERROR' || !inv.files.includes(displayFile(fileName))) return false; + } + return onlyFailed ? !item || item.status !== 'AVAILABLE' : force || !item || item.status !== 'AVAILABLE' || item.sessionId !== target.sessionId || Date.now() - item.loadedAt > CACHE_MS; + }).map(target => ({target, fileName}))); + if (!requested.length) return records; + notify({busy: true, error: '', message: 'Reading reward configuration from each target…'}); + for (const fileName of allFiles) { + const wanted = requested.filter(item => item.fileName === fileName); + if (!wanted.length || !current(captured, serial)) continue; + const supportsFile = target => namedFile(fileName) ? target.rewardFilesSupported === true : target.supported !== false; + const eligible = wanted.filter(({target}) => target.online === true && supportsFile(target)); + for (const {target} of wanted.filter(({target}) => target.online !== true || !supportsFile(target))) + records.set(key(id(target), fileName), {status: !supportsFile(target) ? 'UNSUPPORTED' : 'ERROR', + message: !supportsFile(target) ? namedFile(fileName) ? 'Connector does not support named reward files' + : 'Connector does not support file configuration' : 'Target offline', sessionId: target.sessionId, scopes: []}); + if (!eligible.length) continue; + try { + const operation = await adapter.operation('/api/v1/configuration/read', {nodeIds: eligible.map(({target}) => id(target)), configuration: {domain: 'file', fileName}}); + await Promise.all(eligible.map(async ({target}) => { + const nodeId = id(target); const result = resultFor(operation, nodeId); + if (!current(captured, serial)) return; + if (!result || result.success !== true) { records.set(key(nodeId, fileName), {status: 'ERROR', message: result && (result.message || result.code) || 'READ failed', sessionId: target.sessionId, scopes: []}); return; } + try { + const typed = await adapter.request(STATE, {readOperationId: operation.operationId, nodeId, fileName}); + if (!current(captured, serial)) return; + if (typed.nodeId !== nodeId || typed.fileName !== fileName || typed.sessionId !== target.sessionId || typed.revision !== result.revision) throw new Error('Read identity changed; read again'); + records.set(key(nodeId, fileName), Object.assign({}, typed, {status: 'AVAILABLE', loadedAt: Date.now()})); + } catch (error) { if (current(captured, serial)) records.set(key(nodeId, fileName), {status: 'ERROR', message: errorText(error), sessionId: target.sessionId, scopes: []}); } + })); + } catch (error) { + if (current(captured, serial)) eligible.forEach(({target}) => records.set(key(id(target), fileName), {status: 'ERROR', message: errorText(error), sessionId: target.sessionId, scopes: []})); + } + } + if (current(captured, serial)) { if (approval && approval.signature !== signature() && !applying) invalidate('Source changed; preview again'); notify({busy: false, message: 'Reward inventory refreshed'}); } + return records; + })(); + const pending = run.finally(() => { if (flight === pending) flight = null; }); + flight = pending; return pending; + } + function plan() { + if (!edit || !selected.rewardPath) return []; + return targets().map(target => { + const info = targetScope(target); const scope = info.scope; + const namedUnsupportedAction = namedFile(selected.fileName) && ['CREATE_REWARD', 'REMOVE_REWARD'].includes(edit.operation); + const advanced = scope?.advancedKeys || []; + const advancedField = advanced.includes(edit.field) || (edit.field?.startsWith('Messages.') && advanced.includes('Messages')); + const itemFieldMissing = edit.operation === 'SET_SCALAR' && /^Items\.[A-Za-z0-9_-]{1,64}\.(Material|Amount)$/.test(edit.field || '') + && !Object.prototype.hasOwnProperty.call(scope?.fields || {}, edit.field); + const nestedFieldMissing = edit.operation === 'SET_SCALAR' && edit.field?.includes('.') + && !Object.prototype.hasOwnProperty.call(scope?.fields || {}, edit.field); + const commands = scope?.fields?.Commands; + const missingCommands = ['REMOVE_LIST_ENTRY', 'REPLACE_LIST'].includes(edit.operation) && !Array.isArray(commands); + const ambiguousRemoval = edit.operation === 'REMOVE_LIST_ENTRY' && Array.isArray(commands) + && commands.filter(value => value === edit.value).length !== 1; + // The server rejects a preview whose source-preserving patch does not + // change a target. Treat matching scalar/list values as an explicit + // exclusion before opening a per-target preview, rather than turning a + // mixed workspace into an all-or-nothing failed preview. + const unchangedScalar = edit.operation === 'SET_SCALAR' && typeof scope?.fields?.[edit.field] === 'string' + && String(scope.fields[edit.field]) === String(edit.value); + const unchangedList = edit.operation === 'REPLACE_LIST' && Array.isArray(commands) && same(commands, edit.value); + const unchanged = unchangedScalar || unchangedList; + const status = namedUnsupportedAction ? 'UNSUPPORTED' : info.status !== 'AVAILABLE' ? info.status : !scope ? 'MISSING' + : !scope.editable ? 'UNSUPPORTED' : edit.operation === 'CREATE_REWARD' ? scope.status === 'MISSING' ? 'READY' : 'CONFLICT' + : edit.operation === 'REMOVE_REWARD' ? scope.status === 'PRESENT' ? 'READY' : 'UNCHANGED' + : scope.status !== 'PRESENT' ? 'MISSING' : advancedField || itemFieldMissing || nestedFieldMissing ? 'UNSUPPORTED' + : missingCommands ? 'MISSING' : ambiguousRemoval ? 'CONFLICT' : unchanged ? 'UNCHANGED' : 'READY'; + return {nodeId: info.nodeId, status, revision: info.revision, scope}; + }); + } + async function preview() { + sync(); if (state.busy || applying || !edit || !selected.rewardPath || adapter.active && !adapter.active()) return false; + await release(approval && approval.items); approval = null; + const captured = context; const serial = generation; const before = signature(); const plans = plan(); + const ready = plans.filter(item => item.status === 'READY'); + if (!ready.length || ready.length > MAX_CHANGED) { notify({error: ready.length ? 'At most eight changed targets can be previewed' : 'No eligible changed targets', previewState: 'Stale'}); return false; } + notify({busy: true, error: '', message: 'Previewing exact per-target reward changes…', previews: [], previewState: 'Not previewed'}); + const items = await Promise.all(plans.map(async item => { + if (item.status !== 'READY') return Object.assign({}, item, {status: 'SKIP', reason: item.status}); + const source = record(item.nodeId, selected.fileName); + try { + const operation = await adapter.operation(PREVIEW, {readOperationId: source.readOperationId, nodeId: item.nodeId, + fileName: selected.fileName, rewardPath: selected.rewardPath, action: edit.operation, field: edit.field, value: edit.value}); + const result = resultFor(operation, item.nodeId); + const before = item.scope && item.scope.fields && item.scope.fields[edit.field]; + const expectedValue = edit.operation === 'APPEND_LIST_ENTRY' ? [...(Array.isArray(before) ? before : []), edit.value] + : edit.operation === 'REMOVE_LIST_ENTRY' && Array.isArray(before) ? before.filter(value => value !== edit.value) + : edit.operation === 'REPLACE_LIST' ? edit.value : edit.operation === 'CREATE_REWARD' ? [edit.value] : edit.value; + return {nodeId: item.nodeId, status: operation.state === 'SUCCEEDED' && operation.approvalToken && result && result.success === true && result.revision === item.revision ? 'READY' : 'ERROR', + operationId: operation.operationId, approvalToken: operation.approvalToken, operation, revision: item.revision, expectedValue}; + } catch (error) { return {nodeId: item.nodeId, status: 'ERROR', message: errorText(error)}; } + })); + if (!current(captured, serial) || before !== signature() || items.some(item => item.status === 'ERROR')) { + void release(items); if (current(captured, serial)) notify({busy: false, error: 'Preview failed or draft changed', previewState: 'Stale', previews: items}); return false; + } + approval = {signature: before, context: captured, serial, excluded: items.some(item => item.status !== 'READY'), + items: items.filter(item => item.status === 'READY'), excludedItems: items.filter(item => item.status !== 'READY'), + edit: Object.assign({}, edit), selected: Object.assign({}, selected)}; + notify({busy: false, previewState: 'Ready', previews: items, ackRequired: items.some(item => item.status !== 'READY'), message: 'Preview ready'}); return true; + } + async function apply(acknowledged) { + if (!approval || state.busy || applying || approval.signature !== signature() || !current(approval.context, approval.serial)) { invalidate('Preview is stale'); return false; } + if (state.ackRequired && acknowledged !== true) { notify({error: 'Acknowledge excluded or unavailable targets'}); return false; } + const approved = approval; applying = true; await read(true); + if (!current(approved.context, approved.serial) || approved.signature !== signature()) { applying = false; invalidate('Source changed; preview again'); return false; } + notify({busy: true, error: '', message: 'Applying approved reward changes…'}); + const outcomes = approved.excludedItems.map(item => ({nodeId: item.nodeId, status: item.reason || item.status, + message: 'Excluded from this apply', confirmed: false})); + for (const item of approved.items) { + if (!current(approved.context, approved.serial)) break; + try { + const operation = await adapter.operation('/api/v1/configuration/apply', {previewOperationId: item.operationId, approvalToken: item.approvalToken}); + const result = resultFor(operation, item.nodeId); + outcomes.push({nodeId: item.nodeId, status: result && result.success === true && operation.state === 'SUCCEEDED' ? 'APPLIED' : 'ERROR', result, operation}); + } catch (error) { outcomes.push({nodeId: item.nodeId, status: 'ERROR', message: errorText(error)}); } + } + const order = targets().map(id); + outcomes.sort((left, right) => order.indexOf(left.nodeId) - order.indexOf(right.nodeId)); + approval = null; + if (current(approved.context, approved.serial)) await read(true); + if (!current(approved.context, approved.serial)) { applying = false; return false; } + outcomes.forEach(item => { + if (item.status === 'UNCHANGED') { + item.confirmed = plan().some(candidate => candidate.nodeId === item.nodeId && candidate.status === 'UNCHANGED'); + return; + } + const source = record(item.nodeId, approved.selected.fileName); + const scope = source && source.scopes.find(value => value.path === approved.selected.rewardPath); + const fieldValue = scope && scope.fields && scope.fields[approved.edit.field]; + const approvedItem = approved.items.find(candidate => candidate.nodeId === item.nodeId); + item.confirmed = item.status === 'APPLIED' && source && source.status === 'AVAILABLE' && ( + approved.edit.operation === 'REMOVE_REWARD' ? scope && scope.status === 'MISSING' + : approved.edit.operation === 'CREATE_REWARD' ? scope && scope.status === 'PRESENT' && same(scope.fields?.Commands, approvedItem.expectedValue) + : approved.edit.operation === 'APPEND_LIST_ENTRY' ? same(fieldValue, approvedItem.expectedValue) + : approved.edit.operation === 'REMOVE_LIST_ENTRY' ? same(fieldValue, approvedItem.expectedValue) + : approved.edit.operation === 'REPLACE_LIST' ? same(fieldValue, approvedItem.expectedValue) + : approved.edit.operation === 'SET_SCALAR' ? same(fieldValue, approved.edit.value) || String(fieldValue) === String(approved.edit.value) : false); + }); + applying = false; const complete = approved.excludedItems.every(item => item.reason === 'UNCHANGED') + && outcomes.length === targets().length && outcomes.every(item => item.confirmed); + if (complete) edit = null; + notify({busy: false, previewState: 'Not previewed', previews: [], results: outcomes, ackRequired: false, + message: complete ? 'Reward changes confirmed from fresh reads' : 'Some targets failed or remain unconfirmed; inspect each result'}); + return complete; + } + return {get state() { return state; }, get selected() { return selected; }, get edit() { return edit; }, targets, record, scopes, targetScope, aggregate, plan, read, preview, apply, files, inventory, + scopeChanged() { sync(); return records; }, select(fileName, rewardPath) { sync(); if (applying) return; if (selected.fileName !== fileName || selected.rewardPath !== rewardPath) { selected = {fileName, rewardPath}; edit = null; invalidate(); } }, + setEdit(operation, field, value) { if (applying) return; edit = {operation, field, value}; invalidate(); }, reset() { if (applying) return; edit = null; invalidate(); }, + invalidateReads() { records.clear(); if (!applying) invalidate('Configuration changed; read and preview again'); }, + clear() { const prior = approval; approval = null; if (prior) void release(prior.items); generation++; flight = null; queuedRead = null; queuedForce = false; queuedOnlyFailed = false; records.clear(); inventories.clear(); edit = null; selected = {fileName: 'VoteSites.yml', rewardPath: ''}; notify({busy: false, error: '', message: '', previewState: 'Not previewed', previews: [], results: [], ackRequired: false}); }}; + } + return {create, FILES}; +})); diff --git a/src/main/resources/web/vote-sites-state.js b/src/main/resources/web/vote-sites-state.js new file mode 100644 index 00000000..1f730d39 --- /dev/null +++ b/src/main/resources/web/vote-sites-state.js @@ -0,0 +1,288 @@ +(function (root, factory) { + const api = factory(root.ControlConfiguration || (typeof require === 'function' ? require('./configuration-state.js') : null)); + if (typeof module === 'object' && module.exports) module.exports = api; + root.ControlVoteSites = api; +}(typeof globalThis !== 'undefined' ? globalThis : this, function (configuration) { + 'use strict'; + if (!configuration || !configuration.aggregateSnapshots) throw new Error('ControlConfiguration.aggregateSnapshots is required'); + + const TARGET_STATUSES = new Set(['AVAILABLE', 'ERROR', 'UNSUPPORTED']); + const FIELD_STATUSES = new Set(['AVAILABLE', 'MISSING', 'UNSUPPORTED']); + const WORKFLOWS = new Set(['EDIT_EXISTING', 'CREATE_MISSING', 'REMOVE']); + const ADD_FIELDS = ['Enabled', 'Name', 'ServiceSite', 'VoteURL', 'VoteDelay', 'Priority', 'Hidden', + 'DisplayItem.Material', 'DisplayItem.Amount']; + + function parseIntegerField(path, raw) { + if (typeof raw !== 'string' || !/^-?(0|[1-9][0-9]*)$/.test(raw)) return null; + const value = Number(raw); + if (!Number.isSafeInteger(value)) return null; + if (path === 'Priority') return value >= -2147483648 && value <= 2147483647 ? value : null; + if (path === 'DisplayItem.Amount') return value >= 1 && value <= 64 ? value : null; + return null; + } + + function parseStringField(path, raw) { + if (typeof raw !== 'string' || raw.includes('\0')) return null; + const length = Array.from(raw).length; + if (path === 'Name') return length <= 200 ? raw : null; + if (path === 'DisplayItem.Material') return /^[A-Za-z0-9_:-]{1,100}$/.test(raw) ? raw : null; + if (!['ServiceSite', 'VoteURL', 'VoteDelay'].includes(path)) return null; + const max = path === 'ServiceSite' ? 2048 : path === 'VoteURL' ? 500 : 64; + return length >= 1 && length <= max && !/[\u0000-\u001f\u007f-\u009f|]/.test(raw) ? raw : null; + } + + function text(value) { return typeof value === 'string' ? value : ''; } + function clone(value) { + if (value === undefined) return undefined; + return JSON.parse(JSON.stringify(value)); + } + function equal(left, right) { return JSON.stringify(canonical(left)) === JSON.stringify(canonical(right)); } + function canonical(value) { + if (Array.isArray(value)) return value.map(canonical); + if (value && typeof value === 'object') { + const result = {}; + Object.keys(value).sort().forEach(function (key) { result[key] = canonical(value[key]); }); + return result; + } + return value; + } + function fieldsSnapshot(fields) { + const result = {}; + if (!fields || typeof fields !== 'object') return result; + Object.keys(fields).forEach(function (name) { + const field = fields[name] || {}; + result[name] = {status: FIELD_STATUSES.has(field.status) ? field.status : 'MISSING', value: clone(field.value)}; + }); + return result; + } + function siteSnapshot(site) { + if (!site || typeof site !== 'object' || typeof site.siteKey !== 'string' || !site.siteKey) return null; + return {siteKey: site.siteKey, editable: site.editable === true, rewardsConfigured: site.rewardsConfigured === true, + fields: fieldsSnapshot(site.fields)}; + } + function targetRecord(target) { + return {nodeId: target.nodeId, sessionId: text(target.sessionId), status: 'MISSING', revision: '', sites: [], + code: '', message: ''}; + } + function targetSite(target, key) { + return target.sites.find(function (site) { return site.siteKey === key; }) || null; + } + function targetStatus(target) { + return target.status === 'ERROR' ? 'ERROR' : target.status === 'UNSUPPORTED' ? 'UNSUPPORTED' : 'MISSING'; + } + function completeAddFields(fields) { + return ADD_FIELDS.every(function (name) { + return Object.prototype.hasOwnProperty.call(fields, name) && fields[name] !== undefined; + }); + } + + class VoteSitesState { + constructor(options) { + this.targets = new Map(); + this.dirty = new Map(); + this.addFields = {}; + this.createTargetIds = new Set(); + this.createMissing = true; + this.createEditExisting = false; + this.selectedSiteKey = ''; + this.workflow = 'EDIT_EXISTING'; + this.mode = 'EDIT_EXISTING'; + this.preview = null; + this.results = new Map(); + this.requestedPlans = null; + const targets = Array.isArray(options) ? options : options && options.targets; + if (targets) this.setTargets(targets); + } + + setTargets(targets) { + const next = new Map(); + (Array.isArray(targets) ? targets : []).forEach(function (target) { + if (!target || typeof target.nodeId !== 'string' || !target.nodeId) return; + const sessionId = text(target.sessionId); + const current = this.targets.get(target.nodeId); + next.set(target.nodeId, current && current.sessionId === sessionId ? current : targetRecord({nodeId: target.nodeId, sessionId})); + }, this); + const changed = !equal(Array.from(this.targets.keys()), Array.from(next.keys())) || Array.from(next.keys()).some(function (id) { + return !this.targets.has(id) || this.targets.get(id).sessionId !== next.get(id).sessionId; + }, this); + this.targets = next; + this.createTargetIds.forEach(function (id) { if (!next.has(id)) this.createTargetIds.delete(id); }, this); + if (changed) { + // A draft is bound to the exact target set and sessions. Never carry an + // earlier workspace's overrides into a newly selected or reconnected node. + this.dirty.clear(); this.addFields = {}; this.createTargetIds.clear(); + this.createMissing = true; this.createEditExisting = false; + this.workflow = 'EDIT_EXISTING'; this.mode = 'EDIT_EXISTING'; this.partialPolicy = 'existing'; + this.results.clear(); this.requestedPlans = null; this.invalidatePreview(); + } + return this; + } + + setRead(nodeId, read) { + const target = this.targets.get(nodeId); + if (!target || !read || typeof read !== 'object') return this; + const sites = Array.isArray(read.sites) ? read.sites.map(siteSnapshot).filter(Boolean) : []; + const next = {nodeId, sessionId: text(read.sessionId), status: TARGET_STATUSES.has(read.status) ? read.status : 'ERROR', + revision: text(read.revision), sites, code: text(read.code), message: text(read.message)}; + if (!equal(target, next)) this.invalidatePreview(); + this.targets.set(nodeId, next); + return this; + } + + selectSite(siteKey) { + const next = text(siteKey); + if (next !== this.selectedSiteKey) { + this.selectedSiteKey = next; + // A draft belongs to one exact key. It must never become an edit/add/remove for another site. + this.dirty.clear(); this.addFields = {}; this.createTargetIds.clear(); this.createEditExisting = false; + this.workflow = 'EDIT_EXISTING'; this.mode = 'EDIT_EXISTING'; this.requestedPlans = null; + } + this.invalidatePreview(); return this; + } + setWorkflow(workflow) { + if (WORKFLOWS.has(workflow)) { this.workflow = workflow; this.mode = workflow; this.invalidatePreview(); } + return this; + } + setMode(mode) { return this.setWorkflow(mode); } + setCreateTargets(ids) { + this.createTargetIds = new Set((Array.isArray(ids) ? ids : []).filter(function (id) { return this.targets.has(id); }, this)); + this.invalidatePreview(); return this; + } + setCreateMissing(enabled) { this.createMissing = enabled === true; this.invalidatePreview(); return this; } + setCreateEditExisting(enabled) { this.createEditExisting = enabled === true; this.invalidatePreview(); return this; } + setAddFields(fields) { this.addFields = fields && typeof fields === 'object' ? clone(fields) : {}; this.invalidatePreview(); return this; } + edit(field, value) { + if (typeof field === 'string' && field && value !== undefined) { this.dirty.set(field, clone(value)); this.invalidatePreview(); } + return this; + } + reset(field) { if (typeof field === 'string' && field) this.dirty.delete(field); this.invalidatePreview(); return this; } + resetAll() { this.dirty.clear(); this.invalidatePreview(); return this; } + + aggregate(siteKey) { + const key = siteKey === undefined ? this.selectedSiteKey : text(siteKey); + const entries = []; + let present = 0; + this.targets.forEach(function (target) { + const site = target.status === 'AVAILABLE' ? targetSite(target, key) : null; + if (site) present += 1; + entries.push({nodeId: target.nodeId, sessionId: target.sessionId, status: site ? 'AVAILABLE' : targetStatus(target), + revision: target.revision, site: site && clone(site), code: target.code, message: target.message}); + }); + const presence = !present ? 'NONE' : present === this.targets.size ? 'ALL' : 'PARTIAL'; + const fields = {}; + const names = new Set(); + entries.forEach(function (entry) { if (entry.site) Object.keys(entry.site.fields).forEach(function (name) { names.add(name); }); }); + names.forEach(function (name) { fields[name] = this.aggregateField(name, key); }, this); + return {siteKey: key, presence, fields, targets: entries}; + } + + aggregateField(field, siteKey) { + const key = siteKey === undefined ? this.selectedSiteKey : text(siteKey); + const entries = []; + this.targets.forEach(function (target) { + const site = target.status === 'AVAILABLE' ? targetSite(target, key) : null; + const snapshot = site && site.fields[field]; + const status = target.status === 'ERROR' ? 'ERROR' : target.status === 'UNSUPPORTED' ? 'UNSUPPORTED' + : !site || !snapshot ? 'MISSING' : snapshot.status; + const value = status === 'AVAILABLE' ? clone(snapshot.value) : undefined; + entries.push({nodeId: target.nodeId, sessionId: target.sessionId, revision: target.revision, status, value, + code: target.code, message: target.message}); + }); + const aggregate = configuration.aggregateSnapshots(entries, equal); + if (aggregate.value !== undefined) aggregate.value = clone(aggregate.value); + return aggregate; + } + + plans() { + const key = this.selectedSiteKey; + const validAdd = completeAddFields(this.addFields); + return Array.from(this.targets.values()).map(function (target) { + const site = target.status === 'AVAILABLE' ? targetSite(target, key) : null; + const base = {nodeId: target.nodeId, sessionId: target.sessionId, revision: target.revision, siteKey: key, + operation: 'UNCHANGED', changes: {}, fields: {}, skipped: [], status: target.status}; + if (target.status !== 'AVAILABLE') { base.operation = 'SKIP'; base.skipped.push({status: targetStatus(target)}); return base; } + if (this.workflow === 'REMOVE') { + if (!site) return base; + if (!site.editable) { base.operation = 'SKIP'; base.skipped.push({status: 'NONEDITABLE'}); return base; } + base.operation = 'REMOVE'; return base; + } + if (!site) { + if (this.workflow !== 'CREATE_MISSING' || !this.createMissing || (this.createTargetIds.size && !this.createTargetIds.has(target.nodeId))) { + if (this.selectedSiteKey && (this.dirty.size || this.workflow === 'REMOVE')) { + base.skipped.push({status: 'MISSING_SITE'}); + } + return base; + } + if (!validAdd) { base.operation = 'SKIP'; base.skipped.push({status: 'INVALID_ADD_FIELDS'}); return base; } + base.operation = 'ADD'; base.fields = clone(this.addFields); return base; + } + if (this.workflow === 'CREATE_MISSING' && !this.createEditExisting) return base; + if (!site.editable) { base.operation = 'SKIP'; base.skipped.push({status: 'NONEDITABLE'}); return base; } + this.dirty.forEach(function (value, field) { + const snapshot = site.fields[field]; + // MISSING is an editable insertion into an existing site. Only an explicit + // UNSUPPORTED field is excluded from an edit proposal. + if (!snapshot || snapshot.status === 'MISSING') base.changes[field] = clone(value); + else if (snapshot.status !== 'AVAILABLE') base.skipped.push({field, status: snapshot.status}); + else if (!equal(snapshot.value, value)) base.changes[field] = clone(value); + }); + if (Object.keys(base.changes).length) base.operation = 'EDIT'; + return base; + }, this); + } + + changedCount() { return this.plans().filter(function (plan) { return ['ADD', 'EDIT', 'REMOVE'].includes(plan.operation); }).length; } + exceedsChangedTargetLimit(limit) { return this.changedCount() > (typeof limit === 'number' ? limit : 8); } + signature() { + const targets = Array.from(this.targets.values()).map(function (target) { return canonical(target); }); + return JSON.stringify(canonical({targets, selectedSiteKey: this.selectedSiteKey, workflow: this.workflow, mode: this.mode, + dirty: Array.from(this.dirty.entries()), addFields: this.addFields, createTargetIds: Array.from(this.createTargetIds), + createMissing: this.createMissing, createEditExisting: this.createEditExisting})); + } + beginPreview() { this.invalidatePreview(); return this.signature(); } + finishPreview(signature, items) { + if (signature !== this.signature() || !Array.isArray(items)) return false; + this.preview = {signature, items}; return true; + } + previewCurrent() { return this.preview && this.preview.signature === this.signature() ? this.preview : null; } + invalidatePreview() { this.preview = null; return this; } + markApplyRequested() { this.requestedPlans = clone(this.plans()); return this; } + setApplyResult(nodeId, result) { + if (!this.requestedPlans) this.markApplyRequested(); + if (typeof nodeId === 'string' && nodeId) this.results.set(nodeId, clone(result)); + return this; + } + clearConfirmedDirty() { + const requested = this.requestedPlans || this.plans(); + this.dirty.forEach(function (value, field) { + const edits = requested.filter(function (plan) { return plan.operation === 'EDIT' && Object.prototype.hasOwnProperty.call(plan.changes, field); }); + if (edits.length && edits.every(function (plan) { + const target = this.targets.get(plan.nodeId); const site = target && target.status === 'AVAILABLE' && targetSite(target, plan.siteKey); + const snapshot = site && site.fields[field]; + return this.results.get(plan.nodeId)?.confirmed === true + && snapshot && snapshot.status === 'AVAILABLE' && equal(snapshot.value, value); + }, this)) this.dirty.delete(field); + }, this); + const adds = requested.filter(function (plan) { return plan.operation === 'ADD'; }); + if (adds.length && adds.every(function (plan) { + const target = this.targets.get(plan.nodeId); const site = target && target.status === 'AVAILABLE' && targetSite(target, plan.siteKey); + return this.results.get(plan.nodeId)?.confirmed === true && site && ADD_FIELDS.every(function (field) { + const snapshot = site.fields[field]; return snapshot && snapshot.status === 'AVAILABLE' && equal(snapshot.value, plan.fields[field]); + }); + }, this)) this.addFields = {}; + const removals = requested.filter(function (plan) { return plan.operation === 'REMOVE'; }); + if (this.workflow === 'REMOVE' && removals.length && requested.every(function (plan) { + if (plan.operation === 'UNCHANGED') return true; + if (plan.operation !== 'REMOVE') return false; + const target = this.targets.get(plan.nodeId); + return this.results.get(plan.nodeId)?.confirmed === true && target?.status === 'AVAILABLE' + && !targetSite(target, plan.siteKey); + }, this)) { + this.workflow = 'EDIT_EXISTING'; this.mode = 'EDIT_EXISTING'; this.requestedPlans = null; + } + return this; + } + } + + return {VoteSitesState, ADD_FIELDS, parseIntegerField, parseStringField}; +})); diff --git a/src/main/resources/web/vote-sites.js b/src/main/resources/web/vote-sites.js new file mode 100644 index 00000000..6f1d02b9 --- /dev/null +++ b/src/main/resources/web/vote-sites.js @@ -0,0 +1,155 @@ +(function (root, factory) { + const api = factory(root.ControlVoteSites || (typeof require === 'function' ? require('./vote-sites-state.js') : null)); + if (typeof module === 'object' && module.exports) module.exports = api; + root.ControlVoteSitesEditor = api; +}(typeof globalThis !== 'undefined' ? globalThis : this, function (voteSites) { + 'use strict'; + if (!voteSites || !voteSites.VoteSitesState) throw new Error('ControlVoteSites.VoteSitesState is required'); + const CACHE_MILLIS = 30000; + const MAX_CHANGED_TARGETS = 8; + const STATE_PATH = '/api/v1/configuration/vote-sites/state'; + const PREVIEW_PATH = '/api/v1/configuration/vote-sites/preview'; + const DISCARD_PATH = '/api/v1/configuration/vote-sites/discard-preview'; + function id(target) { return target && (target.nodeId || target.id); } + function targets(adapter) { return (adapter.targets() || []).filter(function (target) { return typeof id(target) === 'string' && id(target); }); } + function resultFor(operation, nodeId) { const results = operation && operation.results; return Array.isArray(results) ? results.find(function (item) { return item.nodeId === nodeId || item.id === nodeId; }) : results && results[nodeId]; } + function errorText(error) { return error && (error.message || error.code) || 'Request failed'; } + function create(adapter) { + if (!adapter || ['targets', 'context', 'operation', 'request'].some(function (name) { return typeof adapter[name] !== 'function'; })) throw new Error('A vote-sites adapter is required'); + const model = new voteSites.VoteSitesState(); const reads = new Map(); + let approval = null; let flight = null; let queuedRead = null; let queuedForce = false; + let queuedOnlyFailed = false; let generation = 0; let knownContext = context(); let applying = false; + let state = {busy: false, error: '', message: '', previewState: 'Not previewed', ackRequired: false, previewItems: []}; + function context() { return String(adapter.context()); } + function active() { return typeof adapter.active !== 'function' || adapter.active(); } + function notify() { if (typeof adapter.changed === 'function') adapter.changed(); } + function set(values) { state = Object.assign({}, state, values); notify(); } + function current(captured, generationAtStart) { return context() === captured && generation === generationAtStart; } + function syncTargets() { model.setTargets(targets(adapter).map(function (target) { return {nodeId: id(target), sessionId: target.sessionId}; })); } + function release(items) { return Promise.all((items || []).map(function (item) { + return item.operationId && item.approvalToken ? Promise.resolve(adapter.request(DISCARD_PATH, {previewOperationId: item.operationId, approvalToken: item.approvalToken})).catch(function () {}) : Promise.resolve(); + })); } + function invalidate(message) { const old = approval; approval = null; model.invalidatePreview(); if (old && !applying) release(old.items); set({previewState: 'Stale', ackRequired: false, previewItems: [], message: message || 'Preview invalidated', error: ''}); } + function syncContext() { + syncTargets(); + if (approval && !model.previewCurrent() && !applying) invalidate(); + const next = context(); + if (next !== knownContext) { release(approval && approval.items); generation++; knownContext = next; queuedRead = null; queuedForce = false; queuedOnlyFailed = false; reads.clear(); model.results.clear(); approval = null; model.invalidatePreview(); set({busy: false, error: '', message: '', previewState: 'Not previewed', ackRequired: false, previewItems: []}); } + } + function readError(target, message, code) { reads.delete(id(target)); model.setRead(id(target), {status: target.supported === false ? 'UNSUPPORTED' : 'ERROR', sessionId: target.sessionId, revision: '', sites: [], code: code || 'READ_FAILED', message: message || ''}); } + function eligible(target) { return target.online === true && target.supported !== false; } + function cacheCurrent(target) { const item = reads.get(id(target)); return item && item.sessionId === target.sessionId && Date.now() - item.loadedAt < CACHE_MILLIS; } + async function load(operation, wanted, captured, generationAtStart) { + await Promise.all(wanted.map(async function (target) { + const nodeId = id(target); const result = resultFor(operation, nodeId); + if (!result || result.success !== true) { if (current(captured, generationAtStart)) readError(target, result && (result.message || result.code), result && result.code); return; } + try { + const typed = await adapter.request(STATE_PATH, {readOperationId: operation.operationId, nodeId}); + if (!current(captured, generationAtStart) || !model.targets.has(nodeId)) return; + if (!typed || typed.nodeId !== nodeId || typed.sessionId !== target.sessionId || typed.revision !== result.revision) { readError(target, 'Read identity changed; read the target again', 'TARGET_CHANGED'); return; } + reads.set(nodeId, {readOperationId: operation.operationId, sessionId: target.sessionId, revision: typed.revision, loadedAt: Date.now()}); + model.setRead(nodeId, Object.assign({}, typed, {status: 'AVAILABLE'})); + } catch (error) { if (current(captured, generationAtStart)) readError(target, errorText(error), error && error.code); } + })); + } + function read(force, onlyFailed) { + syncContext(); const captured = context(); const generationAtStart = generation; const key = captured + ':' + generationAtStart; + if (flight && flight.key === key) { + if (!force && !onlyFailed) return flight.promise; + queuedForce = queuedForce || force === true; + queuedOnlyFailed = queuedOnlyFailed || onlyFailed === true; + if (!queuedRead) { + queuedRead = flight.promise.catch(function () {}).then(function () { + if (!current(captured, generationAtStart)) return model; + const nextForce = queuedForce; + const nextOnlyFailed = !nextForce && queuedOnlyFailed; + queuedRead = null; queuedForce = false; queuedOnlyFailed = false; + return read(nextForce, nextOnlyFailed); + }); + } + return queuedRead; + } + const wanted = targets(adapter).filter(function (target) { + if (!eligible(target)) { readError(target, target.supported === false ? 'Vote sites are unsupported' : 'Target is offline', target.supported === false ? 'UNSUPPORTED' : 'OFFLINE'); return false; } + return onlyFailed ? model.targets.get(id(target)).status !== 'AVAILABLE' : force || !cacheCurrent(target); + }); + if (!wanted.length) return Promise.resolve(model); + set({busy: true, error: '', message: 'Reading VoteSites.yml…'}); + const currentFlight = {key, promise: null}; currentFlight.promise = (async function () { + try { + const operation = await adapter.operation('/api/v1/configuration/read', {nodeIds: wanted.map(id), configuration: {domain: 'file', fileName: 'VoteSites.yml'}}); + if (current(captured, generationAtStart)) await load(operation, wanted, captured, generationAtStart); + if (current(captured, generationAtStart)) { if (approval && !model.previewCurrent() && !applying) invalidate(); set({message: 'Vote sites loaded'}); } + } catch (error) { if (current(captured, generationAtStart)) { wanted.forEach(function (target) { readError(target, errorText(error), error && error.code); }); set({error: errorText(error), message: ''}); } } + finally { if (current(captured, generationAtStart)) set({busy: false}); if (flight === currentFlight) flight = null; } + return model; + }()); flight = currentFlight; return currentFlight.promise; + } + async function preview() { + syncContext(); if (state.busy || !active()) return false; const captured = context(); const generationAtStart = generation; + await release(approval && approval.items); approval = null; const signature = model.beginPreview(); const plans = model.plans(); const changed = plans.filter(function (plan) { return ['EDIT', 'ADD', 'REMOVE'].includes(plan.operation); }); + if (!changed.length) { set({error: 'Make an explicit change before previewing', previewState: 'Not previewed', previewItems: []}); return false; } + if (changed.length > MAX_CHANGED_TARGETS) { set({error: 'At most 8 changed targets can be previewed at once', previewState: 'Stale', previewItems: []}); return false; } + set({busy: true, error: '', message: 'Previewing vote-site changes…', previewState: 'Not previewed', ackRequired: false, previewItems: []}); + const items = plans.map(function (plan) { return {nodeId: plan.nodeId, status: plan.operation, skipped: plan.skipped}; }); let failed = false; + await Promise.all(changed.map(async function (plan) { + const index = items.findIndex(function (item) { return item.nodeId === plan.nodeId; }); const cached = reads.get(plan.nodeId); + if (!cached) { failed = true; items[index] = {nodeId: plan.nodeId, status: 'ERROR', message: 'A successful read is required', skipped: plan.skipped}; return; } + const fields = plan.operation === 'ADD' ? plan.fields : plan.operation === 'EDIT' ? plan.changes : {}; + try { + const operation = await adapter.operation(PREVIEW_PATH, {readOperationId: cached.readOperationId, nodeId: plan.nodeId, action: plan.operation, siteKey: plan.siteKey, fields}); const result = resultFor(operation, plan.nodeId); + const ready = operation.state === 'SUCCEEDED' && operation.approvalToken && result && result.success === true && result.revision === cached.revision; + items[index] = {nodeId: plan.nodeId, status: ready ? 'READY' : 'ERROR', operationId: operation.operationId, approvalToken: operation.approvalToken, skipped: plan.skipped, operation}; if (!ready) failed = true; + } catch (error) { failed = true; items[index] = {nodeId: plan.nodeId, status: 'ERROR', message: errorText(error), skipped: plan.skipped}; } + })); + if (current(captured, generationAtStart)) set({busy: false}); + if (!current(captured, generationAtStart) || signature !== model.signature() || failed || !model.finishPreview(signature, items)) { release(items); if (current(captured, generationAtStart)) set({error: failed ? 'One or more previews failed' : 'Vote-site draft changed while previewing', previewState: 'Stale', previewItems: items}); return false; } + approval = {signature, context: captured, generation: generationAtStart, items: items.filter(function (item) { return item.status === 'READY'; })}; const ackRequired = items.some(function (item) { return item.status === 'SKIP' || item.skipped.length; }); + set({message: 'Preview ready', previewState: 'Ready', ackRequired, previewItems: items}); return true; + } + async function apply(acknowledged) { + if (state.busy || applying || !approval || !model.previewCurrent() || approval.signature !== model.signature() || !current(approval.context, approval.generation)) { if (!applying) invalidate(); return false; } + if (state.ackRequired && acknowledged !== true) { set({error: 'Acknowledge unchanged or excluded targets before applying'}); return false; } + const approved = approval; applying = true; await read(true); + if (!current(approved.context, approved.generation) || approved.signature !== model.signature()) { applying = false; invalidate(); return false; } + const plans = model.plans(); model.results.clear(); model.markApplyRequested(); set({busy: true, error: '', message: 'Applying approved changes…'}); + for (const item of approved.items) { + if (!current(approved.context, approved.generation)) break; + try { const operation = await adapter.operation('/api/v1/configuration/apply', {previewOperationId: item.operationId, approvalToken: item.approvalToken}); const result = resultFor(operation, item.nodeId); if (current(approved.context, approved.generation)) model.setApplyResult(item.nodeId, {operation, result, status: result && result.success === true ? 'APPLIED' : 'ERROR', confirmed: false}); } + catch (error) { if (current(approved.context, approved.generation)) model.setApplyResult(item.nodeId, {status: 'ERROR', message: errorText(error), confirmed: false}); } + } + if (!current(approved.context, approved.generation)) { applying = false; return false; } + approval = null; const postRead = Date.now(); await read(true); + if (!current(approved.context, approved.generation)) { applying = false; return false; } + approved.items.forEach(function (item) { + const outcome = model.results.get(item.nodeId); const plan = plans.find(function (candidate) { return candidate.nodeId === item.nodeId; }); const cached = reads.get(item.nodeId); + const target = model.targets.get(item.nodeId); const site = target && target.sites.find(function (candidate) { return candidate.siteKey === plan.siteKey; }); + const exact = plan && (plan.operation === 'REMOVE' ? !site : plan.operation === 'ADD' + ? site && Object.keys(plan.fields).every(function (field) { return site.fields[field] && site.fields[field].status === 'AVAILABLE' && JSON.stringify(site.fields[field].value) === JSON.stringify(plan.fields[field]); }) + : plan.operation === 'EDIT' && site && Object.keys(plan.changes).every(function (field) { return site.fields[field] && site.fields[field].status === 'AVAILABLE' && JSON.stringify(site.fields[field].value) === JSON.stringify(plan.changes[field]); })); + if (outcome) outcome.confirmed = outcome.status === 'APPLIED' && cached && cached.loadedAt >= postRead && exact; + }); + model.clearConfirmedDirty(); model.invalidatePreview(); applying = false; + const failed = [...model.results.values()].some(function (outcome) { return outcome.status !== 'APPLIED' || !outcome.confirmed; }); + set({busy: false, previewState: 'Not previewed', ackRequired: false, previewItems: [], message: failed ? 'Apply had failures or unconfirmed targets; inspect each result' : 'Apply completed; refreshed target state'}); return !failed; + } + function setPartialPolicy(policy) { + if (policy === 'existing') model.setWorkflow('EDIT_EXISTING').setCreateMissing(false).setCreateEditExisting(true); + else if (policy === 'missing') model.setWorkflow('CREATE_MISSING').setCreateMissing(true).setCreateEditExisting(false); + else if (policy === 'both') model.setWorkflow('CREATE_MISSING').setCreateMissing(true).setCreateEditExisting(true); + else return model; + model.partialPolicy = policy; model.invalidatePreview(); return model; + } + function draft(change) { change(); invalidate(); return model; } + return {get model() { return model; }, get state() { return state; }, scopeChanged: function () { syncContext(); return Promise.resolve(model); }, read, preview, apply, + selectSite: function (key) { return draft(function () { model.selectSite(key); }); }, beginAdd: function (key, fields, policy) { return draft(function () { + const selectedPolicy = policy || 'missing'; + model.selectSite(key); model.setWorkflow('CREATE_MISSING').setAddFields(fields); setPartialPolicy(selectedPolicy); + if (selectedPolicy === 'existing' || selectedPolicy === 'both') { + Object.keys(fields || {}).forEach(function (field) { model.edit(field, fields[field]); }); + } + }); }, setPartialPolicy: function (policy) { return draft(function () { setPartialPolicy(policy); }); }, + edit: function (field, value) { return draft(function () { model.edit(field, value); }); }, reset: function (field) { return draft(function () { model.reset(field); }); }, resetAll: function () { return draft(function () { model.resetAll(); }); }, remove: function (key) { return draft(function () { model.selectSite(key); model.setWorkflow('REMOVE'); }); }, cancelDraft: function () { return draft(function () { model.resetAll(); model.setAddFields({}); model.setWorkflow('EDIT_EXISTING'); }); }, invalidateReads: function () { reads.clear(); if (!applying) invalidate(); return model; }, clear: function () { generation++; queuedRead = null; queuedForce = false; queuedOnlyFailed = false; reads.clear(); release(approval && approval.items); approval = null; applying = false; model.setTargets([]); set({busy: false, error: '', message: '', previewState: 'Not previewed', ackRequired: false, previewItems: []}); return model; }}; + } + return {create}; +})); diff --git a/src/main/resources/web/workspace.js b/src/main/resources/web/workspace.js new file mode 100644 index 00000000..8667f60e --- /dev/null +++ b/src/main/resources/web/workspace.js @@ -0,0 +1,268 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + root.ControlWorkspace = api; +}(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + const PAGE_NAMES = new Set(['home', 'overview', 'general-settings', 'vote-sites', 'rewards', 'quick-setup', 'data', 'network', + 'configurations', 'activity', 'servers', 'access']); + const SECTION_NAMES = new Set(['settings', 'vote-sites', 'rewards', 'sync']); + const ELIGIBLE_CAPABILITIES = new Set([ + 'config.files.v1', 'config.quick-setup.v1', 'config.quick-setup.v2', 'data.inspect.v1' + ]); + const SESSION_KEY = 'votingplugin-control.workspace.v1'; + + function idSet(ids) { + const result = new Set(); + if (!ids || typeof ids[Symbol.iterator] !== 'function') return result; + for (const id of ids) { + if (typeof id === 'string' && id.trim()) result.add(id); + } + return result; + } + + function scopeFor(ids) { + return ids.size === 1 ? 'SERVER' : ids.size > 1 ? 'MULTI_SERVER' : null; + } + + function safeDecode(value) { + try { + const decoded = decodeURIComponent(value); + return decoded && decoded.indexOf('/') === -1 ? decoded : ''; + } catch (_) { + return ''; + } + } + + function homeRoute() { + return {page: 'home', scope: null, inspectedServerId: '', section: ''}; + } + + function parseRoute(hash) { + if (typeof hash !== 'string') return homeRoute(); + const fragment = hash.startsWith('#') ? hash.slice(1) : hash; + if (!fragment) return homeRoute(); + const parts = fragment.split('/'); + if (parts.some(part => !part)) return homeRoute(); + + if (parts.length === 1 && PAGE_NAMES.has(parts[0])) { + return {page: parts[0], scope: null, inspectedServerId: '', section: ''}; + } + if (parts[0] === 'servers' && parts.length === 3 && parts[2] === 'overview') { + const inspectedServerId = safeDecode(parts[1]); + return inspectedServerId + ? {page: 'overview', scope: null, inspectedServerId, section: ''} + : homeRoute(); + } + + const workspacePages = { + overview: 'overview', + network: 'network', + 'quick-setup': 'quick-setup', + configuration: 'configurations', + configurations: 'configurations', + data: 'data', + activity: 'activity' + }; + const workspaceSections = { + settings: ['general-settings', 'settings'], + 'vote-sites': ['vote-sites', 'vote-sites'], + rewards: ['rewards', 'rewards'], + sync: ['quick-setup', 'sync'] + }; + if (parts[0] === 'workspace') { + if (parts.length === 2 && workspacePages[parts[1]]) { + return {page: workspacePages[parts[1]], scope: 'WORKSPACE', inspectedServerId: '', section: ''}; + } + if (parts.length === 2 && workspaceSections[parts[1]]) { + const [page, section] = workspaceSections[parts[1]]; + return {page, scope: 'WORKSPACE', inspectedServerId: '', section}; + } + if (parts.length === 3 && parts[1] === 'overview' && workspaceSections[parts[2]]) { + const [page, section] = workspaceSections[parts[2]]; + return {page, scope: 'WORKSPACE', inspectedServerId: '', section}; + } + return homeRoute(); + } + + const globalPages = { + network: 'network', routing: 'network', synchronization: 'quick-setup', + configuration: 'configurations', configurations: 'configurations', activity: 'activity' + }; + if (parts[0] === 'global' && parts.length === 2 && globalPages[parts[1]]) { + return { + page: globalPages[parts[1]], scope: 'GLOBAL', inspectedServerId: '', + section: parts[1] === 'synchronization' ? 'sync' : '' + }; + } + return homeRoute(); + } + + function formatRoute(route) { + const normalized = route || homeRoute(); + if (normalized.inspectedServerId && normalized.page === 'overview') { + return '#servers/' + encodeURIComponent(normalized.inspectedServerId) + '/overview'; + } + if (normalized.scope === 'GLOBAL') { + const globalPart = normalized.page === 'configurations' ? 'configuration' + : normalized.page === 'quick-setup' && normalized.section === 'sync' ? 'synchronization' + : normalized.page; + return globalPart === 'network' || globalPart === 'synchronization' || globalPart === 'configuration' || globalPart === 'activity' + ? '#global/' + globalPart : '#home'; + } + if (normalized.scope === 'WORKSPACE') { + const section = SECTION_NAMES.has(normalized.section) ? normalized.section : ''; + if (section) return '#workspace/' + section; + const page = normalized.page === 'configurations' ? 'configuration' + : normalized.page === 'general-settings' ? 'settings' + : normalized.page === 'vote-sites' ? 'vote-sites' : normalized.page; + return ['overview', 'network', 'settings', 'quick-setup', 'configuration', 'data', 'activity'].includes(page) + ? '#workspace/' + page : '#home'; + } + return normalized.page && PAGE_NAMES.has(normalized.page) ? '#' + normalized.page : '#home'; + } + + function isBukkit(node) { + return node && String(node.platform || '').toUpperCase() === 'BUKKIT' + && typeof node.nodeId === 'string' && node.nodeId.trim(); + } + + function capabilitiesOf(node) { + const capabilities = Array.isArray(node && node.acceptedCapabilities) ? node.acceptedCapabilities + : Array.isArray(node && node.capabilities) ? node.capabilities : []; + return new Set(capabilities.filter(value => typeof value === 'string')); + } + + class Workspace { + constructor(storage) { + this.storage = storage || (typeof sessionStorage !== 'undefined' ? sessionStorage : null); + this.currentRoute = '#home'; + this.login(); + } + + login() { + this.managementScope = null; + this.selectedTargetIds = new Set(); + this.inspectedServerId = ''; + this.previousTargetIds = new Set(); + this.currentRoute = '#home'; + return this; + } + + logout() { + this.clearPersisted(); + return this.login(); + } + + persist(route) { + if (typeof route === 'string' && route) this.currentRoute = route; + if (!this.storage || typeof this.storage.setItem !== 'function') return this; + const snapshot = { + managementScope: this.managementScope, + selectedTargetIds: [...this.selectedTargetIds], + previousTargetIds: this.managementScope === 'GLOBAL' ? [...this.previousTargetIds] : [], + inspectedServerId: this.inspectedServerId || '', + route: this.currentRoute || '#home' + }; + try { this.storage.setItem(SESSION_KEY, JSON.stringify(snapshot)); } catch (_) { /* private storage may be unavailable */ } + return this; + } + + setRoute(route) { + return this.persist(typeof route === 'string' && route.startsWith('#') ? route : '#home'); + } + + restore(nodes) { + if (!this.storage || typeof this.storage.getItem !== 'function') return this; + let snapshot; + try { snapshot = JSON.parse(this.storage.getItem(SESSION_KEY) || 'null'); } catch (_) { snapshot = null; } + if (!snapshot || typeof snapshot !== 'object') return this; + const scope = snapshot.managementScope; + const ids = idSet(snapshot.selectedTargetIds); + if (scope === 'GLOBAL') { + this.managementScope = 'GLOBAL'; + this.selectedTargetIds = new Set(); + this.previousTargetIds = idSet(snapshot.previousTargetIds); + } else { + this.selectedTargetIds = ids; + this.managementScope = scopeFor(ids); + this.previousTargetIds = new Set(); + } + this.inspectedServerId = typeof snapshot.inspectedServerId === 'string' ? snapshot.inspectedServerId : ''; + this.currentRoute = typeof snapshot.route === 'string' && snapshot.route.startsWith('#') ? snapshot.route : '#home'; + this.reconcile(nodes); + this.persist(this.currentRoute); + return this; + } + + clearPersisted() { + if (!this.storage || typeof this.storage.removeItem !== 'function') return this; + try { this.storage.removeItem(SESSION_KEY); } catch (_) { /* private storage may be unavailable */ } + return this; + } + + setTargets(ids) { + this.selectedTargetIds = idSet(ids); + this.managementScope = scopeFor(this.selectedTargetIds); + this.persist(); + return this; + } + + toggleTarget(id) { + if (typeof id !== 'string' || !id.trim()) return this; + const targets = new Set(this.selectedTargetIds); + if (targets.has(id)) targets.delete(id); else targets.add(id); + return this.setTargets(targets); + } + + clearTargets() { return this.setTargets([]); } + + enterGlobal() { + if (this.managementScope !== 'GLOBAL') this.previousTargetIds = new Set(this.selectedTargetIds); + this.selectedTargetIds = new Set(); + this.managementScope = 'GLOBAL'; + this.persist(); + return this; + } + + returnToServers() { + this.selectedTargetIds = new Set(this.previousTargetIds); + this.previousTargetIds = new Set(); + this.managementScope = scopeFor(this.selectedTargetIds); + this.persist(); + return this; + } + + inspect(id) { + this.inspectedServerId = typeof id === 'string' && id.trim() ? id : ''; + this.persist(); + return this; + } + + reconcile(nodes) { + const validIds = new Set((Array.isArray(nodes) ? nodes : []).filter(isBukkit).map(node => node.nodeId)); + const keep = ids => new Set([...ids].filter(id => validIds.has(id))); + this.selectedTargetIds = keep(this.selectedTargetIds); + this.previousTargetIds = keep(this.previousTargetIds); + if (this.inspectedServerId && !validIds.has(this.inspectedServerId)) this.inspectedServerId = ''; + if (this.managementScope !== 'GLOBAL') this.managementScope = scopeFor(this.selectedTargetIds); + this.persist(); + return this; + } + + selectEligible(nodes, max) { + const limit = max === undefined ? 100 : Math.max(0, Math.floor(Number(max) || 0)); + const ids = []; + for (const node of Array.isArray(nodes) ? nodes : []) { + if (ids.length >= limit) break; + if (!isBukkit(node) || !node.online) continue; + const capabilities = capabilitiesOf(node); + if ([...capabilities].some(capability => ELIGIBLE_CAPABILITIES.has(capability))) ids.push(node.nodeId); + } + return this.setTargets(ids); + } + } + + return {Workspace, parseRoute, formatRoute, SESSION_KEY}; +})); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/GeneralSettingsDocumentTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/GeneralSettingsDocumentTest.java new file mode 100644 index 00000000..504fea7c --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/domain/GeneralSettingsDocumentTest.java @@ -0,0 +1,130 @@ +package com.bencodez.votingplugin.control.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class GeneralSettingsDocumentTest { + @Test void fieldsExposeOnlyKnownSettingsWithMissingAndTypeStatus() { + String content = "\"ProcessRewards\": FALSE # deliberately uppercase\n" + + "AutoCreateVoteSites: true\n" + + "ExtraAllSitesCheck: 'false'\n" + + "CountFakeVotes: null\n" + + "SecretToken: __VOTINGPLUGIN_CONTROL_REDACTED__\n"; + + Map fields = GeneralSettingsDocument.fields(content); + + assertEquals(9, fields.size()); + assertEquals(new GeneralSettingsDocument.Field(GeneralSettingsDocument.Status.AVAILABLE, false), + fields.get("ProcessRewards")); + assertEquals(new GeneralSettingsDocument.Field(GeneralSettingsDocument.Status.AVAILABLE, true), + fields.get("AutoCreateVoteSites")); + assertEquals(new GeneralSettingsDocument.Field(GeneralSettingsDocument.Status.UNSUPPORTED, null), + fields.get("ExtraAllSitesCheck")); + assertEquals(new GeneralSettingsDocument.Field(GeneralSettingsDocument.Status.UNSUPPORTED, null), + fields.get("CountFakeVotes")); + assertEquals(new GeneralSettingsDocument.Field(GeneralSettingsDocument.Status.MISSING, null), + fields.get("DisableNoServiceSiteMessage")); + assertEquals(new GeneralSettingsDocument.Field(GeneralSettingsDocument.Status.MISSING, null), + fields.get("DisableUpdateChecking")); + assertEquals(new GeneralSettingsDocument.Field(GeneralSettingsDocument.Status.MISSING, null), + fields.get("UseVoteGUIMainCommand")); + assertEquals(new GeneralSettingsDocument.Field(GeneralSettingsDocument.Status.MISSING, null), + fields.get("CloseInventoryOnVote")); + assertEquals(new GeneralSettingsDocument.Field(GeneralSettingsDocument.Status.MISSING, null), + fields.get("ExtraVoteShopCheck")); + } + + @Test void patchChangesOnlySelectedScalarSpansAndPreservesIndependentDocuments() { + String first = "# settings\n\"ProcessRewards\": true # quoted key intentionally\n" + + "Unknown: keep\nPassword: __VOTINGPLUGIN_CONTROL_REDACTED__\n"; + String second = "Unknown: keep\nProcessRewards : FALSE # another layout\n"; + + assertEquals("# settings\n\"ProcessRewards\": false # quoted key intentionally\n" + + "Unknown: keep\nPassword: __VOTINGPLUGIN_CONTROL_REDACTED__\n", + GeneralSettingsDocument.patch(first, Map.of("ProcessRewards", false))); + assertEquals("Unknown: keep\nProcessRewards : true # another layout\n", + GeneralSettingsDocument.patch(second, Map.of("ProcessRewards", true))); + } + + @Test void patchesVerifiedUiAndShopBooleansWithoutTouchingOtherConfiguration() { + String source = "# GUI behavior\nUseVoteGUIMainCommand: false # /vote\n" + + "CloseInventoryOnVote: true\nExtraVoteShopCheck: true\n" + + "VoteShop: {Unknown: preserved}\nSecret: __VOTINGPLUGIN_CONTROL_REDACTED__\n"; + + assertEquals("# GUI behavior\nUseVoteGUIMainCommand: true # /vote\n" + + "CloseInventoryOnVote: false\nExtraVoteShopCheck: true\n" + + "VoteShop: {Unknown: preserved}\nSecret: __VOTINGPLUGIN_CONTROL_REDACTED__\n", + GeneralSettingsDocument.patch(source, Map.of("UseVoteGUIMainCommand", true, + "CloseInventoryOnVote", false))); + assertEquals(new GeneralSettingsDocument.Field(GeneralSettingsDocument.Status.AVAILABLE, true), + GeneralSettingsDocument.fields(source).get("ExtraVoteShopCheck")); + } + + @Test void patchIsStrictAndNeverInventsOrCoercesSettings() { + String missing = "Other: true\n"; + String unsupported = "ProcessRewards: 'true'\n"; + HashMap nullValue = new HashMap<>(); + nullValue.put("ProcessRewards", null); + + assertThrows(IllegalArgumentException.class, + () -> GeneralSettingsDocument.patch(missing, Map.of("ProcessRewards", true))); + assertThrows(IllegalArgumentException.class, + () -> GeneralSettingsDocument.patch(unsupported, Map.of("ProcessRewards", true))); + assertThrows(IllegalArgumentException.class, + () -> GeneralSettingsDocument.patch("ProcessRewards: true\n", Map.of("unknown", true))); + assertThrows(IllegalArgumentException.class, + () -> GeneralSettingsDocument.patch("ProcessRewards: true\n", nullValue)); + assertThrows(IllegalArgumentException.class, + () -> GeneralSettingsDocument.patch("ProcessRewards: true\n", Map.of())); + } + + @Test void noChangeReturnsTextVerbatimIncludingNonCanonicalBooleanSpelling() { + String content = "ProcessRewards: TRUE # retain source style\n"; + assertEquals(content, GeneralSettingsDocument.patch(content, Map.of("ProcessRewards", true))); + } + + @Test void supportsFlowRootWithoutReformattingIt() { + String content = "{ProcessRewards: true, unknown: __VOTINGPLUGIN_CONTROL_REDACTED__}\n"; + assertEquals("{ProcessRewards: false, unknown: __VOTINGPLUGIN_CONTROL_REDACTED__}\n", + GeneralSettingsDocument.patch(content, Map.of("ProcessRewards", false))); + } + + @Test void usesCodePointMarksWhenTextBeforeASettingContainsNonBmpCharacters() { + String content = "Message: '😀'\nProcessRewards: true # exact location follows emoji\n"; + assertEquals("Message: '😀'\nProcessRewards: false # exact location follows emoji\n", + GeneralSettingsDocument.patch(content, Map.of("ProcessRewards", false))); + } + + @Test void rejectsAliasOrAnchorInvolvementForAnEditedSetting() { + String anchored = "ProcessRewards: &shared true\nOther: *shared\n"; + String aliased = "Other: &shared true\nProcessRewards: *shared\n"; + + assertEquals(GeneralSettingsDocument.Status.UNSUPPORTED, + GeneralSettingsDocument.fields(anchored).get("ProcessRewards").status()); + assertEquals(GeneralSettingsDocument.Status.UNSUPPORTED, + GeneralSettingsDocument.fields(aliased).get("ProcessRewards").status()); + assertThrows(IllegalArgumentException.class, + () -> GeneralSettingsDocument.patch(anchored, Map.of("ProcessRewards", false))); + assertThrows(IllegalArgumentException.class, + () -> GeneralSettingsDocument.patch(aliased, Map.of("ProcessRewards", false))); + } + + @Test void rejectsDuplicateRootsMultipleDocumentsInvalidAndOversizedInputWithoutLeakingIt() { + assertThrows(IllegalArgumentException.class, + () -> GeneralSettingsDocument.fields("ProcessRewards: true\nProcessRewards: false\n")); + assertThrows(IllegalArgumentException.class, () -> GeneralSettingsDocument.fields("- ProcessRewards\n")); + assertThrows(IllegalArgumentException.class, + () -> GeneralSettingsDocument.fields("ProcessRewards: true\n---\nProcessRewards: false\n")); + assertThrows(IllegalArgumentException.class, + () -> GeneralSettingsDocument.fields("ProcessRewards: [\n")); + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> GeneralSettingsDocument.fields("ProcessRewards: " + "x".repeat(512 * 1024))); + assertTrue(!failure.getMessage().contains("x")); + } +} diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/GeneralSettingsOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/GeneralSettingsOperationsTest.java new file mode 100644 index 00000000..d9e4625e --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/domain/GeneralSettingsOperationsTest.java @@ -0,0 +1,432 @@ +package com.bencodez.votingplugin.control.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.bencodez.votingplugin.control.protocol.ConfigurationTask; +import com.bencodez.votingplugin.control.protocol.ConfigurationTaskResult; +import com.bencodez.votingplugin.control.protocol.Heartbeat; +import com.bencodez.votingplugin.control.protocol.ManagedConfiguration; +import com.bencodez.votingplugin.control.protocol.NodeRegistration; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class GeneralSettingsOperationsTest { + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-09-18T00:00:00Z"), ZoneOffset.UTC); + private static final String REDACTED = "__VOTINGPLUGIN_CONTROL_REDACTED__"; + + @TempDir Path directory; + + @Test void readUsesThatBackendDocumentAndRevision() throws Exception { + try (Fixture fixture = fixture("backend-a")) { + String document = "# keep\nProcessRewards: true\nToken: " + REDACTED + "\n"; + ConfigurationOperations.OperationView read = read(fixture, "backend-a", document, revision('a')); + + ConfigurationOperations.SettingsState state = fixture.operations.generalSettingsState(read.operationId(), + "backend-a"); + assertEquals(read.operationId(), state.readOperationId()); + assertEquals(fixture.session("backend-a"), state.sessionId()); + assertEquals(revision('a'), state.revision()); + assertEquals(GeneralSettingsDocument.Status.AVAILABLE, state.fields().get("ProcessRewards").status()); + assertEquals(Boolean.TRUE, state.fields().get("ProcessRewards").value()); + assertEquals(GeneralSettingsDocument.Status.MISSING, + state.fields().get("AutoCreateVoteSites").status()); + assertNull(state.fields().get("AutoCreateVoteSites").value()); + } + } + + @Test void voteSitesUseIndependentRetainedReadsAndKeepEachTargetsUnknownAndRewardsContent() throws Exception { + try (Fixture fixture = fixture("backend-a", "backend-b")) { + String first = "VoteSites:\n Alpha:\n Enabled: false\n Priority: 5\n" + + " Rewards:\n Commands:\n - 'private A'\n Unknown: A\n"; + String second = "VoteSites:\n Alpha:\n Enabled: true\n Priority: 2\n" + + " Rewards:\n Commands:\n - 'private B'\n Unknown: B\n"; + ConfigurationOperations.OperationView read = fixture.operations.createRead(List.of("backend-a", "backend-b"), + ManagedConfiguration.file("VoteSites.yml", null)); + completeFileRead(fixture, read, "backend-a", first, revision('a'), "VoteSites.yml"); + read = completeFileRead(fixture, read, "backend-b", second, revision('b'), "VoteSites.yml"); + assertEquals(false, fixture.operations.voteSitesState(read.operationId(), "backend-a") + .sites().get(0).fields().get("Enabled").value()); + assertEquals(true, fixture.operations.voteSitesState(read.operationId(), "backend-b") + .sites().get(0).fields().get("Enabled").value()); + ConfigurationOperations.OperationView firstPreview = fixture.operations.createVoteSitesPreview( + read.operationId(), "backend-a", "EDIT", "Alpha", Map.of("Priority", 20)); + ConfigurationOperations.OperationView secondPreview = fixture.operations.createVoteSitesPreview( + read.operationId(), "backend-b", "EDIT", "Alpha", Map.of("Priority", 20)); + ConfigurationTask firstTask = fixture.operations.claim("backend-a", fixture.session("backend-a")); + ConfigurationTask secondTask = fixture.operations.claim("backend-b", fixture.session("backend-b")); + assertEquals(first.replace("Priority: 5", "Priority: 20"), firstTask.configuration().content()); + assertEquals(second.replace("Priority: 2", "Priority: 20"), secondTask.configuration().content()); + assertEquals(revision('a'), firstTask.expectedRevision()); + assertEquals(revision('b'), secondTask.expectedRevision()); + complete(fixture, firstPreview.operationId(), "backend-a", firstTask, revision('a')); + complete(fixture, secondPreview.operationId(), "backend-b", secondTask, revision('b')); + } + } + + @Test void rewardPreviewUsesEachTargetsOwnRetainedDocumentAndIsRevisionBound() throws Exception { + try (Fixture fixture = fixture("backend-a", "backend-b")) { + String first = "VoteSites:\n Alpha:\n Rewards:\n Commands:\n - 'say A'\n" + + " AdvancedPriority:\n Rare:\n Chance: 20\n Private: keep-A\n"; + String second = first.replace("say A", "say B").replace("keep-A", "keep-B"); + ConfigurationOperations.OperationView read = fixture.operations.createRead(List.of("backend-a", "backend-b"), + ManagedConfiguration.file("VoteSites.yml", null)); + completeFileRead(fixture, read, "backend-a", first, revision('a'), "VoteSites.yml"); + read = completeFileRead(fixture, read, "backend-b", second, revision('b'), "VoteSites.yml"); + assertEquals(List.of("say A"), fixture.operations.rewardsState(read.operationId(), "backend-a", "VoteSites.yml") + .scopes().get(0).fields().get("Commands")); + assertEquals(List.of("say B"), fixture.operations.rewardsState(read.operationId(), "backend-b", "VoteSites.yml") + .scopes().get(0).fields().get("Commands")); + ConfigurationOperations.OperationView a = fixture.operations.createRewardsPreview(read.operationId(), "backend-a", + "VoteSites.yml", "VoteSites.Alpha.Rewards", "APPEND_LIST_ENTRY", "Commands", "say new"); + ConfigurationOperations.OperationView b = fixture.operations.createRewardsPreview(read.operationId(), "backend-b", + "VoteSites.yml", "VoteSites.Alpha.Rewards", "APPEND_LIST_ENTRY", "Commands", "say new"); + ConfigurationTask taskA = fixture.operations.claim("backend-a", fixture.session("backend-a")); + ConfigurationTask taskB = fixture.operations.claim("backend-b", fixture.session("backend-b")); + assertEquals(first.replace(" - 'say A'\n", " - 'say A'\n - \"say new\"\n"), taskA.configuration().content()); + assertEquals(second.replace(" - 'say B'\n", " - 'say B'\n - \"say new\"\n"), taskB.configuration().content()); + assertEquals(revision('a'), taskA.expectedRevision()); + assertEquals(revision('b'), taskB.expectedRevision()); + complete(fixture, a.operationId(), "backend-a", taskA, revision('a')); + complete(fixture, b.operationId(), "backend-b", taskB, revision('b')); + UUID retainedReadId = read.operationId(); + assertThrows(ValidationException.class, () -> fixture.operations.createRewardsPreview( + retainedReadId, "backend-a", "SpecialRewards.yml", "VoteParty.Rewards", "REMOVE_REWARD", null, null)); + } + } + + @Test void namedRewardFilesRequireV1CapabilityAndPatchOnlyRetainedRoot() throws Exception { + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(CLOCK, Duration.ofMinutes(2)); + UUID oldSession = UUID.randomUUID(); + UUID newSession = UUID.randomUUID(); + registry.register(new NodeRegistration("old", oldSession, "Old", "BUKKIT", "test", 1, + Set.of(ConfigurationOperations.FILE_CAPABILITY), Set.of())); + registry.register(new NodeRegistration("new", newSession, "New", "BUKKIT", "test", 1, + Set.of(ConfigurationOperations.FILE_CAPABILITY, ManagedConfiguration.REWARD_FILE_CAPABILITY), Set.of())); + try (ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory.resolve("named-audit"), CLOCK), CLOCK)) { + String name = "Rewards/StandardVote.yml"; + assertEquals("NODE_UNAVAILABLE", assertThrows(ValidationException.class, + () -> operations.createRead(List.of("old"), ManagedConfiguration.file(name, null))).code()); + String source = "# keep\nCommands:\n- 'say first'\nItems:\n Diamond:\n CustomModelData: 123\n"; + ConfigurationOperations.OperationView read = operations.createRead(List.of("new"), ManagedConfiguration.file(name, null)); + ConfigurationTask task = operations.claim("new", newSession); + read = operations.complete(read.operationId(), "new", new ConfigurationTaskResult(newSession, + true, "OK", "read", revision('a'), ManagedConfiguration.file(name, source), List.of(), + false, false, task.attemptId())); + UUID readId = read.operationId(); + assertEquals("$", operations.rewardsState(read.operationId(), "new", name).scopes().get(0).path()); + ConfigurationOperations.OperationView preview = operations.createRewardsPreview(read.operationId(), "new", + name, "$", "APPEND_LIST_ENTRY", "Commands", "say second"); + ConfigurationTask proposal = operations.claim("new", newSession); + assertEquals(revision('a'), proposal.expectedRevision()); + assertEquals(source.replace("- 'say first'\n", "- 'say first'\n- \"say second\"\n"), + proposal.configuration().content()); + assertNull(preview.configuration().content()); + assertEquals("VALIDATION_ERROR", assertThrows(ValidationException.class, + () -> operations.rewardsState(readId, "new", "Rewards/../Config.yml")).code()); + } + } + + @Test void voteSitesRejectStaleRevisionAndDisposeExactPreviewBeforeAnyApply() throws Exception { + try (Fixture fixture = fixture("backend-a")) { + String source = "VoteSites:\n Alpha:\n Enabled: false\n"; + ConfigurationOperations.OperationView read = fixture.operations.createRead(List.of("backend-a"), + ManagedConfiguration.file("VoteSites.yml", null)); + read = completeFileRead(fixture, read, "backend-a", source, revision('a'), "VoteSites.yml"); + ConfigurationOperations.OperationView stale = fixture.operations.createVoteSitesPreview( + read.operationId(), "backend-a", "EDIT", "Alpha", Map.of("Enabled", true)); + ConfigurationTask task = fixture.operations.claim("backend-a", fixture.session("backend-a")); + complete(fixture, stale.operationId(), "backend-a", task, revision('b')); + assertEquals("STALE_REVISION", fixture.operations.get(stale.operationId()).results().get("backend-a").code()); + assertEquals("PREVIEW_INCOMPLETE", assertThrows(ValidationException.class, + () -> fixture.operations.createApply(stale.operationId(), "no-token")).code()); + + ConfigurationOperations.OperationView preview = fixture.operations.createVoteSitesPreview( + read.operationId(), "backend-a", "EDIT", "Alpha", Map.of("Enabled", true)); + ConfigurationTask valid = fixture.operations.claim("backend-a", fixture.session("backend-a")); + preview = complete(fixture, preview.operationId(), "backend-a", valid, revision('a')); + UUID id = preview.operationId(); String token = preview.approvalToken(); + assertNotNull(token); + assertEquals("APPROVAL_REQUIRED", assertThrows(ValidationException.class, + () -> fixture.operations.discardVoteSitesPreview(id, "wrong-token")).code()); + fixture.operations.discardVoteSitesPreview(id, token); + assertEquals("APPROVAL_REQUIRED", assertThrows(ValidationException.class, + () -> fixture.operations.createApply(id, token)).code()); + assertNull(fixture.operations.claim("backend-a", fixture.session("backend-a"))); + } + } + + @Test void previewsArePerTargetAndOnlyChangeRequestedScalarText() throws Exception { + try (Fixture fixture = fixture("backend-a", "backend-b")) { + String first = "# first comment\nProcessRewards: true # retain\nUnknown: first\nToken: " + REDACTED + "\n"; + String second = "# second comment\nAutoCreateVoteSites: false # retain\nUnknown: second\nPassword: " + REDACTED + "\n"; + ConfigurationOperations.OperationView read = fixture.operations.createRead(List.of("backend-a", "backend-b"), + ManagedConfiguration.file("Config.yml", null)); + completeRead(fixture, read, "backend-a", first, revision('a')); + read = completeRead(fixture, read, "backend-b", second, revision('b')); + + ConfigurationOperations.OperationView firstPreview = fixture.operations.createGeneralSettingsPreview( + read.operationId(), "backend-a", Map.of("ProcessRewards", false)); + ConfigurationOperations.OperationView secondPreview = fixture.operations.createGeneralSettingsPreview( + read.operationId(), "backend-b", Map.of("AutoCreateVoteSites", true)); + ConfigurationTask firstTask = fixture.operations.claim("backend-a", fixture.session("backend-a")); + ConfigurationTask secondTask = fixture.operations.claim("backend-b", fixture.session("backend-b")); + + assertEquals("# first comment\nProcessRewards: false # retain\nUnknown: first\nToken: " + REDACTED + "\n", + firstTask.configuration().content()); + assertEquals("# second comment\nAutoCreateVoteSites: true # retain\nUnknown: second\nPassword: " + + REDACTED + "\n", secondTask.configuration().content()); + assertFalse(firstTask.configuration().content().contains("second")); + assertFalse(secondTask.configuration().content().contains("first")); + complete(fixture, firstPreview.operationId(), "backend-a", firstTask, revision('c')); + complete(fixture, secondPreview.operationId(), "backend-b", secondTask, revision('d')); + } + } + + @Test void rejectsUnknownNonBooleanMissingAndWrongTypeEdits() throws Exception { + try (Fixture fixture = fixture("backend-a")) { + ConfigurationOperations.OperationView read = read(fixture, "backend-a", + "ProcessRewards: true\nExtraAllSitesCheck: 'false'\n", revision('a')); + assertValidation(() -> fixture.operations.createGeneralSettingsPreview(read.operationId(), "backend-a", + Map.of("UnknownSetting", true))); + assertValidation(() -> fixture.operations.createGeneralSettingsPreview(read.operationId(), "backend-a", + Map.of("ProcessRewards", "false"))); + assertValidation(() -> fixture.operations.createGeneralSettingsPreview(read.operationId(), "backend-a", + Map.of("DisableUpdateChecking", false))); + assertValidation(() -> fixture.operations.createGeneralSettingsPreview(read.operationId(), "backend-a", + Map.of("ExtraAllSitesCheck", false))); + } + } + + @Test void rejectsUnsupportedCapabilityProxyTargetAndReconnectedReadTarget() throws Exception { + try (Fixture fixture = fixture("backend-a")) { + ConfigurationOperations.OperationView read = read(fixture, "backend-a", "ProcessRewards: true\n", revision('a')); + fixture.registry.heartbeat("backend-a", new Heartbeat(fixture.session("backend-a"), 1, Set.of(), Set.of())); + assertEquals("NODE_UNAVAILABLE", assertThrows(ValidationException.class, + () -> fixture.operations.generalSettingsState(read.operationId(), "backend-a")).code()); + } + try (Fixture fixture = fixture("proxy-a")) { + assertEquals("INVALID_TARGET", assertThrows(ValidationException.class, + () -> fixture.operations.createRead(List.of("proxy-a"), ManagedConfiguration.file("Config.yml", null))).code()); + } + try (Fixture fixture = fixture("backend-a")) { + ConfigurationOperations.OperationView read = read(fixture, "backend-a", "ProcessRewards: true\n", revision('a')); + UUID replacement = UUID.randomUUID(); + fixture.registry.register(new NodeRegistration("backend-a", replacement, "backend-a", "BUKKIT", "test", 1, + Set.of(ConfigurationOperations.FILE_CAPABILITY), Set.of())); + assertEquals("TARGET_CHANGED", assertThrows(ValidationException.class, + () -> fixture.operations.generalSettingsState(read.operationId(), "backend-a")).code()); + } + } + + @Test void stalePreviewRevisionCannotBeApprovedForApply() throws Exception { + try (Fixture fixture = fixture("backend-a")) { + ConfigurationOperations.OperationView read = read(fixture, "backend-a", "ProcessRewards: true\n", revision('a')); + ConfigurationOperations.OperationView preview = fixture.operations.createGeneralSettingsPreview( + read.operationId(), "backend-a", Map.of("ProcessRewards", false)); + ConfigurationTask task = fixture.operations.claim("backend-a", fixture.session("backend-a")); + assertEquals(revision('a'), task.expectedRevision()); + preview = complete(fixture, preview.operationId(), "backend-a", task, revision('b')); + + assertEquals("STALE_REVISION", preview.results().get("backend-a").code()); + assertNull(preview.approvalToken()); + UUID stalePreviewId = preview.operationId(); + assertEquals("PREVIEW_INCOMPLETE", assertThrows(ValidationException.class, + () -> fixture.operations.createApply(stalePreviewId, "unused")).code()); + } + } + + @Test void boundPreviewCreatesExactSingleUseRevisionBoundApply() throws Exception { + try (Fixture fixture = fixture("backend-a")) { + ConfigurationOperations.OperationView read = read(fixture, "backend-a", + "# retain\nProcessRewards: true\nUnknown: value\n", revision('a')); + ConfigurationOperations.OperationView preview = fixture.operations.createGeneralSettingsPreview( + read.operationId(), "backend-a", Map.of("ProcessRewards", false)); + ConfigurationTask previewTask = fixture.operations.claim("backend-a", fixture.session("backend-a")); + String proposal = "# retain\nProcessRewards: false\nUnknown: value\n"; + assertEquals(proposal, previewTask.configuration().content()); + preview = complete(fixture, preview.operationId(), "backend-a", previewTask, revision('a')); + assertNotNull(preview.approvalToken()); + UUID approvedPreviewId = preview.operationId(); + String approvalToken = preview.approvalToken(); + + ConfigurationOperations.OperationView apply = fixture.operations.createApply(approvedPreviewId, approvalToken); + ConfigurationTask applyTask = fixture.operations.claim("backend-a", fixture.session("backend-a")); + assertEquals("APPLY", applyTask.type()); + assertEquals(revision('a'), applyTask.expectedRevision()); + assertEquals(proposal, applyTask.configuration().content()); + assertEquals("APPROVAL_REQUIRED", assertThrows(ValidationException.class, + () -> fixture.operations.createApply(approvedPreviewId, approvalToken)).code()); + apply = complete(fixture, apply.operationId(), "backend-a", applyTask, revision('c')); + assertEquals("SUCCEEDED", apply.state()); + assertNull(apply.results().get("backend-a").configuration().content()); + } + } + + @Test void pendingBoundPreviewIsNotEvictedByFileRetention() throws Exception { + try (Fixture fixture = fixture("backend-a")) { + ConfigurationOperations.OperationView read = read(fixture, "backend-a", "ProcessRewards: true\n", revision('a')); + ConfigurationOperations.OperationView bound = fixture.operations.createGeneralSettingsPreview( + read.operationId(), "backend-a", Map.of("ProcessRewards", false)); + ConfigurationTask boundTask = fixture.operations.claim("backend-a", fixture.session("backend-a")); + bound = complete(fixture, bound.operationId(), "backend-a", boundTask, revision('a')); + UUID protectedId = bound.operationId(); + + for (int index = 0; index < 16; index++) { + ConfigurationOperations.OperationView extra = fixture.operations.createPreview(List.of("backend-a"), + ManagedConfiguration.file("Config.yml", "Other: " + index + "\n")); + ConfigurationTask task = fixture.operations.claim("backend-a", fixture.session("backend-a")); + complete(fixture, extra.operationId(), "backend-a", task, revision((char) ('a' + index % 6))); + } + + ConfigurationOperations.OperationView retained = fixture.operations.get(protectedId); + assertEquals("SUCCEEDED", retained.state()); + assertNotNull(retained.approvalToken()); + } + } + + @Test void abandonedVisualApprovalIsRevokedWithoutSchedulingAWriteAndCanBeEvicted() throws Exception { + try (Fixture fixture = fixture("backend-a")) { + ConfigurationOperations.OperationView read = read(fixture, "backend-a", "ProcessRewards: true\n", revision('a')); + ConfigurationOperations.OperationView preview = fixture.operations.createGeneralSettingsPreview( + read.operationId(), "backend-a", Map.of("ProcessRewards", false)); + ConfigurationTask task = fixture.operations.claim("backend-a", fixture.session("backend-a")); + preview = complete(fixture, preview.operationId(), "backend-a", task, revision('a')); + UUID id = preview.operationId(); String token = preview.approvalToken(); + assertEquals("APPROVAL_REQUIRED", assertThrows(ValidationException.class, + () -> fixture.operations.discardGeneralSettingsPreview(id, "wrong-token")).code()); + assertNotNull(fixture.operations.get(id).approvalToken()); + assertNull(fixture.operations.discardGeneralSettingsPreview(id, token).approvalToken()); + assertNull(fixture.operations.claim("backend-a", fixture.session("backend-a"))); + assertEquals("APPROVAL_REQUIRED", assertThrows(ValidationException.class, + () -> fixture.operations.createApply(id, token)).code()); + for (int index = 0; index < 17; index++) read(fixture, "backend-a", "ProcessRewards: true\n", revision('a')); + assertEquals("OPERATION_NOT_FOUND", assertThrows(ValidationException.class, + () -> fixture.operations.get(id)).code()); + } + } + + @Test void staleVisualPreviewCannotBeRetriedWithAnOldDocumentAtANewRevision() throws Exception { + try (Fixture fixture = fixture("backend-a")) { + ConfigurationOperations.OperationView read = read(fixture, "backend-a", "ProcessRewards: true\n", revision('a')); + ConfigurationOperations.OperationView preview = fixture.operations.createGeneralSettingsPreview( + read.operationId(), "backend-a", Map.of("ProcessRewards", false)); + ConfigurationTask task = fixture.operations.claim("backend-a", fixture.session("backend-a")); + complete(fixture, preview.operationId(), "backend-a", task, revision('b')); + UUID id = preview.operationId(); + assertEquals("PREVIEW_REQUIRED", assertThrows(ValidationException.class, + () -> fixture.operations.retry(id)).code()); + } + } + + @Test void journalAndAuditNeverPersistReadOrProposalSecrets() throws Exception { + Path journalDirectory = directory.resolve("journal"); + Path auditDirectory = directory.resolve("audit"); + String secret = "DO_NOT_PERSIST_GENERAL_SETTINGS_SECRET"; + Fixture fixture = fixture(journalDirectory, auditDirectory, "backend-a"); + try { + ConfigurationOperations.OperationView read = read(fixture, "backend-a", + "ProcessRewards: true\nUnknownSecret: " + secret + "\n", revision('a')); + ConfigurationOperations.OperationView preview = fixture.operations.createGeneralSettingsPreview( + read.operationId(), "backend-a", Map.of("ProcessRewards", false)); + ConfigurationTask task = fixture.operations.claim("backend-a", fixture.session("backend-a")); + complete(fixture, preview.operationId(), "backend-a", task, revision('b')); + } finally { + fixture.close(); + } + assertFalse(Files.readString(journalDirectory.resolve("configuration-operations.json")).contains(secret)); + assertFalse(Files.readString(auditDirectory.resolve("configuration-audit.jsonl")).contains(secret)); + } + + private ConfigurationOperations.OperationView read(Fixture fixture, String nodeId, String content, String revision) { + ConfigurationOperations.OperationView read = fixture.operations.createRead(List.of(nodeId), + ManagedConfiguration.file("Config.yml", null)); + return completeRead(fixture, read, nodeId, content, revision); + } + + private ConfigurationOperations.OperationView completeRead(Fixture fixture, ConfigurationOperations.OperationView read, + String nodeId, String content, String revision) { + ConfigurationTask task = fixture.operations.claim(nodeId, fixture.session(nodeId)); + assertEquals("READ", task.type()); + assertNull(task.expectedRevision()); + return fixture.operations.complete(read.operationId(), nodeId, new ConfigurationTaskResult(fixture.session(nodeId), + true, "OK", "read", revision, ManagedConfiguration.file("Config.yml", content), List.of(), false, false, + task.attemptId())); + } + + private ConfigurationOperations.OperationView completeFileRead(Fixture fixture, ConfigurationOperations.OperationView read, + String nodeId, String content, String revision, String file) { + ConfigurationTask task = fixture.operations.claim(nodeId, fixture.session(nodeId)); + assertEquals("READ", task.type()); + return fixture.operations.complete(read.operationId(), nodeId, new ConfigurationTaskResult(fixture.session(nodeId), + true, "OK", "read", revision, ManagedConfiguration.file(file, content), List.of(), false, false, + task.attemptId())); + } + + private ConfigurationOperations.OperationView complete(Fixture fixture, UUID operationId, String nodeId, + ConfigurationTask task, String revision) { + return fixture.operations.complete(operationId, nodeId, new ConfigurationTaskResult(fixture.session(nodeId), true, + "OK", "valid", revision, task.configuration(), List.of(), false, false, task.attemptId())); + } + + private static void assertValidation(Runnable action) { + assertEquals("VALIDATION_ERROR", assertThrows(ValidationException.class, action::run).code()); + } + + private Fixture fixture(String... nodeIds) throws Exception { + return fixture(null, null, nodeIds); + } + + private Fixture fixture(Path journalDirectory, Path auditDirectory, String... nodeIds) throws Exception { + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(CLOCK, Duration.ofMinutes(2)); + for (String nodeId : nodeIds) { + String platform = nodeId.startsWith("proxy") ? "VELOCITY" : "BUKKIT"; + registry.register(new NodeRegistration(nodeId, UUID.randomUUID(), nodeId, platform, "test", 1, + Set.of(ConfigurationOperations.FILE_CAPABILITY), Set.of())); + } + Path auditRoot = auditDirectory == null ? directory.resolve(UUID.randomUUID().toString()) : auditDirectory; + ConfigurationAuditLog audit = new ConfigurationAuditLog(auditRoot, CLOCK); + ConfigurationOperationJournal journal = journalDirectory == null ? null + : new ConfigurationOperationJournal(journalDirectory, CLOCK); + ConfigurationOperations operations = journal == null + ? new ConfigurationOperations(registry, audit, CLOCK) + : new ConfigurationOperations(registry, audit, CLOCK, journal); + return new Fixture(registry, operations); + } + + private static String revision(char value) { + return String.valueOf(value).repeat(64); + } + + private static final class Fixture implements AutoCloseable { + private final InMemoryNodeRegistry registry; + private final ConfigurationOperations operations; + + private Fixture(InMemoryNodeRegistry registry, ConfigurationOperations operations) { + this.registry = registry; + this.operations = operations; + } + + private UUID session(String nodeId) { + return registry.find(nodeId).sessionId(); + } + + @Override public void close() throws Exception { + operations.close(); + } + } +} 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 7b9e98e3..ca7ef9fd 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java @@ -93,6 +93,45 @@ class InspectionOperationsTest { assertEquals("CAPABILITY_LOST", result.result().code()); } + @Test void namedRewardInventoryRequiresBothInspectionAndRewardFileCapabilities() { + register(session, Set.of(InspectionQuery.CAPABILITY)); + InspectionOperations operations = new InspectionOperations(registry, clock); + InspectionQuery query = new InspectionQuery("reward-file-inventory", Map.of()); + assertEquals("NODE_UNAVAILABLE", assertThrows(ValidationException.class, + () -> operations.create("backend-a", query)).code()); + + register(session, Set.of(InspectionQuery.CAPABILITY, InspectionQuery.REWARD_FILE_CAPABILITY)); + UUID id = operations.create("backend-a", query).inspectionId(); + registry.heartbeat("backend-a", new Heartbeat(session, 1, Set.of(InspectionQuery.CAPABILITY), Set.of())); + assertNull(operations.claim("backend-a", session)); + assertEquals("CAPABILITY_LOST", operations.get(id).result().code()); + } + + @Test void namedRewardInventoryRejectsUnboundedOrUnsafeRemoteResults() { + register(session, Set.of(InspectionQuery.CAPABILITY, InspectionQuery.REWARD_FILE_CAPABILITY)); + InspectionOperations operations = new InspectionOperations(registry, clock); + InspectionQuery query = new InspectionQuery("reward-file-inventory", Map.of()); + UUID id = operations.create("backend-a", query).inspectionId(); + InspectionTask task = operations.claim("backend-a", session); + ObjectNode data = envelope("reward-file-inventory"); + data.withObject("result").putArray("files").add("Daily.yml").add("daily.yml"); + assertEquals("VALIDATION_ERROR", assertThrows(ValidationException.class, + () -> operations.complete(id, "backend-a", + new InspectionTaskResult(session, true, "OK", "done", data, task.attemptId()))).code()); + data.withObject("result").putArray("files").add("../secret.yml"); + assertEquals("VALIDATION_ERROR", assertThrows(ValidationException.class, + () -> operations.complete(id, "backend-a", + new InspectionTaskResult(session, true, "OK", "done", data, task.attemptId()))).code()); + data.withObject("result").putArray("files").add("Daily.yml"); + data.withObject("result").put("raw", "must not be retained"); + assertEquals("VALIDATION_ERROR", assertThrows(ValidationException.class, + () -> operations.complete(id, "backend-a", + new InspectionTaskResult(session, true, "OK", "done", data, task.attemptId()))).code()); + data.withObject("result").remove("raw"); + assertEquals("SUCCEEDED", operations.complete(id, "backend-a", + new InspectionTaskResult(session, true, "OK", "done", data, task.attemptId())).state()); + } + @Test void completionRequiresTheCurrentAttemptAndAnActiveLease() { register(session, Set.of(InspectionQuery.CAPABILITY)); InspectionOperations operations = new InspectionOperations(registry, clock); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/RewardsDocumentTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/RewardsDocumentTest.java new file mode 100644 index 00000000..89c072d1 --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/domain/RewardsDocumentTest.java @@ -0,0 +1,418 @@ +package com.bencodez.votingplugin.control.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +class RewardsDocumentTest { + private static final String SOURCE = "VoteSites:\n Alpha:\n Name: Alpha\n Rewards:\n" + + " Commands:\n - 'say one'\n - 'say two'\n" + + " Messages:\n Player: 'hello'\n" + + " Items:\n diamond:\n Material: DIAMOND\n Amount: 2\n CustomModelData: 123\n" + + " AdvancedPriority:\n Rare:\n Chance: 20\n Commands:\n - 'say rare'\n" + + " Unknown: preserve # important\n" + + " Beta:\n Name: Beta\n Rewards:\n Commands:\n - 'say beta'\n" + + "EverySiteReward: {}\n"; + + @Test void inventoryShowsExistingAndAdvancedStructureWithoutCopyingUnknownValues() { + List scopes = RewardsDocument.inventory(SOURCE, "VoteSites.yml"); + assertEquals(3, scopes.size()); + assertEquals("VoteSites.Alpha.Rewards", scopes.get(0).path()); + assertEquals(List.of("say one", "say two"), scopes.get(0).fields().get("Commands")); + assertTrue(scopes.get(0).advancedKeys().contains("AdvancedPriority")); + assertTrue(scopes.get(0).advancedKeys().contains("Items")); + assertTrue(scopes.get(0).structurePaths().contains("Items.diamond.CustomModelData")); + assertFalse(scopes.get(0).toString().contains("123")); + } + + @Test void inventoryRejectsStructurePathsThatWouldExpandBeyondTheBoundedTypedResponse() { + String key = "x".repeat(129); + assertThrows(IllegalArgumentException.class, + () -> RewardsDocument.inventory("VoteSites:\n Alpha:\n Rewards:\n " + key + ": value\n", "VoteSites.yml")); + + StringBuilder source = new StringBuilder("VoteSites:\n Alpha:\n Rewards:\n " + "p".repeat(120) + ":\n"); + for (int index = 0; index < 120; index++) { + source.append(" ").append("c".repeat(125)).append(String.format("%03d", index)).append(": value\n"); + } + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.inventory(source.toString(), "VoteSites.yml")); + } + + @Test void discoveredRewardScopePathsUseTheSameResponseBoundsAsTheirStructure() { + String key = "n".repeat(128); + String overlongPath = key + ":\n " + key + ":\n " + key + ":\n " + key + ":\n Rewards: {}\n"; + assertThrows(IllegalArgumentException.class, + () -> RewardsDocument.inventory(overlongPath, "SpecialRewards.yml")); + + StringBuilder cumulative = new StringBuilder(); + for (int index = 0; index < 50; index++) { + String suffix = String.format("%03d", index); + cumulative.append("a".repeat(125)).append(suffix).append(":\n ") + .append("b".repeat(125)).append(suffix).append(":\n ") + .append("c".repeat(125)).append(suffix).append(":\n Rewards: {}\n"); + } + assertThrows(IllegalArgumentException.class, + () -> RewardsDocument.inventory(cumulative.toString(), "SpecialRewards.yml")); + } + + @Test void appendAndRemoveCommandsKeepDifferentTargetDocumentsIndependent() { + String a = RewardsDocument.patch(SOURCE, "VoteSites.yml", "VoteSites.Alpha.Rewards", + new RewardsDocument.Edit("APPEND_LIST_ENTRY", "Commands", "say three")); + assertTrue(a.contains("- \"say three\"")); + assertTrue(a.contains("CustomModelData: 123")); + assertTrue(a.contains("Unknown: preserve # important")); + String b = RewardsDocument.patch(SOURCE.replace("say one", "say other"), "VoteSites.yml", "VoteSites.Alpha.Rewards", + new RewardsDocument.Edit("APPEND_LIST_ENTRY", "Commands", "say three")); + assertTrue(b.contains("say other")); + assertFalse(a.contains("say other")); + String removed = RewardsDocument.patch(a, "VoteSites.yml", "VoteSites.Alpha.Rewards", + new RewardsDocument.Edit("REMOVE_LIST_ENTRY", "Commands", "say two")); + assertFalse(removed.contains("say two")); + assertTrue(removed.contains("say one")); + } + + @Test void scalarEditPreservesNestedRewardAndOtherSite() { + String changed = RewardsDocument.patch(SOURCE, "VoteSites.yml", "VoteSites.Alpha.Rewards", + new RewardsDocument.Edit("SET_SCALAR", "Messages.Player", "new hello")); + assertEquals(SOURCE.replace("Player: 'hello'", "Player: \"new hello\""), changed); + assertTrue(changed.contains("say beta")); + assertTrue(changed.contains("AdvancedPriority")); + } + + @Test void explicitReplaceAndLastEntryRemovalHaveBoundedNonAmbiguousSemantics() { + String replaced = RewardsDocument.patch(SOURCE, "VoteSites.yml", "VoteSites.Alpha.Rewards", + new RewardsDocument.Edit("REPLACE_LIST", "Commands", List.of("say replacement"))); + assertTrue(replaced.contains("Commands:\n - \"say replacement\"\n Messages:")); + assertTrue(replaced.contains("CustomModelData: 123")); + String single = SOURCE.replace(" - 'say two'\n", ""); + String removed = RewardsDocument.patch(single, "VoteSites.yml", "VoteSites.Alpha.Rewards", + new RewardsDocument.Edit("REMOVE_LIST_ENTRY", "Commands", "say one")); + assertTrue(removed.contains("Commands: []\n Messages:")); + String withComment = SOURCE.replace(" - 'say one'", " - 'say one' # keep"); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(withComment, "VoteSites.yml", + "VoteSites.Alpha.Rewards", new RewardsDocument.Edit("REPLACE_LIST", "Commands", List.of("changed")))); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(withComment, "VoteSites.yml", + "VoteSites.Alpha.Rewards", new RewardsDocument.Edit("REMOVE_LIST_ENTRY", "Commands", "say one"))); + String hashInCommand = SOURCE.replace(" - 'say one'", " - 'say # one'"); + assertFalse(RewardsDocument.patch(hashInCommand, "VoteSites.yml", "VoteSites.Alpha.Rewards", + new RewardsDocument.Edit("REMOVE_LIST_ENTRY", "Commands", "say # one")).contains("say # one")); + String replacedHash = RewardsDocument.patch(hashInCommand, "VoteSites.yml", "VoteSites.Alpha.Rewards", + new RewardsDocument.Edit("REPLACE_LIST", "Commands", List.of("say changed"))); + assertTrue(replacedHash.contains("- \"say changed\"")); + } + + @Test void multilineAndFlowCommandsRemainAdvancedAndCannotBePartiallyRemoved() { + String multiline = SOURCE.replace(" - 'say one'\n", " - >\n say one\n continued\n"); + String path = "VoteSites.Alpha.Rewards"; + RewardsDocument.Scope scope = RewardsDocument.inventory(multiline, "VoteSites.yml").get(0); + assertFalse(scope.fields().containsKey("Commands")); + assertTrue(scope.advancedKeys().contains("Commands")); + for (RewardsDocument.Edit edit : List.of( + new RewardsDocument.Edit("REMOVE_LIST_ENTRY", "Commands", "say one continued\n"), + new RewardsDocument.Edit("APPEND_LIST_ENTRY", "Commands", "say three"), + new RewardsDocument.Edit("REPLACE_LIST", "Commands", List.of("say replacement")))) { + assertThrows(IllegalArgumentException.class, + () -> RewardsDocument.patch(multiline, "VoteSites.yml", path, edit)); + } + String changed = RewardsDocument.patch(multiline, "VoteSites.yml", path, + new RewardsDocument.Edit("SET_SCALAR", "Money", 5)); + assertTrue(changed.contains(" - >\n say one\n continued\n")); + + String flow = SOURCE.replace("Commands:\n - 'say one'\n - 'say two'", "Commands: ['say one', 'say two']"); + assertTrue(RewardsDocument.inventory(flow, "VoteSites.yml").get(0).advancedKeys().contains("Commands")); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(flow, "VoteSites.yml", path, + new RewardsDocument.Edit("REMOVE_LIST_ENTRY", "Commands", "say one"))); + String escaped = SOURCE.replace(" - 'say one'", " - \"say one\\ncontinued\""); + assertEquals(List.of("say one\ncontinued", "say two"), + RewardsDocument.inventory(escaped, "VoteSites.yml").get(0).fields().get("Commands")); + assertFalse(RewardsDocument.patch(escaped, "VoteSites.yml", path, + new RewardsDocument.Edit("REMOVE_LIST_ENTRY", "Commands", "say one\ncontinued")).contains("say one")); + + String multilineMessage = SOURCE.replace("Player: 'hello'", "Player: >\n hello\n continued"); + RewardsDocument.Scope messageScope = RewardsDocument.inventory(multilineMessage, "VoteSites.yml").get(0); + assertFalse(messageScope.fields().containsKey("Messages.Player")); + assertTrue(messageScope.advancedKeys().contains("Messages.Player")); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(multilineMessage, "VoteSites.yml", path, + new RewardsDocument.Edit("SET_SCALAR", "Messages.Player", "replacement"))); + + String multilineMoney = SOURCE.replace(" Unknown: preserve # important", " Money: !!int >-\n 10\n Unknown: preserve # important"); + RewardsDocument.Scope moneyScope = RewardsDocument.inventory(multilineMoney, "VoteSites.yml").get(0); + assertFalse(moneyScope.fields().containsKey("Money")); + assertTrue(moneyScope.advancedKeys().contains("Money")); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(multilineMoney, "VoteSites.yml", path, + new RewardsDocument.Edit("SET_SCALAR", "Money", 20))); + String multilineAmount = SOURCE.replace(" Amount: 2", " Amount: !!int >-\n 2"); + assertFalse(RewardsDocument.inventory(multilineAmount, "VoteSites.yml").get(0).fields().containsKey("Items.diamond.Amount")); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(multilineAmount, "VoteSites.yml", path, + new RewardsDocument.Edit("SET_SCALAR", "Items.diamond.Amount", 3))); + } + + @Test void emptyCommandsCanBeRepopulatedAfterExplicitRemovalOrReplacement() { + String path = "VoteSites.Alpha.Rewards"; + String empty = SOURCE.replace(" Commands:\n - 'say one'\n - 'say two'\n", " Commands: []\n"); + assertEquals(List.of(), RewardsDocument.inventory(empty, "VoteSites.yml").get(0).fields().get("Commands")); + String appended = RewardsDocument.patch(empty, "VoteSites.yml", path, + new RewardsDocument.Edit("APPEND_LIST_ENTRY", "Commands", "say restored")); + assertEquals(List.of("say restored"), RewardsDocument.inventory(appended, "VoteSites.yml").get(0).fields().get("Commands")); + assertTrue(appended.contains("CustomModelData: 123")); + String replaced = RewardsDocument.patch(empty, "VoteSites.yml", path, + new RewardsDocument.Edit("REPLACE_LIST", "Commands", List.of("say first", "say second"))); + assertEquals(List.of("say first", "say second"), RewardsDocument.inventory(replaced, "VoteSites.yml").get(0).fields().get("Commands")); + assertEquals(empty, RewardsDocument.patch(empty, "VoteSites.yml", path, + new RewardsDocument.Edit("REPLACE_LIST", "Commands", List.of()))); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(empty, "VoteSites.yml", path, + new RewardsDocument.Edit("REMOVE_LIST_ENTRY", "Commands", "say restored"))); + String commented = empty.replace("Commands: []", "Commands: [] # keep"); + String restoredWithNote = RewardsDocument.patch(commented, "VoteSites.yml", path, + new RewardsDocument.Edit("APPEND_LIST_ENTRY", "Commands", "say restored")); + assertTrue(restoredWithNote.contains("Commands: # keep\n - \"say restored\"")); + assertEquals(List.of("say restored"), RewardsDocument.inventory(restoredWithNote, "VoteSites.yml") + .get(0).fields().get("Commands")); + String spaced = empty.replace("Commands: []", "Commands: [ ]"); + assertEquals(List.of("say restored"), RewardsDocument.inventory(RewardsDocument.patch(spaced, + "VoteSites.yml", path, new RewardsDocument.Edit("REPLACE_LIST", "Commands", + List.of("say restored"))), "VoteSites.yml").get(0).fields().get("Commands")); + String removedLast = RewardsDocument.patch(SOURCE.replace(" - 'say two'\n", ""), "VoteSites.yml", path, + new RewardsDocument.Edit("REMOVE_LIST_ENTRY", "Commands", "say one")); + assertEquals(List.of("say restored"), RewardsDocument.inventory(RewardsDocument.patch(removedLast, + "VoteSites.yml", path, new RewardsDocument.Edit("APPEND_LIST_ENTRY", "Commands", "say restored")), + "VoteSites.yml").get(0).fields().get("Commands")); + } + + @Test void explicitCreateOnlyWhenAbsentAndUnicodeSourceMarksRemainCorrect() { + String source = "# 😀\nVoteSites:\n Alpha:\n Name: Alpha\n Beta:\n Name: Beta\n"; + String created = RewardsDocument.patch(source, "VoteSites.yml", "VoteSites.Alpha.Rewards", + new RewardsDocument.Edit("CREATE_REWARD", "Commands", "say 😀")); + assertTrue(created.contains(" Rewards:\n Commands:\n - \"say 😀\"\n Beta:")); + assertTrue(created.startsWith("# 😀\n")); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(created, "VoteSites.yml", + "VoteSites.Alpha.Rewards", new RewardsDocument.Edit("CREATE_REWARD", "Commands", "again"))); + } + + @Test void exactRemovalLeavesSiteAndOtherRewards() { + String changed = RewardsDocument.patch(SOURCE, "VoteSites.yml", "VoteSites.Alpha.Rewards", + new RewardsDocument.Edit("REMOVE_REWARD", null, null)); + assertTrue(changed.contains("Alpha:\n Name: Alpha")); + assertFalse(changed.contains("say one")); + assertTrue(changed.contains("say beta")); + } + + @Test void removingRewardPreservesTrailingNotesBeforeSiblingAndEof() { + String source = "VoteSites:\n Alpha:\n Rewards:\n Commands:\n - 'say one'\n" + + " # internal note\n Money: 1\n # operator note\n\n Hidden: true\n"; + String removed = RewardsDocument.patch(source, "VoteSites.yml", "VoteSites.Alpha.Rewards", + new RewardsDocument.Edit("REMOVE_REWARD", null, null)); + assertEquals("VoteSites:\n Alpha:\n # operator note\n\n Hidden: true\n", removed); + String eof = source.substring(0, source.indexOf(" Hidden:")); + String removedAtEof = RewardsDocument.patch(eof, "VoteSites.yml", "VoteSites.Alpha.Rewards", + new RewardsDocument.Edit("REMOVE_REWARD", null, null)); + assertEquals("VoteSites:\n Alpha:\n # operator note\n\n", removedAtEof); + } + + @Test void rejectsTraversalAmbiguousYamlAndDuplicateCommands() { + RewardsDocument.Scope nullReward = RewardsDocument.inventory( + "VoteSites:\n Alpha:\n Rewards: null\n", "VoteSites.yml").get(0); + assertEquals("UNSUPPORTED", nullReward.status()); + assertFalse(nullReward.editable()); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(SOURCE, "VoteSites.yml", "VoteSites...Rewards", + new RewardsDocument.Edit("REMOVE_REWARD", null, null))); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(SOURCE, "VoteSites.yml", "VoteSites.Alpha.Rewards", + new RewardsDocument.Edit("REMOVE_LIST_ENTRY", "Commands", "not there"))); + String duplicate = SOURCE.replace("say two", "say one"); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(duplicate, "VoteSites.yml", "VoteSites.Alpha.Rewards", + new RewardsDocument.Edit("REMOVE_LIST_ENTRY", "Commands", "say one"))); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.inventory("A: 1\nA: 2\n", "SpecialRewards.yml")); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.inventory("A: &x {B: 1}\nC: *x\n", "SpecialRewards.yml")); + } + + @Test void escapesSupportedWhitespaceAndRejectsInvalidYamlControlCharacters() { + String changed = RewardsDocument.patch(SOURCE, "VoteSites.yml", "VoteSites.Alpha.Rewards", + new RewardsDocument.Edit("APPEND_LIST_ENTRY", "Commands", "say\tplayer")); + assertTrue(changed.contains("- \"say\\tplayer\"")); + assertEquals(List.of("say one", "say two", "say\tplayer"), + RewardsDocument.inventory(changed, "VoteSites.yml").get(0).fields().get("Commands")); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(SOURCE, "VoteSites.yml", + "VoteSites.Alpha.Rewards", new RewardsDocument.Edit("APPEND_LIST_ENTRY", "Commands", "say\u0001player"))); + } + + @Test void malformedEditPayloadsFailAsValidationErrors() { + String path = "VoteSites.Alpha.Rewards"; + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(SOURCE, "VoteSites.yml", path, + new RewardsDocument.Edit(null, "Commands", "say no"))); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(SOURCE, "VoteSites.yml", path, + new RewardsDocument.Edit("SET_SCALAR", null, "say no"))); + String withoutCommands = SOURCE.replace(" Commands:\n - 'say one'\n - 'say two'\n", ""); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(withoutCommands, "VoteSites.yml", path, + new RewardsDocument.Edit("APPEND_LIST_ENTRY", "Commands", 42))); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(withoutCommands, "VoteSites.yml", path, + new RewardsDocument.Edit("SET_SCALAR", "Commands", "say wrong shape"))); + String added = RewardsDocument.patch(withoutCommands, "VoteSites.yml", path, + new RewardsDocument.Edit("APPEND_LIST_ENTRY", "Commands", "say valid")); + assertEquals(List.of("say valid"), RewardsDocument.inventory(added, "VoteSites.yml").get(0).fields().get("Commands")); + } + + @Test void missingFieldEditsDoNotCreateCaseFoldedDuplicateKeys() { + String path = "VoteSites.Alpha.Rewards"; + String lowerMoney = SOURCE.replace(" Unknown: preserve # important", " money: 1\n Unknown: preserve # important"); + assertTrue(RewardsDocument.inventory(lowerMoney, "VoteSites.yml").get(0).advancedKeys().contains("Money")); + assertFalse(RewardsDocument.inventory(lowerMoney, "VoteSites.yml").get(0).fields().containsKey("Money")); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(lowerMoney, "VoteSites.yml", path, + new RewardsDocument.Edit("SET_SCALAR", "Money", 2))); + String lowerCommands = SOURCE.replace("Commands:\n - 'say one'\n - 'say two'", "commands:\n - 'say one'"); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(lowerCommands, "VoteSites.yml", path, + new RewardsDocument.Edit("APPEND_LIST_ENTRY", "Commands", "say two"))); + String lowerReward = "VoteSites:\n Alpha:\n rewards: {}\n"; + assertEquals("UNSUPPORTED", RewardsDocument.inventory(lowerReward, "VoteSites.yml").get(0).status()); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(lowerReward, "VoteSites.yml", path, + new RewardsDocument.Edit("CREATE_REWARD", "Commands", "say new"))); + String named = "chance: 5\n"; + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(named, "Rewards/StandardVote.yml", "$", + new RewardsDocument.Edit("SET_SCALAR", "Chance", 10))); + String added = RewardsDocument.patch(named, "Rewards/StandardVote.yml", "$", + new RewardsDocument.Edit("SET_SCALAR", "Money", 2)); + assertEquals("2", RewardsDocument.inventory(added, "Rewards/StandardVote.yml").get(0).fields().get("Money")); + } + + @Test void namedRewardEditsPatchOnlyRootFieldsAndPreserveUnknownChildren() { + String source = "# owner note\nCommands:\n- 'say first'\nItems:\n diamond:\n Material: DIAMOND\n" + + " Amount: 1\n CustomModelData: 912\nCondition:\n FutureKey: preserve\n"; + String file = "Rewards/StandardVote.yml"; + RewardsDocument.Scope root = RewardsDocument.inventory(source, file).get(0); + assertEquals("$", root.path()); + assertEquals(List.of("say first"), root.fields().get("Commands")); + String appended = RewardsDocument.patch(source, file, "$", + new RewardsDocument.Edit("APPEND_LIST_ENTRY", "Commands", "say second")); + assertTrue(appended.contains("- \"say second\"")); + assertTrue(appended.contains("CustomModelData: 912")); + assertTrue(appended.contains("FutureKey: preserve")); + String scalar = RewardsDocument.patch(appended, file, "$", + new RewardsDocument.Edit("SET_SCALAR", "Money", 2)); + assertTrue(scalar.contains("Money: 2\n")); + assertTrue(scalar.startsWith("# owner note\n")); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(source, file, "$", + new RewardsDocument.Edit("REMOVE_REWARD", null, null))); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.inventory(source, "Rewards/../Config.yml")); + } + + @Test void namedRewardMissingFieldsAreInsertedBeforeExplicitDocumentEnd() { + String source = "# owner note\nCommands:\n- 'say first'\n# trailing note\n...\n"; + String file = "Rewards/StandardVote.yml"; + String added = RewardsDocument.patch(source, file, "$", new RewardsDocument.Edit("SET_SCALAR", "Money", 10)); + assertTrue(added.indexOf("Money: 10") < added.indexOf("...")); + assertTrue(added.contains("# trailing note")); + assertEquals("10", RewardsDocument.inventory(added, file).get(0).fields().get("Money")); + + String withoutCommands = "# owner note\nMoney: 2\n...\n"; + String appended = RewardsDocument.patch(withoutCommands, file, "$", + new RewardsDocument.Edit("APPEND_LIST_ENTRY", "Commands", "say added")); + assertTrue(appended.indexOf("Commands:") < appended.indexOf("...")); + assertEquals(List.of("say added"), RewardsDocument.inventory(appended, file).get(0).fields().get("Commands")); + } + + @Test void itemLeafEditsPreserveMetadataCommentsAndOtherItems() { + String source = "Items:\n diamond: # keep identity\n Material: DIAMOND\n Amount: 1\n" + + " Name: '&bVote diamond'\n Lore:\n - first\n Enchantments:\n sharpness: 5\n" + + " CustomModelData: 912\n future_item:\n Material: FUTURE_MATERIAL\n Amount: 2\n FutureMetadata: preserve\n"; + String material = RewardsDocument.patch(source, "Rewards/StandardVote.yml", "$", + new RewardsDocument.Edit("SET_SCALAR", "Items.diamond.Material", "NETHERITE_INGOT")); + assertTrue(material.contains("Material: \"NETHERITE_INGOT\"")); + assertTrue(material.contains("Name: '&bVote diamond'")); + assertTrue(material.contains("sharpness: 5")); + assertTrue(material.contains("CustomModelData: 912")); + assertTrue(material.contains("FutureMetadata: preserve")); + String amount = RewardsDocument.patch(material, "Rewards/StandardVote.yml", "$", + new RewardsDocument.Edit("SET_SCALAR", "Items.diamond.Amount", 2)); + assertTrue(amount.contains("Amount: 2")); + assertTrue(amount.contains("future_item:")); + RewardsDocument.Scope scope = RewardsDocument.inventory(amount, "Rewards/StandardVote.yml").get(0); + assertEquals("NETHERITE_INGOT", scope.fields().get("Items.diamond.Material")); + assertEquals("2", scope.fields().get("Items.diamond.Amount")); + } + + @Test void numericRewardEditsRejectQuotedOrAdvancedCurrentScalars() { + String path = "VoteSites.Alpha.Rewards"; + String quoted = SOURCE.replace(" Unknown: preserve # important\n", + " Money: '10'\n Chance: '25'\n Unknown: preserve # important\n"); + RewardsDocument.Scope scope = RewardsDocument.inventory(quoted, "VoteSites.yml").get(0); + assertTrue(scope.advancedKeys().contains("Money")); + assertTrue(scope.advancedKeys().contains("Chance")); + for (String field : List.of("Money", "Chance")) { + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(quoted, "VoteSites.yml", path, + new RewardsDocument.Edit("SET_SCALAR", field, 20))); + } + String numeric = quoted.replace("Money: '10'", "Money: 10").replace("Chance: '25'", "Chance: 25.5"); + String changed = RewardsDocument.patch(numeric, "VoteSites.yml", path, + new RewardsDocument.Edit("SET_SCALAR", "Chance", 30)); + assertTrue(changed.contains("Chance: 30\n")); + assertTrue(changed.contains("Money: 10\n")); + } + + @Test void messageEditsRejectNonstringsClassifiedAsAdvanced() { + String source = SOURCE.replace("Player: 'hello'", "Player: true\n Broadcast: 42"); + RewardsDocument.Scope scope = RewardsDocument.inventory(source, "VoteSites.yml").get(0); + assertTrue(scope.advancedKeys().contains("Messages.Player")); + assertTrue(scope.advancedKeys().contains("Messages.Broadcast")); + for (String field : List.of("Messages.Player", "Messages.Broadcast")) { + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(source, "VoteSites.yml", + "VoteSites.Alpha.Rewards", new RewardsDocument.Edit("SET_SCALAR", field, "safe text"))); + } + String changed = RewardsDocument.patch(SOURCE, "VoteSites.yml", "VoteSites.Alpha.Rewards", + new RewardsDocument.Edit("SET_SCALAR", "Messages.Player", "new hello")); + assertTrue(changed.contains("Player: \"new hello\"")); + } + + @Test void flowMessagesAreAdvancedWhileBlockMessagesRemainEditable() { + String flow = SOURCE.replace("Messages:\n Player: 'hello'", "Messages: {Player: 'hello'}"); + RewardsDocument.Scope scope = RewardsDocument.inventory(flow, "VoteSites.yml").get(0); + assertFalse(scope.fields().containsKey("Messages.Player")); + assertTrue(scope.advancedKeys().contains("Messages")); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(flow, "VoteSites.yml", + "VoteSites.Alpha.Rewards", new RewardsDocument.Edit("SET_SCALAR", "Messages.Player", "new hello"))); + + String named = "Messages: {Player: 'hello'}\n"; + RewardsDocument.Scope namedScope = RewardsDocument.inventory(named, "Rewards/StandardVote.yml").get(0); + assertFalse(namedScope.fields().containsKey("Messages.Player")); + assertTrue(namedScope.advancedKeys().contains("Messages")); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(named, "Rewards/StandardVote.yml", "$", + new RewardsDocument.Edit("SET_SCALAR", "Messages.Player", "new hello"))); + assertEquals("new hello", RewardsDocument.inventory(RewardsDocument.patch(SOURCE, "VoteSites.yml", + "VoteSites.Alpha.Rewards", new RewardsDocument.Edit("SET_SCALAR", "Messages.Player", "new hello")), + "VoteSites.yml").get(0).fields().get("Messages.Player")); + } + + @Test void mappedCommandsStayAdvancedAndCannotBeEditedThroughTypedPreview() { + String mapped = SOURCE.replace("Commands:\n - 'say one'\n - 'say two'", + "Commands:\n Console: 'say one'\n Player: 42"); + RewardsDocument.Scope scope = RewardsDocument.inventory(mapped, "VoteSites.yml").get(0); + assertFalse(scope.fields().containsKey("Commands")); + assertTrue(scope.advancedKeys().contains("Commands")); + for (String field : List.of("Commands.Console", "Commands.Player")) { + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(mapped, "VoteSites.yml", + "VoteSites.Alpha.Rewards", new RewardsDocument.Edit("SET_SCALAR", field, "new command"))); + } + String named = "Commands:\n Console: 'say one'\n"; + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(named, "Rewards/StandardVote.yml", "$", + new RewardsDocument.Edit("SET_SCALAR", "Commands.Console", "new command"))); + assertEquals(List.of("say one", "say two"), + RewardsDocument.inventory(SOURCE, "VoteSites.yml").get(0).fields().get("Commands")); + } + + @Test void itemEditingFailsClosedForUnsupportedOrUnsafeShapes() { + String path = "VoteSites.Alpha.Rewards"; + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(SOURCE, "VoteSites.yml", path, + new RewardsDocument.Edit("SET_SCALAR", "Items.diamond.Amount", 65))); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(SOURCE, "VoteSites.yml", path, + new RewardsDocument.Edit("SET_SCALAR", "Items.diamond.Material", "not-a-material"))); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(SOURCE, "VoteSites.yml", path, + new RewardsDocument.Edit("SET_SCALAR", "Items.diamond.Amount", 1.5))); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(SOURCE, "VoteSites.yml", path, + new RewardsDocument.Edit("SET_SCALAR", "AdvancedPriority.Rare.Items.diamond.Amount", 2))); + String missing = SOURCE.replace(" Amount: 2\n", ""); + assertThrows(IllegalArgumentException.class, () -> RewardsDocument.patch(missing, "VoteSites.yml", path, + new RewardsDocument.Edit("SET_SCALAR", "Items.diamond.Amount", 2))); + } +} diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/VoteSitesDocumentTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/VoteSitesDocumentTest.java new file mode 100644 index 00000000..ff650f10 --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/domain/VoteSitesDocumentTest.java @@ -0,0 +1,160 @@ +package com.bencodez.votingplugin.control.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class VoteSitesDocumentTest { + private static final String SITE = "VoteSites:\n" + + " Alpha:\n" + + " Enabled: true\n" + + " Name: Alpha display\n" + + " ServiceSite: alpha-service\n" + + " VoteURL: https://example.test/vote\n" + + " VoteDelay: '24h'\n" + + " Priority: 7\n" + + " Hidden: false\n" + + " DisplayItem:\n" + + " Material: DIAMOND\n" + + " Amount: 2\n"; + + @Test void inventoryUsesMappingKeyNotNameOrServiceAndExposesOnlyNineFields() { + String content = "Header: retain-me\n" + SITE + " Password: never-exposed\n" + + " Rewards: {}\n" + + " unsafe key!: \n" + + " Enabled: false\n" + + " Name: secret-name-is-an-allowed-field\n" + + " ServiceSite: beta\n" + + " VoteURL: https://example.test/b\n" + + " VoteDelay: 10\n" + + " Priority: 0\n" + + " Hidden: false\n" + + " DisplayItem:\n" + + " Material: STONE\n" + + " Amount: 1\n"; + + VoteSitesDocument.Inventory inventory = VoteSitesDocument.inventory(content); + + assertEquals(List.of("Alpha", "unsafe key!"), inventory.sites().stream().map(VoteSitesDocument.Site::key).toList()); + VoteSitesDocument.Site alpha = inventory.sites().get(0); + assertTrue(alpha.editable()); + assertFalse(alpha.rewardsConfigured()); + assertEquals(9, alpha.fields().size()); + assertFalse(alpha.fields().containsKey("Password")); + assertEquals("Alpha display", alpha.fields().get("Name").value()); + assertEquals("alpha-service", alpha.fields().get("ServiceSite").value()); + assertEquals(7, alpha.fields().get("Priority").value()); + assertFalse(inventory.sites().get(1).editable()); + assertEquals(VoteSitesDocument.Status.UNSUPPORTED, + inventory.sites().get(1).fields().get("VoteDelay").status()); + assertFalse(inventory.toString().contains("never-exposed")); + } + + @Test void rewardsPresenceAndLegacyDelayAreObservedWithoutDisclosure() { + String content = SITE + " Rewards:\n Commands:\n - say hi\n"; + VoteSitesDocument.Site site = VoteSitesDocument.inventory(content).sites().get(0); + assertTrue(site.rewardsConfigured()); + assertEquals(VoteSitesDocument.Status.AVAILABLE, site.fields().get("VoteDelay").status()); + + String legacy = SITE.replace("VoteDelay: '24h'", "VoteDelay: 24"); + assertEquals(VoteSitesDocument.Status.UNSUPPORTED, + VoteSitesDocument.inventory(legacy).sites().get(0).fields().get("VoteDelay").status()); + } + + @Test void editChangesOneScalarAndPreservesRewardsUnknownFieldsAndCommentsExactly() { + String content = "# outside\n" + SITE + " Unknown: keep # unknown comment\n" + + " Rewards:\n Commands:\n - 'secret command' # reward comment\n"; + String expected = content.replace("Priority: 7", "Priority: 8"); + assertEquals(expected, VoteSitesDocument.edit(content, "Alpha", Map.of("Priority", 8))); + } + + @Test void editCanInsertMissingDirectAndNestedFieldsWithoutRebuildingDisplayItem() { + String content = "VoteSites:\n Alpha:\n Enabled: true\n DisplayItem:\n Material: STONE\n UnknownNested: preserve\n" + + " Rewards:\n A: B\n"; + String edited = VoteSitesDocument.edit(content, "Alpha", Map.of("Name", "Inserted", "DisplayItem.Amount", 3)); + assertEquals("VoteSites:\n Alpha:\n Enabled: true\n DisplayItem:\n Material: STONE\n UnknownNested: preserve\n" + + " Amount: 3\n Rewards:\n A: B\n Name: \"Inserted\"\n", edited); + } + + @Test void addRequiresFullTypedMapAndKeepsExistingDocument() { + String content = "Top: keep\n" + SITE + "# trailing\n"; + String added = VoteSitesDocument.add(content, "Beta", complete("Beta", "beta")); + assertTrue(added.startsWith("Top: keep\n" + SITE + "# trailing\n \"Beta\":\n")); + assertTrue(added.contains(" Name: \"Beta\"\n")); + assertThrows(IllegalArgumentException.class, () -> VoteSitesDocument.add(content, "Alpha", complete("B", "b"))); + assertThrows(IllegalArgumentException.class, () -> VoteSitesDocument.add(content, "alpha", complete("B", "b"))); + assertThrows(IllegalArgumentException.class, () -> VoteSitesDocument.add(content, "bad key!", complete("B", "b"))); + Map incomplete = new LinkedHashMap<>(complete("B", "b")); + incomplete.remove("Hidden"); + assertThrows(IllegalArgumentException.class, () -> VoteSitesDocument.add(content, "Beta", incomplete)); + } + + @Test void removeIsExactAndRejectsMissingOrUnsafeIdentity() { + String content = "VoteSites:\n Alpha:\n Enabled: true\n Beta:\n Enabled: false\n# retained\n"; + assertEquals("VoteSites:\n Beta:\n Enabled: false\n# retained\n", VoteSitesDocument.remove(content, "Alpha")); + assertThrows(IllegalArgumentException.class, () -> VoteSitesDocument.remove(content, "missing")); + assertThrows(IllegalArgumentException.class, + () -> VoteSitesDocument.remove("VoteSites:\n bad key!:\n Enabled: true\n", "bad key!")); + String empty = VoteSitesDocument.remove("VoteSites:\n Alpha:\n Enabled: true\n", "Alpha"); + assertEquals("VoteSites:\n", empty); + assertTrue(VoteSitesDocument.inventory(empty).sites().isEmpty()); + assertEquals(1, VoteSitesDocument.inventory(VoteSitesDocument.add(empty, "Beta", complete("Beta", "beta"))).sites().size()); + } + + @Test void invalidDocumentsFailClosed() { + List invalid = List.of( + "VoteSites:\n Alpha: {}\n Alpha: {}\n", + "VoteSites:\n Alpha: &site\n Enabled: true\n", + "VoteSites:\n Alpha: *missing\n", + "VoteSites: { Alpha: {} }\n", + "VoteSites:\n Alpha: [x]\n", + "VoteSites:\n Alpha: {}\n---\nVoteSites: {}\n", + "VoteSites:\n Alpha: [\n"); + for (String content : invalid) assertThrows(IllegalArgumentException.class, () -> VoteSitesDocument.inventory(content)); + assertThrows(IllegalArgumentException.class, + () -> VoteSitesDocument.inventory("VoteSites:\n A:\n Enabled: true\n" + "#".repeat(512 * 1024))); + assertThrows(IllegalArgumentException.class, + () -> VoteSitesDocument.inventory("VoteSites:\n Alpha:\n Enabled: true\n alpha:\n Enabled: false\n")); + StringBuilder tooMany = new StringBuilder("VoteSites:\n"); + for (int i = 0; i <= 200; i++) tooMany.append(" Site").append(i).append(":\n Enabled: true\n"); + assertThrows(IllegalArgumentException.class, () -> VoteSitesDocument.inventory(tooMany.toString())); + } + + @Test void editRejectsUnknownWrongTypeRangeAndUnsupportedExistingFields() { + assertThrows(IllegalArgumentException.class, () -> VoteSitesDocument.edit(SITE, "Alpha", Map.of("Unknown", true))); + assertThrows(IllegalArgumentException.class, () -> VoteSitesDocument.edit(SITE, "Alpha", Map.of("Enabled", "true"))); + assertThrows(IllegalArgumentException.class, () -> VoteSitesDocument.edit(SITE, "Alpha", Map.of("DisplayItem.Amount", 0))); + assertThrows(IllegalArgumentException.class, () -> VoteSitesDocument.edit(SITE, "Alpha", Map.of("VoteURL", "bad\nurl"))); + String unsupported = SITE.replace("VoteDelay: '24h'", "VoteDelay: 24"); + assertThrows(IllegalArgumentException.class, + () -> VoteSitesDocument.edit(unsupported, "Alpha", Map.of("VoteDelay", "24h"))); + String lowerCase = SITE.replace("Enabled: true", "enabled: true"); + assertEquals(VoteSitesDocument.Status.UNSUPPORTED, + VoteSitesDocument.inventory(lowerCase).sites().get(0).fields().get("Enabled").status()); + assertThrows(IllegalArgumentException.class, + () -> VoteSitesDocument.edit(lowerCase, "Alpha", Map.of("Enabled", false))); + String ambiguous = SITE.replace("Enabled: true", "Enabled: true\n enabled: false"); + assertThrows(IllegalArgumentException.class, () -> VoteSitesDocument.inventory(ambiguous)); + } + + private static Map complete(String name, String service) { + Map fields = new LinkedHashMap<>(); + fields.put("Enabled", true); + fields.put("Name", name); + fields.put("ServiceSite", service); + fields.put("VoteURL", "https://example.test/" + service); + fields.put("VoteDelay", "24h"); + fields.put("Priority", 1); + fields.put("Hidden", false); + fields.put("DisplayItem.Material", "STONE"); + fields.put("DisplayItem.Amount", 1); + return fields; + } +} 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 ec15a05f..6d7552a0 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -103,6 +103,12 @@ class ControlHttpServerTest { assertTrue(web.headers().firstValue("Content-Security-Policy").orElseThrow().contains("default-src 'self'")); HttpResponse script = get("/app.js", null); assertEquals(200, script.statusCode()); + HttpResponse workspaceScript = get("/workspace.js", null); + assertEquals(200, workspaceScript.statusCode()); + assertTrue(workspaceScript.headers().firstValue("Content-Type").orElseThrow().startsWith("text/javascript")); + assertTrue(workspaceScript.body().contains("class Workspace")); + assertTrue(web.body().contains("src=\"/workspace.js\"")); + assertTrue(web.body().contains("What do you want to manage?")); assertTrue(script.body().contains("offset=${offset}&limit=${PAGE_SIZE}")); assertTrue(script.body().contains("async function loadAllNodes()")); assertTrue(script.body().contains("let nodeLoadInFlight = null;")); @@ -206,8 +212,10 @@ class ControlHttpServerTest { assertTrue(script.body().contains("function quickReadConfigurationOptions()")); assertTrue(script.body().contains("options: quickReadConfigurationOptions()")); assertTrue(script.body().contains("loadedQuickSetup.selector === JSON.stringify(quickReadConfigurationOptions())")); - assertTrue(script.body().contains("reloadVotePartyWhenTargetCapabilityChanges(previousQuickCapability)"), - "Changing selected backend capability must reload capability-dependent Vote Party state."); + assertTrue(script.body().contains("reloadVotePartyWhenTargetCapabilityChanges(previousQuickCapability, false)"), + "Refresh-driven source capability changes must reload capability-dependent Vote Party state."); + assertTrue(script.body().contains("return ordinaryTargetIds().filter(nodeId => nodeIndex.has(nodeId) && isBackend(nodeIndex.get(nodeId)))"), + "Vote Party forms must use the single workspace source until mixed-aware editing exists."); assertTrue(script.body().contains("quickSetupPreserveReadGeneration = inputGeneration;\n" + " text(quickOperationStatus,\n" + " 'Selected backend capabilities changed. Preserving unsaved Vote Party edits"), @@ -257,8 +265,8 @@ class ControlHttpServerTest { "Invalidated dedicated settings must automatically reload while Quick Setup is visible."); assertTrue(script.body().contains("nodeCapabilities.get(node)?.includes(quickSetupCapability())"), "Quick approvals must remain valid only for their selected capability version."); - assertTrue(script.body().contains("'config.quick-setup.v1', 'config.quick-setup.v2', 'config.proxy-method.v2'"), - "Secondary versioned backends must remain selectable for HTTP and Vote Party setup."); + assertTrue(script.body().contains("checkbox.disabled = !isBackend(node);"), + "Backend workspace selection must not depend on one configuration capability version."); assertTrue(script.body().contains("'config.quick-setup.v2', 'config.proxy-method.v2', 'data.inspect.v1'"), "HTTP capability transitions must invalidate cached guided configuration reads."); assertTrue(script.body().contains("return {enabled: 'true'};"), @@ -312,7 +320,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("is unavailable to ${proxy.displayName}"), "Availability warnings must remain distinct for every reporting proxy."); assertTrue(script.body().contains( - "if (nodeId && nodeId === selectedServerId) {\n serverPicker.value = selectedServerId;\n return;\n }"), + "if (nodeId && nodeId === selectedServerId) {\n serverPicker.value = selectedServerId;\n return true;\n }"), "Selecting the current server again must not reset unsaved YAML or routing drafts."); int primarySelector = script.body().indexOf("function selectPrimaryServer(nodeId)"); int sameServerGuard = script.body().indexOf( @@ -416,8 +424,17 @@ class ControlHttpServerTest { assertTrue(script.body().contains("presentPreviewReady(quickOperationStatus, applyQuickSetup, operation);")); assertTrue(script.body().contains("presentPreviewReady(elements.status, elements.apply, operation);")); assertTrue(script.body().contains("presentPreviewReady(rewardSimulationResult, applyReward, operation);")); + assertTrue(script.body().contains("path !== '/api/v1/configuration/rewards/preview'")); assertTrue(script.body().contains("Your unsaved proxy-routing draft is retained")); - assertTrue(script.body().contains("function invalidateConfigurationReads() {\n fileReadCache.clear();\n" + assertTrue(script.body().contains("function invalidateConfigurationReads() {\n settingsEditor?.invalidateReads();\n" + + " voteSitesEditor?.invalidateReads();\n" + + " if (authenticated && tabFromHash() === 'general-settings' && !settingsEditor?.state.busy) {\n" + + " window.setTimeout(() => void settingsEditor?.read(false), 0);\n }\n" + + " if (authenticated && tabFromHash() === 'vote-sites' && !voteSitesEditor?.state.busy) {\n" + + " window.setTimeout(() => void voteSitesEditor?.read(false), 0);\n }\n" + + " rewardsEditor?.invalidateReads();\n" + + " if (authenticated && tabFromHash() === 'rewards' && !rewardsEditor?.state.busy) {\n" + + " window.setTimeout(() => void rewardsEditor?.read(false), 0);\n }\n fileReadCache.clear();\n" + " lastFileReadOperation = null;\n clearApprovals();\n loadedQuickSetup = null;\n" + " if (!configurationDirty) {\n" + " configurationContent.value = '';\n configurationContentPresent = false;\n" @@ -829,17 +846,18 @@ class ControlHttpServerTest { assertTrue(script.body().contains("voteSitesConfigured: configuredVoteSites == null ? null : configuredVoteSites > 0")); assertTrue(script.body().contains("voteSitesConfiguredKnown: configuredVoteSites != null")); int exactShortcut = script.body().indexOf("const exactShortcut = GLOBAL_PAGE_SHORTCUTS.get(normalized);"); - int fuzzySetting = script.body().indexOf("const setting = SETTINGS_SCHEMA.find"); + int fuzzySetting = script.body().indexOf("const setting = GENERAL_SETTING_FIELDS.find"); assertTrue(exactShortcut >= 0 && exactShortcut < fuzzySetting); - assertTrue(script.body().contains("['rewards', {tab: 'quick-setup', scrollTarget: 'reward-builder-card'}]")); - assertTrue(script.body().contains("['vote sites', {tab: 'data', scrollTarget: 'site-health-card'}]")); + assertTrue(script.body().contains("['rewards', {tab: 'rewards'}]")); + assertTrue(script.body().contains("['vote sites', {tab: 'vote-sites'}]")); assertTrue(script.body().contains("['network doctor', {tab: 'network', scrollTarget: 'network-doctor-card'}]")); assertTrue(script.body().contains("['configuration compare', {tab: 'configurations', configView: 'compare'")); assertTrue(script.body().contains("openGlobalShortcut(GLOBAL_PAGE_SHORTCUTS.get('configuration compare'))")); assertTrue(script.body().contains("globalSearchInput.value = '';\n globalSearchOptions.replaceChildren();")); assertTrue(web.body().contains("data-tab=\"configurations\" data-config-shortcut=\"compare\"")); assertTrue(script.body().contains("if (button.dataset.configShortcut) setConfigView(button.dataset.configShortcut);")); - assertTrue(script.body().contains("if (setting) {\n settingsFilter.value = query;")); + assertTrue(script.body().contains("if (setting) {\n openWorkspace('general-settings');")); + assertTrue(script.body().contains("voteSitesEditor.selectSite(site.siteKey);")); int openWorkspace = script.body().indexOf("function openWorkspace(tab, scrollTarget = '', preset = '', navigationButton = null)"); int presetBeforeTab = script.body().indexOf("quickPreset.value = preset;", openWorkspace); int activateAfterPreset = script.body().indexOf("setActiveTab(tab, true);", openWorkspace); @@ -972,6 +990,130 @@ class ControlHttpServerTest { 409, "REGISTRY_CHANGED"); } + @Test void generalSettingsUsesAuthenticatedTypedReadAndExactBoundPreview() throws Exception { + String backend = registration().replace("VELOCITY", "BUKKIT") + .replace("\"presence.snapshot\"]", "\"config.files.v1\"]"); + assertEquals(201, send("POST", "/api/v1/nodes/register", backend, nodeToken).statusCode()); + String readBody = "{\"nodeIds\":[\"proxy-a\"],\"configuration\":{\"domain\":\"file\",\"fileName\":\"Config.yml\"}}"; + JsonNode read = json.readTree(send("POST", "/api/v1/configuration/read", readBody, adminToken).body()); + String readId = read.get("operationId").asText(); + JsonNode task = json.readTree(send("POST", "/api/v1/nodes/proxy-a/operations", + "{\"sessionId\":\"" + SESSION + "\"}", nodeToken).body()); + String content = "# preserve me\nProcessRewards: false\nAutoCreateVoteSites: false\nDatabase:\n Password: ''\nUnknown: server-local\n"; + Map result = Map.of("sessionId", SESSION, "success", true, "code", "OK", "message", "Read", + "revision", "a".repeat(64), "configuration", Map.of("domain", "file", "fileName", "Config.yml", "content", content), + "changes", java.util.List.of(), "reloaded", false, "rolledBack", false, "attemptId", task.get("attemptId").asText()); + assertEquals(200, send("POST", "/api/v1/nodes/proxy-a/operations/" + readId + "/result", + json.writeValueAsString(result), nodeToken).statusCode()); + String selector = json.writeValueAsString(Map.of("readOperationId", readId, "nodeId", "proxy-a")); + HttpResponse typed = send("POST", "/api/v1/configuration/general-settings/state", selector, adminToken); + assertEquals(200, typed.statusCode()); + assertFalse(typed.body().contains("Password")); + assertFalse(typed.body().contains("server-local")); + JsonNode fields = json.readTree(typed.body()).get("fields"); + assertFalse(fields.get("ProcessRewards").get("value").asBoolean()); + assertEquals("MISSING", fields.get("CountFakeVotes").get("status").asText()); + String previewBody = json.writeValueAsString(Map.of("readOperationId", readId, "nodeId", "proxy-a", + "overrides", Map.of("ProcessRewards", true))); + HttpResponse preview = send("POST", "/api/v1/configuration/general-settings/preview", previewBody, adminToken); + assertEquals(202, preview.statusCode()); + assertFalse(preview.body().contains("server-local")); + JsonNode previewTask = json.readTree(send("POST", "/api/v1/nodes/proxy-a/operations", + "{\"sessionId\":\"" + SESSION + "\"}", nodeToken).body()); + assertEquals("a".repeat(64), previewTask.get("expectedRevision").asText()); + assertEquals(content.replace("ProcessRewards: false", "ProcessRewards: true"), + previewTask.get("configuration").get("content").asText()); + assertError(send("POST", "/api/v1/configuration/general-settings/preview", + previewBody.replace("true", "\"true\""), adminToken), 400, "VALIDATION_ERROR"); + assertEquals(200, get("/configuration-state.js", null).statusCode()); + assertEquals(200, get("/general-settings.js", null).statusCode()); + } + + @Test void generalSettingsEndpointsRequireAdminAndSessionCsrf() throws Exception { + String statePath = "/api/v1/configuration/general-settings/state"; + String previewPath = "/api/v1/configuration/general-settings/preview"; + assertEquals(401, send("POST", statePath, "{}", null).statusCode()); + assertEquals(401, send("POST", previewPath, "{}", nodeToken).statusCode()); + assertEquals(405, get(statePath, adminToken).statusCode()); + assertEquals(405, get(previewPath, adminToken).statusCode()); + assertEquals(400, send("POST", previewPath, "{\"unknown\":true}", adminToken).statusCode()); + credentials.setWebPassword("fixture-password-long-enough".toCharArray()); + HttpResponse login = send("POST", "/api/v1/auth/login", "{\"password\":\"fixture-password-long-enough\"}", null); + String cookie = login.headers().firstValue("Set-Cookie").orElseThrow().split(";", 2)[0]; + assertEquals(403, sendWithHeaders("POST", statePath, "{}", Map.of("Cookie", cookie)).statusCode()); + assertEquals(403, sendWithHeaders("POST", previewPath, "{}", Map.of("Cookie", cookie)).statusCode()); + assertEquals(403, sendWithHeaders("POST", "/api/v1/configuration/general-settings/discard-preview", "{}", Map.of("Cookie", cookie)).statusCode()); + } + + @Test void voteSitesTypedEndpointsRequireAdminCsrfAndExcludeUnknownSourceContent() throws Exception { + String statePath = "/api/v1/configuration/vote-sites/state"; + String previewPath = "/api/v1/configuration/vote-sites/preview"; + String discardPath = "/api/v1/configuration/vote-sites/discard-preview"; + for (String route : java.util.List.of(statePath, previewPath, discardPath)) { + assertEquals(401, send("POST", route, "{}", null).statusCode()); + assertEquals(401, send("POST", route, "{}", nodeToken).statusCode()); + assertEquals(405, get(route, adminToken).statusCode()); + assertEquals(400, send("POST", route, "{\"unknown\":true}", adminToken).statusCode()); + } + assertEquals(200, get("/vote-sites-state.js", null).statusCode()); + assertEquals(200, get("/vote-sites.js", null).statusCode()); + String backend = registration().replace("VELOCITY", "BUKKIT") + .replace("\"presence.snapshot\"]", "\"config.files.v1\"]"); + assertEquals(201, send("POST", "/api/v1/nodes/register", backend, nodeToken).statusCode()); + String readBody = "{\"nodeIds\":[\"proxy-a\"],\"configuration\":{\"domain\":\"file\",\"fileName\":\"VoteSites.yml\"}}"; + String readId = json.readTree(send("POST", "/api/v1/configuration/read", readBody, adminToken).body()) + .get("operationId").asText(); + JsonNode task = json.readTree(send("POST", "/api/v1/nodes/proxy-a/operations", + "{\"sessionId\":\"" + SESSION + "\"}", nodeToken).body()); + String secret = "DO_NOT_EXPOSE_REWARDS_OR_UNKNOWN"; + String source = "VoteSites:\n Alpha:\n Enabled: false\n Name: Alpha\n ServiceSite: alpha\n" + + " VoteDelay: '24h'\n Priority: 5\n Unknown: " + secret + + "\n Rewards:\n Commands:\n - '" + secret + "'\n"; + Map result = Map.of("sessionId", SESSION, "success", true, "code", "OK", "message", "Read", + "revision", "a".repeat(64), "configuration", Map.of("domain", "file", "fileName", "VoteSites.yml", "content", source), + "changes", java.util.List.of(), "reloaded", false, "rolledBack", false, "attemptId", task.get("attemptId").asText()); + assertEquals(200, send("POST", "/api/v1/nodes/proxy-a/operations/" + readId + "/result", + json.writeValueAsString(result), nodeToken).statusCode()); + String selector = json.writeValueAsString(Map.of("readOperationId", readId, "nodeId", "proxy-a")); + HttpResponse state = send("POST", statePath, selector, adminToken); + assertEquals(200, state.statusCode()); + assertFalse(state.body().contains(secret)); + assertTrue(state.body().contains("rewardsConfigured")); + String edit = json.writeValueAsString(Map.of("readOperationId", readId, "nodeId", "proxy-a", + "action", "EDIT", "siteKey", "Alpha", "fields", Map.of("Enabled", true))); + HttpResponse preview = send("POST", previewPath, edit, adminToken); + assertEquals(202, preview.statusCode()); + assertFalse(preview.body().contains(secret)); + JsonNode previewTask = json.readTree(send("POST", "/api/v1/nodes/proxy-a/operations", + "{\"sessionId\":\"" + SESSION + "\"}", nodeToken).body()); + assertEquals("a".repeat(64), previewTask.get("expectedRevision").asText()); + assertEquals(source.replace("Enabled: false", "Enabled: true"), previewTask.get("configuration").get("content").asText()); + assertError(send("POST", previewPath, edit.replace("Alpha", "bad.key"), adminToken), 400, "VALIDATION_ERROR"); + credentials.setWebPassword("fixture-password-long-enough".toCharArray()); + HttpResponse login = send("POST", "/api/v1/auth/login", "{\"password\":\"fixture-password-long-enough\"}", null); + String cookie = login.headers().firstValue("Set-Cookie").orElseThrow().split(";", 2)[0]; + for (String route : java.util.List.of(statePath, previewPath, discardPath)) { + assertEquals(403, sendWithHeaders("POST", route, "{}", Map.of("Cookie", cookie)).statusCode()); + } + } + + @Test void rewardsTypedEndpointsRequireAdminAndCsrf() throws Exception { + for (String route : java.util.List.of("/api/v1/configuration/rewards/state", + "/api/v1/configuration/rewards/preview", "/api/v1/configuration/rewards/discard-preview")) { + assertEquals(401, send("POST", route, "{}", null).statusCode()); + assertEquals(401, send("POST", route, "{}", nodeToken).statusCode()); + assertEquals(405, get(route, adminToken).statusCode()); + assertEquals(400, send("POST", route, "{\"unknown\":true}", adminToken).statusCode()); + } + assertEquals(200, get("/rewards.js", null).statusCode()); + credentials.setWebPassword("fixture-password-long-enough".toCharArray()); + HttpResponse login = send("POST", "/api/v1/auth/login", + "{\"password\":\"fixture-password-long-enough\"}", null); + String cookie = login.headers().firstValue("Set-Cookie").orElseThrow().split(";", 2)[0]; + assertEquals(403, sendWithHeaders("POST", "/api/v1/configuration/rewards/preview", "{}", + Map.of("Cookie", cookie)).statusCode()); + } + @Test void configurationPreviewApprovalAndRevisionCheckedApplyAreEndToEnd() throws Exception { String capableRegistration = registration().replace("\"presence.snapshot\"]", "\"presence.snapshot\",\"config.proxy-routing.v1\"]"); diff --git a/src/test/java/com/bencodez/votingplugin/control/protocol/InspectionQueryTest.java b/src/test/java/com/bencodez/votingplugin/control/protocol/InspectionQueryTest.java index c8827701..21d97eeb 100644 --- a/src/test/java/com/bencodez/votingplugin/control/protocol/InspectionQueryTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/protocol/InspectionQueryTest.java @@ -11,14 +11,23 @@ class InspectionQueryTest { @Test void supportsOnlyTheBoundedReadOnlyInspectionCatalog() { for (String kind : InspectionQuery.KINDS) { - InspectionQuery query = new InspectionQuery(kind, Map.of("player", "Example")); + InspectionQuery query = new InspectionQuery(kind, + "reward-file-inventory".equals(kind) ? Map.of() : Map.of("player", "Example")); assertEquals(kind, query.kind()); - assertEquals("Example", query.filters().get("player")); + if (!query.requiresRewardFiles()) assertEquals("Example", query.filters().get("player")); } assertEquals("data.inspect.v1", InspectionQuery.CAPABILITY); assertThrows(IllegalArgumentException.class, () -> new InspectionQuery("raw-sql", Map.of())); } + @Test void rewardFileInventoryHasNoFiltersAndRequiresItsVersionedCapability() { + InspectionQuery inventory = new InspectionQuery("reward-file-inventory", Map.of()); + assertTrue(inventory.requiresRewardFiles()); + assertEquals("config.reward-files.v1", InspectionQuery.REWARD_FILE_CAPABILITY); + assertThrows(IllegalArgumentException.class, () -> new InspectionQuery("reward-file-inventory", + Map.of("name", "StandardVote"))); + } + @Test void normalizesNullFiltersAndDefensivelyCopiesThem() { assertTrue(new InspectionQuery("overview", null).filters().isEmpty()); Map filters = new LinkedHashMap<>(); diff --git a/src/test/java/com/bencodez/votingplugin/control/protocol/ManagedConfigurationRewardFilesTest.java b/src/test/java/com/bencodez/votingplugin/control/protocol/ManagedConfigurationRewardFilesTest.java new file mode 100644 index 00000000..68f63765 --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/protocol/ManagedConfigurationRewardFilesTest.java @@ -0,0 +1,22 @@ +package com.bencodez.votingplugin.control.protocol; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class ManagedConfigurationRewardFilesTest { + @Test void namedRewardsUseSeparateVersionedCapability() { + assertEquals(ManagedConfiguration.REWARD_FILE_CAPABILITY, + ManagedConfiguration.file("Rewards/StandardVote.yml", null).capability()); + assertEquals("config.files.v1", ManagedConfiguration.file("VoteSites.yml", null).capability()); + } + + @Test void namedRewardsRejectPathsAndUnapprovedNames() { + for (String file : new String[] {"Rewards/../Config.yml", "Rewards/%2e%2e.yml", + "Rewards/Other/File.yml", "Rewards\\Other.yml", "Rewards/.hidden.yml", + "Rewards/Foo.yaml", "/Rewards/Foo.yml", "Rewards/Foo Bar.yml", "Rewards/Foo.yml/Child"}) { + assertThrows(IllegalArgumentException.class, () -> ManagedConfiguration.file(file, null), file); + } + } +} diff --git a/src/test/web/app-file-navigation.test.cjs b/src/test/web/app-file-navigation.test.cjs new file mode 100644 index 00000000..3f7c5973 --- /dev/null +++ b/src/test/web/app-file-navigation.test.cjs @@ -0,0 +1,77 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); + +const source = fs.readFileSync(path.join(__dirname, '../../main/resources/web/app.js'), 'utf8'); + +function block(startMarker) { + const start = source.indexOf(startMarker); + assert.ok(start >= 0, `production handler exists: ${startMarker}`); + const end = source.indexOf('\n});', start); + assert.ok(end > start, `production handler closes: ${startMarker}`); + return source.slice(start, end + 4); +} + +function harness(dirty = false) { + const listeners = new Map(); + const configurationFile = { + value: 'Rewards/Private.yml', + addEventListener(type, listener) { listeners.set(type, listener); }, + dispatchEvent(event) { listeners.get(event.type)?.(event); } + }; + const voteSiteYaml = {addEventListener(type, listener) { listeners.set(`vote-site-${type}`, listener); }}; + const reads = []; + const context = { + configurationFile, + configurationFileSelection: 'Rewards/Private.yml', + configurationContent: {value: 'Commands:\n- say secret', disabled: false, removeAttribute() {}}, + configurationContentPresent: true, + configurationDirty: dirty, + configurationDraftNodeId: 'backend', + configurationDraftSessionId: 'session', + configurationDraftFileName: 'Rewards/Private.yml', + lastFileReadOperation: {operationId: 'old-read'}, + approvedFilePreview: {approvalToken: 'old-preview'}, + readFileConfiguration: {hidden: false}, + fileOperationStatus: {}, + Event: class Event { constructor(type) { this.type = type; } }, + window: {confirm: () => false}, + document: {querySelector: selector => selector === '#vote-site-open-yaml' ? voteSiteYaml : null}, + updateEditorPosition() {}, clearApprovals() {}, updateExtendedButtons() {}, + text(element, value) { element.textContent = value; }, + autoLoadTab(tab) { reads.push(tab); }, + setActiveTab() {}, setConfigView() {} + }; + vm.createContext(context); + const resetStart = source.indexOf('function resetFileEditorForSelection('); + const resetEnd = source.indexOf('\n}\n', resetStart); + assert.ok(resetStart >= 0 && resetEnd > resetStart); + vm.runInContext(source.slice(resetStart, resetEnd + 2), context); + vm.runInContext(block("configurationFile.addEventListener('input', () => {"), context); + vm.runInContext(block("document.querySelector('#vote-site-open-yaml').addEventListener('click', () => {"), context); + return {context, reads, open: () => listeners.get('vote-site-click')()}; +} + +test('Vote Sites Open YAML clears a previously loaded named-reward document before reading the new file', () => { + const {context, reads, open} = harness(); + open(); + assert.equal(context.configurationFile.value, 'VoteSites.yml'); + assert.equal(context.configurationFileSelection, 'VoteSites.yml'); + assert.equal(context.configurationContent.value, ''); + assert.equal(context.configurationContentPresent, false); + assert.equal(context.lastFileReadOperation, null); + assert.equal(context.approvedFilePreview, null); + assert.deepEqual(reads, ['configurations']); +}); + +test('Vote Sites Open YAML does not discard an unsaved named-reward YAML draft when confirmation is declined', () => { + const {context, reads, open} = harness(true); + open(); + assert.equal(context.configurationFile.value, 'Rewards/Private.yml'); + assert.equal(context.configurationFileSelection, 'Rewards/Private.yml'); + assert.equal(context.configurationContent.value, 'Commands:\n- say secret'); + assert.equal(context.configurationDirty, true); + assert.deepEqual(reads, []); +}); diff --git a/src/test/web/app-global-search.test.cjs b/src/test/web/app-global-search.test.cjs new file mode 100644 index 00000000..cafe8276 --- /dev/null +++ b/src/test/web/app-global-search.test.cjs @@ -0,0 +1,43 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); + +const source = fs.readFileSync(path.join(__dirname, '../../main/resources/web/app.js'), 'utf8'); +const start = source.indexOf('const GLOBAL_PAGE_SHORTCUTS = new Map(['); +const end = source.indexOf("window.addEventListener('beforeunload'", start); +assert.ok(start >= 0 && end > start, 'production global-search handler is present'); + +class Element { + constructor() { this.value = ''; this.listeners = new Map(); } + addEventListener(type, listener) { this.listeners.set(type, listener); } +} + +test('global search confirms before replacing a Vote Site draft with another loaded site', () => { + const search = new Element(); const input = new Element(); input.value = 'second'; + const selected = []; const opened = []; const prompts = []; + const context = { + globalSearch: search, globalSearchInput: input, GLOBAL_PAGE_SHORTCUTS: undefined, + allNodeItems: [], GENERAL_SETTING_FIELDS: [], voteSitesHasDraft: () => true, + voteSitesEditor: {model: {selectedSiteKey: 'first', targets: new Map([ + ['node', {sites: [{siteKey: 'second'}]}] + ])}, selectSite: key => selected.push(key)}, + window: {confirm: message => { prompts.push(message); return false; }}, + openWorkspace: (...args) => opened.push(args), renderVoteSites: () => opened.push(['render']), + setConfigView() {}, tabFromHash: () => 'vote-sites', openVoteSiteAdd() {}, text() {}, message: new Element() + }; + vm.runInNewContext(source.slice(start, end), context, {filename: 'app-global-search-handler.js'}); + search.listeners.get('submit')({preventDefault() {}}); + assert.equal(prompts.length, 1); + assert.match(prompts[0], /Discard the current Vote Site draft/); + assert.deepEqual(selected, []); + assert.deepEqual(opened, []); + assert.equal(input.value, 'second'); + + context.window.confirm = () => true; + search.listeners.get('submit')({preventDefault() {}}); + assert.deepEqual(selected, ['second']); + assert.deepEqual(opened, [['vote-sites'], ['render']]); + assert.equal(input.value, ''); +}); diff --git a/src/test/web/app-reward-scope.test.cjs b/src/test/web/app-reward-scope.test.cjs new file mode 100644 index 00000000..8eb73d29 --- /dev/null +++ b/src/test/web/app-reward-scope.test.cjs @@ -0,0 +1,117 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); + +const source = fs.readFileSync(path.join(__dirname, '../../main/resources/web/app.js'), 'utf8'); + +function section(startMarker, endMarker) { + const start = source.indexOf(startMarker); + const end = source.indexOf(endMarker, start); + assert.ok(start >= 0 && end > start, `production section exists: ${startMarker}`); + return source.slice(start, end); +} + +function fixture() { + const calls = []; + const listeners = new Map(); + const scope = {fileName: 'VoteSites.yml', path: 'VoteSites.Beta.Rewards'}; + const editor = { + selected: {fileName: 'VoteSites.yml', rewardPath: 'VoteSites.Alpha.Rewards'}, + edit: {operation: 'APPEND_LIST_ENTRY', field: 'Commands', value: 'say staged'}, + select(fileName, rewardPath) { + calls.push(['select', fileName, rewardPath]); + if (this.selected.fileName !== fileName || this.selected.rewardPath !== rewardPath) { + this.selected = {fileName, rewardPath}; this.edit = null; + } + }, + scopes: () => [scope], + record: () => ({scopes: [{path: scope.path, status: 'PRESENT'}]}), + read: async () => { calls.push(['read']); } + }; + let confirm = false; + const element = () => ({children: [], setAttribute() {}, append(...children) { this.children.push(...children); }, + addEventListener(name, listener) { this[name] = listener; }}); + const list = element(); + const context = { + rewardsEditor: editor, + voteSitesEditor: {model: {selectedSiteKey: 'Beta'}}, + window: {confirm: message => { calls.push(['confirm', message]); return confirm; }}, + document: {querySelector: selector => ({addEventListener: (name, listener) => listeners.set(selector + ':' + name, listener)}), + createElement: element}, + search: '', targets: [{nodeId: 'a'}], selected: editor.selected, list, + text: (node, value) => { node.textContent = value; return node; }, + renderRewards: () => { calls.push(['render']); }, + openWorkspace: tab => { calls.push(['open', tab]); } + }; + vm.createContext(context); + vm.runInContext(section('function chooseRewardScope(', '\nfunction rewardsContext()'), context); + vm.runInContext(section("document.querySelector('#vote-site-edit-rewards').addEventListener", '\nfunction chooseRewardScope('), context); + vm.runInContext(section(' const scopes = rewardsEditor.scopes()', ' if (!scopes.length)'), context); + return {context, calls, list, editor, scope, listeners, allow: value => { confirm = value; }}; +} + +test('scope list keeps a staged Rewards edit when discard is declined and changes after consent', () => { + const f = fixture(); + f.list.children[0].click(); + assert.equal(f.editor.selected.rewardPath, 'VoteSites.Alpha.Rewards'); + assert.equal(f.editor.edit.value, 'say staged'); + assert.equal(f.calls.filter(([name]) => name === 'select' || name === 'read').length, 0); + f.allow(true); + f.list.children[0].click(); + assert.equal(f.editor.selected.rewardPath, f.scope.path); + assert.equal(f.editor.edit, null); + assert.equal(f.calls.filter(([name]) => name === 'select').length, 1); + assert.equal(f.calls.filter(([name]) => name === 'read').length, 1); +}); + +test('Vote Sites Edit rewards keeps the current page and draft when discard is declined', () => { + const f = fixture(); + const click = f.listeners.get('#vote-site-edit-rewards:click'); + click(); + assert.equal(f.editor.selected.rewardPath, 'VoteSites.Alpha.Rewards'); + assert.equal(f.editor.edit.value, 'say staged'); + assert.equal(f.calls.some(([name]) => name === 'open'), false); + f.allow(true); + click(); + assert.equal(f.editor.selected.rewardPath, 'VoteSites.Beta.Rewards'); + assert.equal(f.editor.edit, null); + assert.deepEqual(f.calls.filter(([name]) => name === 'open'), [['open', 'rewards']]); +}); + +test('selecting the current reward scope leaves its draft without a discard prompt', () => { + const f = fixture(); + assert.equal(vm.runInContext("chooseRewardScope('VoteSites.yml', 'VoteSites.Alpha.Rewards')", f.context), true); + assert.equal(f.editor.edit.value, 'say staged'); + assert.equal(f.calls.some(([name]) => name === 'confirm'), false); +}); + +test('Rewards staging rejects oversized scalar and single-command values before creating an edit', () => { + const listener = new Map(); const values = new Map(); const status = {}; + const element = selector => values.get(selector) || (selector === '#rewards-status' ? status : { + value: '', checked: false, addEventListener: (name, handler) => listener.set(`${selector}:${name}`, handler) + }); + const editor = {selected: {rewardPath: 'VoteSites.Alpha.Rewards'}, setEdit: (...args) => { editor.edit = args; }}; + const context = { + rewardsEditor: editor, + window: {confirm: () => true}, + document: {querySelector: element}, + text: (node, value) => { node.textContent = value; return node; } + }; + const stage = section("document.querySelector('#rewards-stage').addEventListener", "document.querySelector('#rewards-preview')"); + vm.createContext(context); vm.runInContext(stage, context); + const click = listener.get('#rewards-stage:click'); + for (const [operation, field] of [ + ['APPEND_LIST_ENTRY', 'Commands'], ['REMOVE_LIST_ENTRY', 'Commands'], ['CREATE_REWARD', 'Commands'], + ['SET_SCALAR', 'Messages.Player'], ['SET_SCALAR', 'Messages.Broadcast'] + ]) { + values.set('#rewards-operation', {value: operation}); + values.set('#rewards-field', {value: field}); + values.set('#rewards-value', {value: 'x'.repeat(501)}); + values.set('#rewards-ack', {checked: true}); + editor.edit = null; click(); + assert.equal(editor.edit, null, `${operation}/${field} must not be staged`); + assert.equal(status.textContent, 'Use at most 500 characters.'); + } +}); diff --git a/src/test/web/app-rewards-capabilities.test.cjs b/src/test/web/app-rewards-capabilities.test.cjs new file mode 100644 index 00000000..cc0d4c86 --- /dev/null +++ b/src/test/web/app-rewards-capabilities.test.cjs @@ -0,0 +1,30 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); + +const source = fs.readFileSync(path.join(__dirname, '../../main/resources/web/app.js'), 'utf8'); +const start = source.indexOf('function rewardsContext()'); +const end = source.indexOf('\nasync function inspectNamedRewardFiles', start); +assert.ok(start >= 0 && end > start, 'production rewards context exists'); + +test('Rewards context invalidates cached reads when target eligibility or discovery capability changes', () => { + const node = {sessionId: 'session', online: true, acceptedCapabilities: ['config.files.v1', 'config.reward-files.v1', 'data.inspect.v1']}; + const context = { + authenticationGeneration: 1, + workspace: {managementScope: 'SERVER', selectedTargetIds: new Set(['node'])}, + nodeIndex: new Map([['node', node]]), + isBackend: value => value === node, + JSON + }; + vm.createContext(context); + vm.runInContext(source.slice(start, end), context); + const ready = vm.runInContext('rewardsContext()', context); + node.online = false; + const offline = vm.runInContext('rewardsContext()', context); + assert.notEqual(offline, ready); + node.online = true; + node.acceptedCapabilities = ['config.files.v1', 'config.reward-files.v1']; + assert.notEqual(vm.runInContext('rewardsContext()', context), ready); +}); diff --git a/src/test/web/app-vote-site-integers.test.cjs b/src/test/web/app-vote-site-integers.test.cjs new file mode 100644 index 00000000..efffa127 --- /dev/null +++ b/src/test/web/app-vote-site-integers.test.cjs @@ -0,0 +1,86 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); + +const source = fs.readFileSync(path.join(__dirname, '../../main/resources/web/app.js'), 'utf8'); +const start = source.indexOf('VOTE_SITE_FIELDS.forEach(field => {\n const control = document.querySelector(field.control);'); +const end = source.indexOf("document.querySelector('#vote-site-reset')", start); +assert.ok(start >= 0 && end > start, 'production Vote Site edit handlers are present'); + +test('Vote Site edit handler rejects out-of-range integers without leaving an approved draft hidden behind the input', () => { + const edits = []; + const controls = new Map(); + for (const selector of ['#vote-site-priority', '#vote-site-amount', '#vote-sites-status', '#vote-site-ack']) { + controls.set(selector, {value: '', checked: true, addEventListener(type, handler) { this.handler = handler; }}); + } + const fields = [ + {path: 'Priority', label: 'Priority', control: '#vote-site-priority', type: 'integer'}, + {path: 'DisplayItem.Amount', label: 'Display amount', control: '#vote-site-amount', type: 'integer'} + ]; + const context = { + VOTE_SITE_FIELDS: fields, + ControlVoteSites: require('../../main/resources/web/vote-sites-state.js'), + document: {querySelector: selector => controls.get(selector)}, + voteSitesEditor: {model: {aggregateField: () => ({})}, edit: (field, value) => edits.push([field, value])}, + voteSiteFieldInput: field => { controls.get(field.control).value = '20'; }, + text: (element, value) => { element.textContent = value; } + }; + vm.runInNewContext(source.slice(start, end), context); + + const priority = controls.get('#vote-site-priority'); + priority.value = '2147483648'; priority.handler(); + assert.deepEqual(edits, []); + assert.equal(priority.value, '20'); + assert.match(controls.get('#vote-sites-status').textContent, /last staged value was retained/); + priority.value = '2147483647'; priority.handler(); + assert.deepEqual(edits, [['Priority', 2147483647]]); + + const amount = controls.get('#vote-site-amount'); + amount.value = '65'; amount.handler(); + assert.equal(amount.value, '20'); + assert.equal(edits.length, 1); + amount.value = '64'; amount.handler(); + assert.deepEqual(edits.at(-1), ['DisplayItem.Amount', 64]); +}); + +test('Vote Site edit handler rejects invalid strings and preserves the staged value', () => { + const edits = []; + const controls = new Map(); + for (const selector of ['#vote-site-name', '#vote-site-service', '#vote-site-url', '#vote-site-delay', + '#vote-site-material', '#vote-sites-status', '#vote-site-ack']) { + controls.set(selector, {value: '', checked: true, addEventListener(type, handler) { this.handler = handler; }}); + } + const fields = [ + {path: 'Name', label: 'Display name', control: '#vote-site-name', type: 'string'}, + {path: 'ServiceSite', label: 'Service site', control: '#vote-site-service', type: 'string'}, + {path: 'VoteURL', label: 'Vote URL', control: '#vote-site-url', type: 'string'}, + {path: 'VoteDelay', label: 'Vote delay', control: '#vote-site-delay', type: 'string'}, + {path: 'DisplayItem.Material', label: 'Display material', control: '#vote-site-material', type: 'string'} + ]; + const context = { + VOTE_SITE_FIELDS: fields, + ControlVoteSites: require('../../main/resources/web/vote-sites-state.js'), + document: {querySelector: selector => controls.get(selector)}, + voteSitesEditor: {model: {aggregateField: () => ({})}, edit: (field, value) => edits.push([field, value])}, + voteSiteFieldInput: field => { controls.get(field.control).value = 'last staged'; }, + text: (element, value) => { element.textContent = value; } + }; + vm.runInNewContext(source.slice(start, end), context); + + for (const field of fields.slice(1)) { + const control = controls.get(field.control); + control.value = field.path === 'DisplayItem.Material' ? 'STONE BLOCK' : ''; + control.handler(); + assert.equal(control.value, 'last staged', `${field.path} must restore the staged value`); + assert.equal(edits.length, 0, `${field.path} must not be staged`); + } + const service = controls.get('#vote-site-service'); + service.value = 'bad|service'; service.handler(); + assert.equal(edits.length, 0); + service.value = 'PlanetMinecraft.com'; service.handler(); + assert.deepEqual(edits, [['ServiceSite', 'PlanetMinecraft.com']]); + assert.equal(controls.get('#vote-site-ack').checked, false); + assert.match(controls.get('#vote-sites-status').textContent, /last staged value was retained/); +}); diff --git a/src/test/web/app-workspace.test.cjs b/src/test/web/app-workspace.test.cjs new file mode 100644 index 00000000..1153c974 --- /dev/null +++ b/src/test/web/app-workspace.test.cjs @@ -0,0 +1,439 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); + +const appSource = fs.readFileSync(path.join(__dirname, '../../main/resources/web/app.js'), 'utf8'); +const indexSource = fs.readFileSync(path.join(__dirname, '../../main/resources/web/index.html'), 'utf8'); +const ControlWorkspace = require('../../main/resources/web/workspace.js'); + +test('authenticated document IDs are unique and sync targets keep their visible control', () => { + const ids = [...indexSource.matchAll(/\sid="([^"]+)"/g)].map(match => match[1]); + assert.equal(new Set(ids).size, ids.length); + assert.match(indexSource, /id="vote-sites-read-targets" class="settings-targets"/); + assert.match(indexSource, /id="vote-sites-targets" class="target-list"/); + assert.match(appSource, /const voteSitesTargets = document\.querySelector\('#vote-sites-targets'\)/); + assert.match(appSource, /const targets = document\.querySelector\('#vote-sites-read-targets'\); targets\.replaceChildren\(\)/); +}); + +function declaration(name) { + const match = new RegExp(`(?:^|\\n)(?:async )?function ${name}\\(`, 'm').exec(appSource); + const start = match ? match.index + (match[0].startsWith('\n') ? 1 : 0) : -1; + assert.notEqual(start, -1, `app.js declares ${name}`); + const open = appSource.indexOf('{', start); + let depth = 0; + let quote = ''; + for (let index = open; index < appSource.length; index++) { + const character = appSource[index]; + if (quote) { + if (character === '\\') index++; + else if (character === quote) quote = ''; + continue; + } + if (character === '\'' || character === '"' || character === '`') { + quote = character; + } else if (character === '{') { + depth++; + } else if (character === '}' && --depth === 0) { + return appSource.slice(start, index + 1); + } + } + throw new Error(`Could not extract ${name}`); +} + +function harness() { + const calls = []; + class Element { + constructor(tagName = 'div') { + this.tagName = tagName; + this.children = []; + this.dataset = {}; + this.listeners = new Map(); + this.hidden = false; + this.value = ''; + this.textContent = ''; + this.className = ''; + } + append(...items) { this.children.push(...items); } + prepend(...items) { this.children.unshift(...items); } + replaceChildren(...items) { this.children = items; } + addEventListener(type, listener) { this.listeners.set(type, listener); } + setAttribute(name, value) { this[name] = value; } + find(predicate) { + if (predicate(this)) return this; + for (const child of this.children) { + if (child && typeof child.find === 'function') { + const found = child.find(predicate); + if (found) return found; + } + } + return null; + } + get lastElementChild() { return [...this.children].reverse().find(child => child instanceof Element) || null; } + } + const element = () => new Element(); + const elements = new Map(); + const document = { + createElement: tagName => new Element(tagName), + createTextNode: text => ({textContent: text}), + querySelector: selector => { + if (!elements.has(selector)) elements.set(selector, element()); + return elements.get(selector); + } + }; + const context = { + settingsEditor: null, voteSitesEditor: null, rewardsEditor: null, + voteSitesHasDraft: () => false, + ControlWorkspace, + workspace: new ControlWorkspace.Workspace(), + registryAvailable: false, + selectedServerId: '', + nodeIndex: new Map(), + nodeCapabilities: new Map(), + selectedNodes: new Set(['stale']), + voteSitesTargetIds: new Set(['stale']), + fileReadCache: new Map([['stale', {}]]), + dedicatedSetupApprovals: new Set(['stale']), + voteLoggingRestartPending: new Set(['stale']), + autoLoadInFlight: new Set(['stale']), + autoLoadPending: new Set(['stale']), + configurationContent: element(), configurationFile: element(), logout: element(), sidebarToggle: element(), + globalSearch: element(), headerAction: element(), authCard: element(), welcome: element(), appShell: element(), + serverPickerLabel: element(), enrollmentCard: element(), quickPreset: element(), + authenticated: false, csrfToken: 'old', approvedPreview: {}, approvedFilePreview: {}, approvedQuickPreview: {}, + loadedQuickSetup: {}, quickSetupDirty: true, voteSitesSourceId: 'stale', voteSitesTargetsInitialized: true, + transportTestProxyId: 'stale', transportTestBackendId: 'stale', proxyMethodProxyId: 'stale', + proxyMethodCurrentFor: 'stale', proxyMethodCurrentSessionId: 'stale', proxyMethodCurrentReadCapability: 'stale', + proxyMethodCurrentValue: 'stale', lastFileReadOperation: {}, lastDiagnostics: {}, lastOverview: {}, + dashboardOverview: {}, dashboardVoteSiteHealth: {}, dashboardVoteSummary24h: {}, dashboardVoteSummary30d: {}, + dashboardLoadedContext: 'stale', dashboardInspectionStatus: {}, dashboardTopologySignature: 'stale', + operationHistoryItems: [{}], deploymentHistoryItems: [{}], observedServerConfigurationGeneration: 1, + operationHistoryStatus: 'loaded', enrollmentStatus: 'loaded', pendingDetectedVoteSite: {}, + configurationContentPresent: true, configurationDirty: true, configurationDraftNodeId: 'stale', + configurationDraftSessionId: 'stale', configurationDraftFileName: 'stale', routingDirty: true, + routingDraftNodeId: 'stale', configurationFileSelection: 'stale', inputGeneration: 0, pageOffset: 9, + allNodeItems: [], enrollmentsLoaded: true, enrollmentIds: new Set(), MAX_CONFIGURATION_TARGETS: 100, + operationStatus: element(), document, activeNavigationHash: '#home', + window: {location: {hash: '#home'}, history: {replaceState: (...args) => { + context.window.location.hash = args[2]; calls.push(['replaceState', ...args]); + }}}, + emptyDashboardInspectionStatus: () => ({empty: true}), + syncTopbarOffset: () => calls.push(['syncTopbarOffset']), + renderOperationHistory: () => calls.push(['renderOperationHistory']), + populateProfilePicker: () => calls.push(['populateProfilePicker']), + confirmDiscardUnsavedConfiguration: () => true, + resetServerContextValues: reason => calls.push(['reset', reason]), + clearApprovals: () => calls.push(['clearApprovals']), + renderNodeViews: () => calls.push(['renderNodeViews']), + updateConfigurationButtons: () => calls.push(['updateConfigurationButtons']), + updateExtendedButtons: () => calls.push(['updateExtendedButtons']), + updateQuickFields: () => calls.push(['updateQuickFields']), + scrollToAnchor: () => calls.push(['scrollToAnchor']), + selectPrimaryServer: id => { context.selectedServerId = id; calls.push(['selectPrimaryServer', id]); return true; }, + setActiveTab: (tab, updateHash) => calls.push(['setActiveTab', tab, updateHash]), + operationContext: () => ({}), configurationOperationsInFlight: 0, + authorized: () => { throw new Error('authorized should not run for rejected preview'); }, + waitForOperation: () => { throw new Error('wait should not run for rejected preview'); }, + calls + }; + vm.createContext(context); + const names = ['text', 'applyAuthenticatedSession', 'isProxy', 'isBackend', 'roleLabel', 'platformLabel', + 'friendlyCapability', 'managedCapabilities', 'proxyReportsFor', 'backendCard', 'nodePresence', 'nodeCard', + 'ordinaryTargetIds', 'comparisonTargetIds', 'confirmDiscardWorkspaceDrafts', 'changeWorkspaceTargets', + 'inspectWorkspaceServer', 'openScopeOverview', 'enterGlobalWorkspace', 'applyNavigationRoute', 'startConfigurationOperation']; + vm.runInContext(names.map(declaration).join('\n'), context, {filename: 'app-workspace-helpers.js'}); + return context; +} + +function backend(id, online = true) { return {nodeId: id, platform: 'BUKKIT', online}; } +function proxy(id, online = true) { return {nodeId: id, platform: 'VELOCITY', online}; } +function run(context, source) { return vm.runInContext(source, context); } + +test('overview shortcuts keep the visible Data or Quick Setup route', () => { + const opened = []; + const context = { + workspace: {route: ''}, + window: {location: {hash: ''}, history: {replaceState() { throw new Error('unexpected route rewrite'); }}, + requestAnimationFrame(callback) { callback(); }}, + document: {getElementById(id) { return {id}; }}, + setActiveTab(tab) { context.workspace.route = `#workspace/${tab}`; context.window.location.hash = context.workspace.route; opened.push(tab); }, + scrollToAnchor(element) { opened.push(element.id); } + }; + vm.createContext(context); + vm.runInContext(declaration('openWorkspace'), context); + run(context, "openWorkspace('data', 'site-health-card')"); + assert.equal(context.window.location.hash, '#workspace/data'); + assert.equal(context.workspace.route, '#workspace/data'); + run(context, "openWorkspace('quick-setup', 'reward-builder-card')"); + assert.equal(context.window.location.hash, '#workspace/quick-setup'); + assert.equal(context.workspace.route, '#workspace/quick-setup'); + assert.deepEqual(opened, ['data', 'site-health-card', 'quick-setup', 'reward-builder-card']); +}); + +test('an empty reconciled workspace replaces stale routes with Home', () => { + const context = harness(); + context.authenticated = true; + context.workspace.setTargets(['disappeared']); + context.window.location.hash = '#workspace/settings'; + context.workspace.reconcile([]); + run(context, 'applyNavigationRoute()'); + assert.equal(context.window.location.hash, '#home'); + assert.deepEqual(context.calls.filter(call => call[0] === 'setActiveTab').at(-1), ['setActiveTab', 'home', undefined]); + + const panels = [{dataset: {panel: 'home'}, hidden: true}, {dataset: {panel: 'general-settings'}, hidden: false}]; + const active = {authenticated: true, tabPanels: panels, navigationButtons: [], tabButtons: [], + workspace: {managementScope: null, route: '', setRoute(route) { this.route = route; }}, + window: {location: {hash: '#workspace/settings'}, history: {replaceState(_state, _title, hash) { active.window.location.hash = hash; }}, + scrollTo() {}}, + workspaceHash(tab) { return tab === 'home' ? '#home' : '#workspace/settings'; }, + renderWorkspaceChrome() {}, renderScopeOverview() {}, renderOverviewActivity() {}, closeSidebar() {}, + updateHeaderAction() {}, autoLoadTab() {}}; + vm.createContext(active); + vm.runInContext(declaration('setActiveTab'), active); + vm.runInContext("setActiveTab('general-settings')", active); + assert.equal(active.window.location.hash, '#home'); + assert.equal(active.workspace.route, '#home'); + assert.equal(panels[0].hidden, false); + assert.equal(panels[1].hidden, true); +}); + +test('node refresh replaces a disappeared inspected-server route without dropping valid targets', () => { + assert.match(appSource, /normalizeWorkspaceRouteAfterNodeRefresh\(previousWorkspaceTargets, previousInspectedServerId\)/); + const context = harness(); + context.workspace.setTargets(['lobby']); + context.workspace.inspect('gone'); + context.window.location.hash = '#servers/gone/overview'; + const previousTargets = [...context.workspace.selectedTargetIds].join('\u0000'); + const previousInspectedServerId = context.workspace.inspectedServerId; + context.workspace.reconcile([backend('lobby')]); + context.workspaceHash = tab => tab === 'overview' ? '#workspace/overview' : '#home'; + vm.runInContext(declaration('normalizeWorkspaceRouteAfterNodeRefresh'), context); + run(context, `normalizeWorkspaceRouteAfterNodeRefresh(${JSON.stringify(previousTargets)}, ${JSON.stringify(previousInspectedServerId)})`); + assert.deepEqual([...context.workspace.selectedTargetIds], ['lobby']); + assert.equal(context.workspace.inspectedServerId, ''); + assert.equal(context.window.location.hash, '#workspace/overview'); + assert.deepEqual(context.calls.filter(call => call[0] === 'setActiveTab').at(-1), ['setActiveTab', 'overview', undefined]); +}); + +test('ordinaryTargetIds accepts only the readable selected source, including global proxy tools', () => { + const context = harness(); + context.registryAvailable = true; + context.nodeIndex.set('backend', backend('backend')); + context.nodeIndex.set('proxy', proxy('proxy')); + context.workspace.setTargets(['backend']); + context.selectedServerId = 'backend'; + assert.deepEqual([...run(context, 'ordinaryTargetIds()')], ['backend']); + + context.workspace.setTargets(['other']); + assert.deepEqual([...run(context, 'ordinaryTargetIds()')], [], 'out-of-scope source is rejected'); + context.workspace.enterGlobal(); + assert.deepEqual([...run(context, 'ordinaryTargetIds()')], [], 'global backend source is rejected'); + context.selectedServerId = 'proxy'; + assert.deepEqual([...run(context, 'ordinaryTargetIds()')], ['proxy']); + context.nodeIndex.set('proxy', proxy('proxy', false)); + assert.deepEqual([...run(context, 'ordinaryTargetIds()')], [], 'offline source is rejected'); + context.registryAvailable = false; + assert.deepEqual([...run(context, 'ordinaryTargetIds()')], [], 'unavailable registry is rejected'); +}); + +test('comparisonTargetIds keeps only selected readable online Bukkit backends', () => { + const context = harness(); + context.workspace.setTargets(['readable', 'offline', 'proxy', 'unsupported', 'missing']); + context.nodeIndex.set('readable', backend('readable')); + context.nodeIndex.set('offline', backend('offline', false)); + context.nodeIndex.set('proxy', proxy('proxy')); + context.nodeIndex.set('unsupported', backend('unsupported')); + context.nodeCapabilities.set('readable', ['config.files.v1']); + context.nodeCapabilities.set('offline', ['config.files.v1']); + context.nodeCapabilities.set('proxy', ['config.files.v1']); + context.nodeCapabilities.set('unsupported', ['config.quick-setup.v1']); + assert.deepEqual([...run(context, 'comparisonTargetIds()')], ['readable']); +}); + +test('workspace target changes, inspection, and scope overview preserve workspace without operations', () => { + const context = harness(); + context.nodeIndex.set('one', backend('one')); + context.nodeIndex.set('two', backend('two')); + context.workspace.setTargets(['one', 'two']); + context.selectedServerId = 'one'; + run(context, 'changeWorkspaceTargets(() => workspace.toggleTarget("two"))'); + assert.deepEqual([...context.workspace.selectedTargetIds], ['one']); + assert.equal(context.selectedServerId, 'one'); + run(context, 'inspectWorkspaceServer("one")'); + assert.equal(context.workspace.inspectedServerId, 'one'); + assert.deepEqual([...context.workspace.selectedTargetIds], ['one']); + run(context, 'openScopeOverview()'); + assert.equal(context.workspace.inspectedServerId, 'one'); + assert.deepEqual([...context.workspace.selectedTargetIds], ['one']); + assert.deepEqual(context.calls.filter(call => call[0] === 'setActiveTab'), [ + ['setActiveTab', 'overview', true], ['setActiveTab', 'overview', true] + ]); + assert.equal(context.calls.some(call => /preview|apply/i.test(call[0])), false); +}); + +test('Rewards drafts require confirmation before workspace target or Global scope changes', () => { + const context = harness(); + context.authenticated = true; + context.nodeIndex.set('one', backend('one')); + context.nodeIndex.set('two', backend('two')); + context.workspace.setTargets(['one', 'two']); + context.selectedServerId = 'one'; + context.rewardsEditor = {edit: {operation: 'APPEND_LIST_ENTRY', field: 'Commands', value: 'say staged'}}; + const prompts = []; + context.window.confirm = message => { prompts.push(message); return false; }; + assert.equal(run(context, 'changeWorkspaceTargets(() => workspace.toggleTarget("two"))'), false); + assert.deepEqual([...context.workspace.selectedTargetIds], ['one', 'two']); + run(context, 'enterGlobalWorkspace()'); + assert.equal(context.workspace.managementScope, 'MULTI_SERVER'); + context.activeNavigationHash = '#workspace/overview'; + context.window.location.hash = '#global/network'; + run(context, 'applyNavigationRoute()'); + assert.equal(context.workspace.managementScope, 'MULTI_SERVER'); + assert.equal(context.window.location.hash, '#workspace/overview'); + assert.equal(prompts.length, 3); + assert.ok(prompts.every(message => message.includes('Rewards edits'))); + assert.equal(context.calls.some(call => call[0] === 'reset'), false); +}); + +test('applyAuthenticatedSession resets workspace and opens Home without preview or apply work', () => { + const context = harness(); + context.workspace.setTargets(['one']).inspect('one').enterGlobal(); + run(context, 'applyAuthenticatedSession({csrfToken: "csrf"})'); + assert.equal(context.authenticated, true); + assert.equal(context.csrfToken, 'csrf'); + assert.equal(context.workspace.managementScope, null); + assert.deepEqual([...context.workspace.selectedTargetIds], []); + assert.equal(context.workspace.inspectedServerId, ''); + assert.deepEqual(context.calls.filter(call => call[0] === 'replaceState'), [['replaceState', null, '', '#home']]); + assert.deepEqual(context.calls.filter(call => call[0] === 'setActiveTab'), [['setActiveTab', 'home', undefined]]); + assert.equal(context.calls.some(call => /preview|apply/i.test(call[0])), false); +}); + +test('nodeCard keeps offline Bukkit nodes selectable, but disables proxy and unknown platforms', () => { + const context = harness(); + const offline = run(context, 'nodeCard({nodeId: "offline", displayName: "Offline", platform: "BUKKIT", online: false, acceptedCapabilities: []})'); + const proxyCard = run(context, 'nodeCard({nodeId: "proxy", displayName: "Proxy", platform: "VELOCITY", online: true, acceptedCapabilities: [], backends: []})'); + const unknownCard = run(context, 'nodeCard({nodeId: "unknown", displayName: "Unknown", platform: "MYSTERY", online: true, acceptedCapabilities: []})'); + const checkbox = card => card.find(node => node.tagName === 'input'); + assert.equal(checkbox(offline).disabled, false); + assert.equal(offline.find(node => node.textContent === 'Control disconnected')?.textContent, 'Control disconnected'); + assert.equal(checkbox(proxyCard).disabled, true); + assert.equal(checkbox(unknownCard).disabled, true); + for (const card of [offline, proxyCard, unknownCard]) { + assert.equal(card.find(node => node.textContent === 'No supported management capability reported. Configuration tools are unavailable.')?.textContent, + 'No supported management capability reported. Configuration tools are unavailable.'); + } +}); + +test('back and forward scope navigation retains targets, maps presets, and restores a cancelled hash', () => { + const context = harness(); + context.authenticated = true; + context.workspace.setTargets(['one', 'two']); + context.selectedServerId = 'one'; + context.nodeIndex.set('one', backend('one')); + context.nodeIndex.set('two', backend('two')); + context.nodeIndex.set('proxy', proxy('proxy')); + context.allNodeItems.push(context.nodeIndex.get('one'), context.nodeIndex.get('two'), context.nodeIndex.get('proxy')); + for (const hash of ['#workspace/overview', '#servers/two/overview', '#global/network', '#servers/two/overview']) { + context.window.location.hash = hash; + run(context, 'applyNavigationRoute()'); + } + assert.deepEqual([...context.workspace.selectedTargetIds], ['one', 'two']); + assert.equal(context.workspace.managementScope, 'MULTI_SERVER'); + assert.equal(context.workspace.inspectedServerId, 'two'); + context.window.location.hash = '#workspace/settings'; + run(context, 'applyNavigationRoute()'); + assert.equal(context.calls.at(-1)[1], 'general-settings'); + context.window.location.hash = '#workspace/vote-sites'; + run(context, 'applyNavigationRoute()'); + assert.equal(context.calls.at(-1)[1], 'vote-sites'); + context.window.location.hash = '#global/synchronization'; + run(context, 'applyNavigationRoute()'); + assert.equal(context.quickPreset.value, 'sync-vote-sites'); + context.activeNavigationHash = '#global/network'; + context.confirmDiscardUnsavedConfiguration = () => false; + context.window.location.hash = '#workspace/overview'; + run(context, 'applyNavigationRoute()'); + assert.deepEqual(context.calls.filter(call => call[0] === 'replaceState').at(-1), + ['replaceState', null, '', '#global/network']); +}); + +test('source-only preview rejects a multi-target request before posting an operation', async () => { + const context = harness(); + context.registryAvailable = true; + context.workspace.setTargets(['one', 'two']); + context.selectedServerId = 'one'; + context.nodeIndex.set('one', backend('one')); + await assert.rejects(() => run(context, + 'startConfigurationOperation("/api/v1/operations/preview", {nodeIds: ["one", "two"], configuration: {preset: "common-settings"}})'), + /Choose a workspace source server/); + assert.equal(context.configurationOperationsInFlight, 0); + assert.equal(context.calls.some(call => /preview|apply/i.test(call[0])), false); +}); + +test('opening workspace overview replaces an inspected view with aggregate content, without changing targets', () => { + const context = harness(); + const source = {nodeId: 'one', displayName: 'One', platform: 'BUKKIT', online: true, acceptedCapabilities: []}; + context.nodeIndex.set('one', source); + context.nodeIndex.set('two', {...source, nodeId: 'two', displayName: 'Two'}); + context.workspace.setTargets(['one', 'two']).inspect('two'); + context.selectedServerId = 'one'; + context.operationHistoryItems = []; + context.dashboardContext = () => 'not-loaded'; + const activitySection = {hidden: false}; + context.voteActivity = {closest: () => activitySection}; + run(context, ['isWorkspaceOverview', 'overviewOperations', 'renderScopeOverview'].map(declaration).join('\n')); + run(context, 'renderScopeOverview()'); + assert.equal(context.document.querySelector('#individual-overview').hidden, false); + context.authenticated = true; + context.navigationButtons = []; + context.tabButtons = []; + context.tabPanels = [{dataset: {panel: 'home'}}, {dataset: {panel: 'overview'}}]; + context.renderWorkspaceChrome = () => {}; + context.renderOverviewActivity = () => {}; + context.closeSidebar = () => {}; + context.updateHeaderAction = () => {}; + context.autoLoadTab = () => {}; + context.window.history.pushState = (_, __, hash) => { context.window.location.hash = hash; }; + run(context, ['workspaceHash', 'setActiveTab'].map(declaration).join('\n')); + run(context, 'openScopeOverview()'); + assert.equal(context.document.querySelector('#individual-overview').hidden, true); + assert.equal(context.document.querySelector('#workspace-overview-targets').hidden, false); + assert.equal(activitySection.hidden, true); + assert.deepEqual([...context.workspace.selectedTargetIds], ['one', 'two']); + assert.equal(context.workspace.inspectedServerId, ''); + assert.equal(context.window.location.hash, '#workspace/overview'); +}); + +test('cancelled server selection restores its checkbox and leaves workspace unchanged', () => { + const context = harness(); + context.workspace.setTargets(['one']); + context.confirmDiscardUnsavedConfiguration = () => false; + const card = run(context, 'nodeCard({nodeId: "two", displayName: "Two", platform: "BUKKIT", online: true, acceptedCapabilities: []})'); + const checkbox = card.find(node => node.tagName === 'input'); + checkbox.checked = true; + checkbox.listeners.get('change')(); + assert.equal(checkbox.checked, false); + assert.deepEqual([...context.workspace.selectedTargetIds], ['one']); +}); + +test('source-only form and explicit coordinated previews preserve existing operation API', async () => { + const context = harness(); + context.registryAvailable = true; + context.workspace.setTargets(['one', 'two']); + context.selectedServerId = 'one'; + context.nodeIndex.set('one', backend('one')); + context.autoLoadPending.clear(); + context.authorized = async (url, options) => { + context.calls.push(['post', url, JSON.parse(options.body)]); + return {operationId: 'preview'}; + }; + context.waitForOperation = async operation => operation; + for (const [preset, nodeIds] of [['common-settings', ['one']], ['proxy-method', ['one', 'two']], ['sync-vote-sites', ['one', 'two']]]) { + const result = await run(context, `startConfigurationOperation('/api/v1/configuration/preview', ${JSON.stringify({nodeIds, configuration: {preset}})})`); + assert.equal(result.operationId, 'preview'); + } + assert.equal(context.calls.filter(call => call[0] === 'post').length, 3); + assert.equal(context.configurationOperationsInFlight, 0); +}); diff --git a/src/test/web/authentication-state.test.cjs b/src/test/web/authentication-state.test.cjs new file mode 100644 index 00000000..5395b47f --- /dev/null +++ b/src/test/web/authentication-state.test.cjs @@ -0,0 +1,116 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); + +const appSource = fs.readFileSync(path.join(__dirname, '../../main/resources/web/app.js'), 'utf8'); + +function declaration(name) { + const match = new RegExp(`(?:^|\\n)(?:async )?function ${name}\\(`, 'm').exec(appSource); + const start = match ? match.index + (match[0].startsWith('\\n') ? 1 : 0) : -1; + assert.notEqual(start, -1, `app.js declares ${name}`); + const signatureEnd = appSource.indexOf(')', start); + const open = appSource.indexOf('{', signatureEnd); + let depth = 0; + let quote = ''; + for (let index = open; index < appSource.length; index++) { + const character = appSource[index]; + if (quote) { + if (character === '\\') index++; + else if (character === quote) quote = ''; + continue; + } + if (character === '\'' || character === '"' || character === '`') quote = character; + else if (character === '{') depth++; + else if (character === '}' && --depth === 0) return appSource.slice(start, index + 1); + } + throw new Error(`Could not extract ${name}`); +} + +class Element { + constructor() { this.value = ''; this.checked = false; this.hidden = false; this.open = false; this.children = []; this.classList = {add() {}}; } + reset() { this.resetCalled = true; this.value = ''; this.checked = false; } + close() { this.closeCalled = true; this.open = false; } + replaceChildren(...children) { this.children = children; } + get firstElementChild() { return this.children[0] || new Element(); } +} + +function harness() { + const elements = new Map(); + const element = selector => { + if (!elements.has(selector)) elements.set(selector, new Element()); + return elements.get(selector); + }; + const context = { + settingsEditor: {clear() {}}, voteSitesEditor: {clear() {}}, rewardsEditor: {clear() {}}, + workspace: {logout() {}}, registryAvailable: true, authenticationGeneration: 3, authenticated: true, csrfToken: 'csrf', + approvedPreview: {}, approvedFilePreview: {}, approvedQuickPreview: {}, loadedQuickSetup: {}, quickSetupDirty: true, + inputGeneration: 2, logout: new Element(), sidebarToggle: new Element(), globalSearch: new Element(), + globalSearchInput: new Element(), globalSearchOptions: new Element(), headerAction: new Element(), + appShell: new Element(), serverPickerLabel: new Element(), welcome: new Element(), authCard: new Element(), + setupRequired: false, enrollmentCard: new Element(), enrollmentCredential: new Element(), enrollmentList: new Element(), + enrollmentMessage: new Element(), selectedNodes: new Set(['server']), voteSitesSourceId: 'server', + voteSitesTargetIds: new Set(['server']), voteSitesTargetsInitialized: true, transportTestProxyId: 'proxy', + transportTestBackendId: 'backend', proxyMethodProxyId: 'proxy', proxyMethodCurrentFor: 'proxy', + proxyMethodCurrentSessionId: 'session', proxyMethodCurrentReadCapability: 'capability', proxyMethodCurrentValue: 'HTTP', + fileReadCache: new Map([['read', {}]]), lastFileReadOperation: {}, lastDiagnostics: {}, lastOverview: {}, + dashboardOverview: {}, dashboardVoteSiteHealth: {}, dashboardVoteSummary24h: {}, dashboardVoteSummary30d: {}, + dashboardLoadedContext: 'context', dashboardInspectionStatus: {}, dashboardTopologySignature: 'topology', + operationHistoryItems: [{}], deploymentHistoryItems: [{}], deploymentJar: new Element(), deploymentRunGeneration: 0, + deploymentInFlight: true, deploymentStatus: new Element(), observedServerConfigurationGeneration: 1, + operationHistoryStatus: 'loaded', enrollmentStatus: 'loaded', dedicatedSetupApprovals: new Set(['approval']), + voteLoggingRestartPending: new Set(['restart']), pendingDetectedVoteSite: {}, selectedServerId: 'server', + visibleNodeItems: [{}], allNodeItems: [{}], enrollmentIds: new Set(['server']), enrollmentsLoaded: true, + backendTopologyTruncated: true, backendTopologyTruncatedNodeIds: new Set(['server']), nodeIndex: new Map([['server', {}]]), + nodeCapabilities: new Map([['server', []]]), nodePlugins: new Map([['server', []]]), configurationForm: new Element(), + fileConfigurationForm: new Element(), configurationFileSelection: 'VoteSites.yml', configurationFile: new Element(), + configurationContentPresent: true, configurationDirty: true, configurationDraftNodeId: 'server', + configurationDraftSessionId: 'session', configurationDraftFileName: 'VoteSites.yml', routingDirty: true, + routingDraftNodeId: 'server', autoLoadInFlight: new Set(['vote-sites']), autoLoadPending: new Set(['rewards']), + quickSetupForm: new Element(), rewardSimulationForm: new Element(), playerLookupForm: new Element(), + voteLogForm: new Element(), voteTraceForm: new Element(), siteResolutionForm: new Element(), snapshotForm: new Element(), + rewardSiteLabel: new Element(), copyRewardToSetup: new Element(), voteLogFilter: new Element(), quickCommandSuggestions: new Element(), + detectedPlugins: new Element(), operationStatus: new Element(), fileOperationStatus: new Element(), quickOperationStatus: new Element(), + transportTestStatus: new Element(), proxyMethodStatus: new Element(), networkDoctorResults: new Element(), dataOverview: new Element(), + playerResult: new Element(), siteHealthResult: new Element(), voteLogSummaryResult: new Element(), voteLogResult: new Element(), + voteTraceResult: new Element(), siteResolutionResult: new Element(), rewardSimulationResult: new Element(), driftResults: new Element(), + snapshotList: new Element(), snapshotStatus: new Element(), nodes: new Element(), serverPicker: new Element(), message: new Element(), + document: {createElement: () => new Element(), querySelector: element}, + emptyDashboardInspectionStatus: () => ({}), syncTopbarOffset() {}, closeSidebar() {}, updateQuickFields() {}, resetDedicatedSetupValues() {}, + renderOperationHistory() {}, renderMetrics() {}, renderTopology() {}, renderSelectedServer() {}, + updateConfigurationButtons() {}, updateExtendedButtons() {}, text(target, value) { target.textContent = value; } + }; + vm.createContext(context); + vm.runInContext([declaration('clearSessionRewardFileOptions'), declaration('discardAuthenticationState')].join('\n'), + context, {filename: 'authentication-state.js'}); + return {context, element}; +} + +test('sign-out and session expiry share teardown that clears Vote Sites and Rewards DOM drafts', () => { + assert.match(declaration('authorized'), /response\.status === 401[\s\S]*discardAuthenticationState\('Session expired\. Sign in again\.'/); + assert.match(appSource, /authorized\('\/api\/v1\/auth\/logout', \{method: 'POST'\}\)[\s\S]*discardAuthenticationState\('Signed out\.'/); + for (const reason of ['Signed out.', 'Session expired. Sign in again.']) { + const {context, element} = harness(); + const dialog = element('#vote-site-add-dialog'); dialog.open = true; + element('#vote-site-search').value = 'draft site'; element('#vote-site-filter').value = 'partial'; element('#vote-site-ack').checked = true; + element('#rewards-search').value = 'draft reward'; element('#rewards-operation').value = 'REPLACE_LIST'; + element('#rewards-field').value = 'Money'; element('#rewards-value').value = 'give player 100'; element('#rewards-ack').checked = true; + const staticOption = {value: 'Config.yml', dataset: {}, remove() { throw new Error('static file removed'); }}; + const dynamicOption = {value: 'Rewards/Private.yml', dataset: {sessionRewardFile: 'true'}, + remove() { context.configurationFile.options = context.configurationFile.options.filter(option => option !== this); }}; + context.configurationFile.options = [staticOption, dynamicOption]; + context.configurationFile.value = dynamicOption.value; + vm.runInContext(`discardAuthenticationState(${JSON.stringify(reason)})`, context); + assert.equal(element('#vote-site-form').resetCalled, true); + assert.equal(element('#vote-site-add-form').resetCalled, true); + assert.equal(dialog.closeCalled, true); + assert.equal(element('#vote-site-search').value, ''); assert.equal(element('#vote-site-filter').value, 'all'); + assert.equal(element('#vote-site-ack').checked, false); + assert.equal(element('#rewards-search').value, ''); assert.equal(element('#rewards-operation').value, 'APPEND_LIST_ENTRY'); + assert.equal(element('#rewards-field').value, 'Commands'); assert.equal(element('#rewards-value').value, ''); + assert.equal(element('#rewards-ack').checked, false); + assert.deepEqual(context.configurationFile.options, [staticOption]); + assert.equal(context.configurationFile.value, 'Config.yml'); + } +}); diff --git a/src/test/web/configuration-state.test.cjs b/src/test/web/configuration-state.test.cjs new file mode 100644 index 00000000..8ba4e53d --- /dev/null +++ b/src/test/web/configuration-state.test.cjs @@ -0,0 +1,97 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const {MultiTargetState} = require('../../main/resources/web/configuration-state.js'); + +const FIELD = 'VoteParty.Enabled'; +const read = (status, revision, sessionId, fieldStatus = 'AVAILABLE', value = false, extra = {}) => ({ + status, revision, sessionId, fields: {[FIELD]: {status: fieldStatus, value}}, ...extra +}); + +test('aggregates mixed values while retaining partial support and explicit unsupported/read errors', () => { + const state = new MultiTargetState([{id: 'a', sessionId: '1'}, {id: 'b', sessionId: '1'}, + {id: 'c', sessionId: '1'}, {id: 'd', sessionId: '1'}]); + state.setRead('a', read('AVAILABLE', '1', '1', 'AVAILABLE', true)); + state.setRead('b', read('AVAILABLE', '1', '1', 'AVAILABLE', false)); + state.setRead('c', read('AVAILABLE', '1', '1', 'UNSUPPORTED')); + assert.deepEqual(state.aggregate(FIELD), { + state: 'UNSUPPORTED', supportedState: 'MIXED', value: undefined, + targets: [ + {id: 'a', status: 'AVAILABLE', value: true, revision: '1', sessionId: '1', code: '', message: ''}, + {id: 'b', status: 'AVAILABLE', value: false, revision: '1', sessionId: '1', code: '', message: ''}, + {id: 'c', status: 'UNSUPPORTED', value: undefined, revision: '1', sessionId: '1', code: '', message: ''}, + {id: 'd', status: 'MISSING', value: undefined, revision: '', sessionId: '1', code: '', message: ''} + ] + }); + state.setRead('d', read('ERROR', '', '1', 'MISSING', false, {code: 'OFFLINE', message: 'not reachable'})); + assert.equal(state.aggregate(FIELD).state, 'ERROR'); + assert.equal(state.aggregate(FIELD).supportedState, 'MIXED'); +}); + +test('plans are dirty-only and preserve distinct target values, missing fields, and no-change targets', () => { + const state = new MultiTargetState([{id: 'a', sessionId: 's'}, {id: 'b', sessionId: 's'}, {id: 'c', sessionId: 's'}]); + state.setRead('a', read('AVAILABLE', 'r1', 's', 'AVAILABLE', false)); + state.setRead('b', read('AVAILABLE', 'r2', 's', 'AVAILABLE', true)); + state.setRead('c', read('AVAILABLE', 'r3', 's', 'MISSING')); + state.edit(FIELD, true); + assert.deepEqual(state.plans(), [ + {id: 'a', revision: 'r1', sessionId: 's', overrides: {[FIELD]: true}, skipped: [], status: 'AVAILABLE'}, + {id: 'b', revision: 'r2', sessionId: 's', overrides: {}, skipped: [], status: 'AVAILABLE'}, + {id: 'c', revision: 'r3', sessionId: 's', overrides: {}, skipped: [{field: FIELD, status: 'MISSING'}], status: 'AVAILABLE'} + ]); + state.setRead('c', read('UNSUPPORTED', '', 's')); + assert.equal(state.plans()[2].skipped[0].status, 'UNSUPPORTED'); +}); + +test('signature and preview reject stale edits, revisions, resets, and target session changes', () => { + const state = new MultiTargetState([{id: 'a', sessionId: 'one'}]); + state.setRead('a', read('AVAILABLE', 'r1', 'one')); + const signature = state.beginPreview(); + assert.equal(state.finishPreview(signature, [{id: 'a'}]), true); + state.setRead('a', read('AVAILABLE', 'r1', 'one', 'AVAILABLE', true)); + assert.equal(state.preview, null); + state.edit(FIELD, true); + assert.equal(state.previewCurrent(), null); + assert.equal(state.finishPreview(signature, []), false); + const next = state.beginPreview(); + state.reset(FIELD); + assert.equal(state.finishPreview(next, []), false); + const revisionPreview = state.beginPreview(); + state.setRead('a', read('AVAILABLE', 'r2', 'one')); + assert.equal(state.preview, null); + assert.equal(state.finishPreview(revisionPreview, []), false); + state.setTargets([{id: 'a', sessionId: 'two'}]); + assert.equal(state.targets.get('a').revision, ''); + assert.equal(state.dirty.size, 0); +}); + +test('rejects non-boolean edits and clears prior results when a target session changes', () => { + const state = new MultiTargetState([{id: 'a', sessionId: 'one'}]); + state.edit(FIELD, 'true'); + assert.equal(state.dirty.size, 0); + state.setApplyResult('a', {success: true}); + state.setTargets([{id: 'a', sessionId: 'two'}]); + assert.equal(state.results.size, 0); +}); + +test('confirmed reads only clear dirty fields when every selected target confirms requested value', () => { + const state = new MultiTargetState([{id: 'a', sessionId: '1'}, {id: 'b', sessionId: '1'}]); + state.setRead('a', read('AVAILABLE', 'r', '1', 'AVAILABLE', true)); + state.setRead('b', read('ERROR', '', '1')); + state.edit(FIELD, true).clearConfirmedDirty(); + assert.equal(state.dirty.get(FIELD), true); + state.setRead('b', read('AVAILABLE', 'r', '1', 'AVAILABLE', true)); + state.clearConfirmedDirty(); + assert.equal(state.dirty.has(FIELD), false); +}); + +test('single target use, apply results, and zero-change preview work without source documents', () => { + const state = new MultiTargetState({targets: [{id: 'only', sessionId: 's'}]}); + state.setRead('only', read('AVAILABLE', 'r', 's', 'AVAILABLE', false)); + assert.deepEqual(state.aggregate(FIELD).state, 'SAME'); + assert.equal(state.plans()[0].overrides[FIELD], undefined); + const signature = state.beginPreview(); + assert.equal(state.finishPreview(signature, [{id: 'only', changes: []}]), true); + assert.equal(state.previewCurrent().items[0].id, 'only'); + state.setApplyResult('only', {status: 'APPLIED'}); + assert.deepEqual(state.results.get('only'), {status: 'APPLIED'}); +}); diff --git a/src/test/web/general-settings.test.cjs b/src/test/web/general-settings.test.cjs new file mode 100644 index 00000000..d86b2b65 --- /dev/null +++ b/src/test/web/general-settings.test.cjs @@ -0,0 +1,252 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const {create} = require('../../main/resources/web/general-settings.js'); +const FIELD = 'ProcessRewards'; + +function deferred() { let resolve; const promise = new Promise(r => { resolve = r; }); return {promise, resolve}; } +function fixture(targets = [{id: 'a', sessionId: 's1', online: true, supported: true}]) { + const calls = []; let version = 1; let context = 'auth|scope|a:s1'; + const adapter = {targets: () => targets, context: () => context, active: () => true, changed() {}, + operation: async (path, body) => { calls.push([path, body]); + if (path.endsWith('/read')) return {operationId: 'read-' + version, results: Object.fromEntries(body.nodeIds.map(id => { + const target = targets.find(candidate => candidate.id === id); + return [id, {success: true, nodeId: id, sessionId: target.sessionId, revision: 'r' + version}]; + }))}; + if (path.endsWith('/preview')) return {operationId: 'preview-' + body.nodeId, state: 'SUCCEEDED', approvalToken: 'token-' + body.nodeId, results: {[body.nodeId]: {success: true, revision: 'r' + version}}}; + return {operationId: 'apply-' + body.previewOperationId, results: {a: {success: true}}}; + }, request: async (_path, body) => ({readOperationId: body.readOperationId, nodeId: body.nodeId, sessionId: targets.find(t => t.id === body.nodeId).sessionId, + revision: 'r' + version, fields: {[FIELD]: {status: 'AVAILABLE', value: false}}})}; + return {adapter, calls, setContext: value => { context = value; }, bump: () => { version++; }}; +} + +test('reads eligible targets once, retains typed snapshots, and makes no automatic write', async () => { + const f = fixture([{id: 'a', sessionId: 's1', online: true, supported: true}, {id: 'b', sessionId: 's2', online: false, supported: true}]); + const editor = create(f.adapter); await editor.scopeChanged(); await editor.read(); + assert.equal(f.calls.filter(c => c[0].endsWith('/read')).length, 1); + assert.equal(f.calls.some(c => c[0].endsWith('/preview') || c[0].endsWith('/apply')), false); + assert.equal(editor.model.targets.get('b').status, 'ERROR'); +}); + +test('single-flight reads ignore a late result from an old context', async () => { + const f = fixture(); const gate = deferred(); + f.adapter.operation = async () => { await gate.promise; return {operationId: 'r', results: {a: {success: true, nodeId: 'a', sessionId: 's1', revision: 'r1'}}}; }; + const editor = create(f.adapter); const first = editor.read(); const second = editor.read(); + assert.equal(first, second); f.setContext('new-context'); gate.resolve(); await first; + assert.equal(editor.model.targets.get('a').status, 'MISSING'); +}); + +test('previews only per-target differences and requires acknowledgement for unsupported exclusions', async () => { + const f = fixture([{id: 'a', sessionId: 's1', online: true, supported: true}, {id: 'b', sessionId: 's2', online: true, supported: false}]); + const editor = create(f.adapter); await editor.read(); editor.edit(FIELD, true); await editor.preview(); + assert.equal(f.calls.filter(c => c[0].endsWith('/preview')).length, 1); + assert.equal(editor.state.ackRequired, true); + assert.equal(await editor.apply(), false); + assert.equal(await editor.apply(true), true); +}); + +test('edits invalidate previews and a forced pre-apply read rejects revision changes', async () => { + const f = fixture(); const editor = create(f.adapter); await editor.read(); editor.edit(FIELD, true); await editor.preview(); + editor.edit(FIELD, false); assert.equal(editor.state.previewState, 'Stale'); + editor.edit(FIELD, true); await editor.preview(); f.bump(); + assert.equal(await editor.apply(), false); + assert.equal(f.calls.filter(c => c[0].endsWith('/apply')).length, 0); +}); + +test('partial apply results are retained and confirmed reads clear only confirmed dirty values', async () => { + const f = fixture([{id: 'a', sessionId: 's1', online: true, supported: true}, {id: 'b', sessionId: 's2', online: true, supported: true}]); + const editor = create(f.adapter); await editor.read(); editor.edit(FIELD, true); await editor.preview(); + f.adapter.operation = async (path, body) => { + f.calls.push([path, body]); + if (path.endsWith('/read')) return {operationId: 'read', results: Object.fromEntries(body.nodeIds.map(id => { + const target = f.adapter.targets().find(candidate => candidate.id === id); + return [id, {success: true, nodeId: id, sessionId: target.sessionId, revision: 'r1'}]; + }))}; + if (path.endsWith('/preview')) return {operationId: 'p-' + body.nodeId, state: 'SUCCEEDED', approvalToken: 't', results: {[body.nodeId]: {success: true, revision: 'r1'}}}; + if (body.previewOperationId === 'preview-b') throw new Error('b failed'); + return {operationId: 'a', results: {a: {success: true}}}; + }; + await editor.apply(); + assert.equal(editor.model.results.get('b').status, 'ERROR'); + assert.equal(editor.model.dirty.get(FIELD), true); +}); + +test('same-context scope refresh retains cache and preview, while inactive navigation does no read or busy stall', async () => { + const f = fixture(); const editor = create(f.adapter); + await editor.read(); editor.edit(FIELD, true); await editor.preview(); + const reads = f.calls.filter(c => c[0].endsWith('/read')).length; + await editor.scopeChanged(); + assert.equal(f.calls.filter(c => c[0].endsWith('/read')).length, reads); + assert.equal(editor.state.previewState, 'Ready'); + f.adapter.active = () => false; + await editor.scopeChanged(); + assert.equal(editor.state.busy, false); + assert.equal(f.calls.filter(c => c[0].endsWith('/read')).length, reads); +}); + +test('a forced read queued behind an existing read performs a fresh second read', async () => { + const f = fixture(); const gate = deferred(); let calls = 0; + f.adapter.operation = async (path, body) => { + if (!path.endsWith('/read')) return {operationId: 'p', state: 'SUCCEEDED', approvalToken: 't', results: {a: {success: true, revision: 'r1'}}}; + calls++; if (calls === 1) await gate.promise; + return {operationId: 'read-' + calls, results: Object.fromEntries(body.nodeIds.map(id => [id, {success: true, nodeId: id, sessionId: 's1', revision: 'r1'}]))}; + }; + const editor = create(f.adapter); const first = editor.read(); const forced = editor.read(true); + gate.resolve(); await Promise.all([first, forced]); + assert.equal(calls, 2); +}); + +test('concurrent forced reads share one queued refresh', async () => { + const f = fixture(); const gate = deferred(); let calls = 0; + const original = f.adapter.operation; + f.adapter.operation = async (path, body) => { + if (path.endsWith('/read')) { calls++; if (calls === 1) await gate.promise; } + return original(path, body); + }; + const editor = create(f.adapter); const first = editor.read(); + const forced = editor.read(true); const another = editor.read(true); const retry = editor.read(false, true); + assert.equal(forced, another); assert.equal(forced, retry); + gate.resolve(); await Promise.all([first, forced, another, retry]); + assert.equal(calls, 2); + assert.equal(f.calls.filter(([path]) => path.endsWith('/read')).length, 2); +}); + +test('failed previews remain visible and cannot create approval', async () => { + const f = fixture(); const editor = create(f.adapter); await editor.read(); editor.edit(FIELD, true); + f.adapter.operation = async (path, body) => path.endsWith('/preview') + ? {operationId: 'broken', state: 'SUCCEEDED', approvalToken: 't', results: {a: {success: false, revision: 'r1'}}} + : {operationId: 'read', results: Object.fromEntries(body.nodeIds.map(id => [id, {success: true, nodeId: id, sessionId: 's1', revision: 'r1'}]))}; + assert.equal(await editor.preview(), false); + assert.equal(editor.state.previewItems[0].status, 'ERROR'); + assert.equal(await editor.apply(), false); +}); + +test('invalidating reads from an apply callback does not interrupt captured sequential approvals', async () => { + const f = fixture([{id: 'a', sessionId: 's1', online: true, supported: true}, {id: 'b', sessionId: 's2', online: true, supported: true}]); + const editor = create(f.adapter); await editor.read(); editor.edit(FIELD, true); await editor.preview(); + const applied = []; + f.adapter.operation = async (path, body) => { + if (path.endsWith('/read')) return {operationId: 'read', results: Object.fromEntries(body.nodeIds.map(id => { + const target = f.adapter.targets().find(candidate => candidate.id === id); + return [id, {success: true, nodeId: id, sessionId: target.sessionId, revision: 'r1'}]; + }))}; + if (path.endsWith('/apply')) { applied.push(body.previewOperationId); editor.invalidateReads(); return {operationId: 'apply', results: {[body.previewOperationId.slice(-1)]: {success: true}}}; } + return {operationId: 'p', state: 'SUCCEEDED', approvalToken: 't', results: {[body.nodeId]: {success: true, revision: 'r1'}}}; + }; + await editor.apply(); + assert.deepEqual(applied, ['preview-a', 'preview-b']); +}); + +test('typed state identity mismatches fail closed as TARGET_CHANGED', async () => { + const f = fixture(); const editor = create(f.adapter); + f.adapter.request = async (_path, body) => ({readOperationId: body.readOperationId, nodeId: body.nodeId, + sessionId: 'wrong-session', revision: 'wrong-revision', fields: {[FIELD]: {status: 'AVAILABLE', value: false}}}); + await editor.read(); + const target = editor.model.targets.get('a'); + assert.equal(target.status, 'ERROR'); + assert.equal(target.code, 'TARGET_CHANGED'); +}); + +test('a late apply completion after logout cannot repopulate results', async () => { + const f = fixture(); const gate = deferred(); const editor = create(f.adapter); + await editor.read(); editor.edit(FIELD, true); await editor.preview(); + f.adapter.operation = async (path, body) => { + if (path.endsWith('/read')) return {operationId: 'read', results: {a: {success: true, nodeId: 'a', sessionId: 's1', revision: 'r1'}}}; + if (path.endsWith('/apply')) { await gate.promise; return {operationId: 'apply', results: {a: {success: true}}}; } + return {operationId: 'preview', state: 'SUCCEEDED', approvalToken: 'token', results: {a: {success: true, revision: 'r1'}}}; + }; + const applying = editor.apply(); + await Promise.resolve(); await Promise.resolve(); + f.setContext('logout'); await editor.scopeChanged(); gate.resolve(); + assert.equal(await applying, false); + assert.equal(editor.model.results.size, 0); +}); + +test('an old flight cannot clear busy while a new-context flight is pending', async () => { + const f = fixture(); const oldGate = deferred(); const newGate = deferred(); let reads = 0; + f.adapter.operation = async (_path, body) => { + reads++; + if (reads === 1) await oldGate.promise; else await newGate.promise; + return {operationId: 'read-' + reads, results: {a: {success: true, nodeId: 'a', sessionId: 's1', revision: 'r1'}}}; + }; + const editor = create(f.adapter); const oldRead = editor.read(); + f.setContext('new-scope'); await editor.scopeChanged(); const newRead = editor.read(); + oldGate.resolve(); await oldRead; + assert.equal(editor.state.busy, true); + newGate.resolve(); await newRead; + assert.equal(editor.state.busy, false); +}); + +test('a second concurrent apply is refused while the first approval is in flight', async () => { + const f = fixture(); const gate = deferred(); const editor = create(f.adapter); + await editor.read(); editor.edit(FIELD, true); await editor.preview(); + let applying = false; + f.adapter.operation = async (path, body) => { + if (path.endsWith('/read')) return {operationId: 'read', results: {a: {success: true, nodeId: 'a', sessionId: 's1', revision: 'r1'}}}; + if (path.endsWith('/apply')) { applying = true; await gate.promise; return {operationId: 'apply', results: {a: {success: true}}}; } + return {operationId: 'preview', state: 'SUCCEEDED', approvalToken: 'token', results: {a: {success: true, revision: 'r1'}}}; + }; + const first = editor.apply(); + while (!applying) await Promise.resolve(); + assert.equal(await editor.apply(), false); + gate.resolve(); await first; +}); + +test('failed automatic READ keeps structured cause visible and read-only Retry recovers', async () => { + const f = fixture(); const normal = f.adapter.operation; let fail = true; + f.adapter.operation = async (path, body) => fail && path.endsWith('/read') + ? {operationId: 'failed', results: {a: {success: false, code: 'INVALID_CONFIGURATION', message: 'Invalid YAML mapping'}}} + : normal(path, body); + const editor = create(f.adapter); await editor.read(); + assert.equal(editor.model.targets.get('a').code, 'INVALID_CONFIGURATION'); + assert.equal(editor.model.aggregate(FIELD).state, 'ERROR'); + assert.deepEqual(editor.model.targets.get('a').fields, {}); + fail = false; await editor.read(true, true); + assert.equal(editor.model.aggregate(FIELD).state, 'SAME'); + assert.equal(editor.model.aggregate(FIELD).value, false); + assert.equal(f.calls.some(([path]) => /\/(preview|apply)$/.test(path)), false); +}); + +test('confirmed successful apply refreshes values, invalidates cache and clears resolved dirty state', async () => { + const f = fixture(); let value = false; let revision = 'r1'; let reads = 0; + f.adapter.request = async (_path, body) => ({nodeId: body.nodeId, sessionId: 's1', revision, + fields: {[FIELD]: {status: 'AVAILABLE', value}}}); + f.adapter.operation = async (path, body) => { + if (path.endsWith('/read')) { reads++; return {operationId: 'read-' + reads, results: {a: {success: true, revision}}}; } + if (path.endsWith('/preview')) return {operationId: 'preview', state: 'SUCCEEDED', approvalToken: 'approved', results: {a: {success: true, revision}}}; + value = true; revision = 'r2'; + return {operationId: 'applied', state: 'SUCCEEDED', results: {a: {success: true, reloaded: true}}}; + }; + const editor = create(f.adapter); await editor.read(); editor.edit(FIELD, true); await editor.preview(); + await editor.apply(); + assert.equal(reads, 3); + assert.equal(editor.model.aggregate(FIELD).value, true); + assert.equal(editor.model.targets.get('a').revision, 'r2'); + assert.equal(editor.model.results.get('a').confirmed, true); + assert.equal(editor.model.dirty.size, 0); + assert.equal(editor.model.previewCurrent(), null); +}); + +test('unchanged targets require no subset acknowledgement and target removal invalidates approval', async () => { + const f = fixture([{id: 'a', sessionId: 's1', online: true, supported: true}, {id: 'b', sessionId: 's2', online: true, supported: true}]); + const original = f.adapter.request; + f.adapter.request = async (path, body) => { const result = await original(path, body); result.fields[FIELD].value = body.nodeId === 'b'; return result; }; + const editor = create(f.adapter); await editor.read(); editor.edit(FIELD, true); await editor.preview(); + assert.equal(editor.state.ackRequired, false); + assert.equal(editor.model.previewCurrent().items.find(item => item.id === 'b').status, 'UNCHANGED'); + f.adapter.targets = () => [{id: 'a', sessionId: 's1', online: true, supported: true}]; + f.setContext('only-a'); await editor.scopeChanged(); + assert.equal(editor.model.previewCurrent(), null); + assert.equal(await editor.apply(), false); + assert.equal(f.calls.some(([path]) => path.endsWith('/apply')), false); +}); + +test('explicit edits discard abandoned exact approvals without queuing a write', async () => { + const f = fixture(); const requests = []; const original = f.adapter.request; + f.adapter.request = async (path, body) => { requests.push([path, body]); return original(path, body); }; + const editor = create(f.adapter); await editor.read(); editor.edit(FIELD, true); await editor.preview(); + editor.reset(FIELD); await Promise.resolve(); await Promise.resolve(); + const disposal = requests.find(([path]) => path.endsWith('/discard-preview')); + assert.ok(disposal); + assert.deepEqual(disposal[1], {previewOperationId: 'preview-a', approvalToken: 'token-a'}); + assert.equal(f.calls.some(([path]) => path.endsWith('/apply')), false); +}); diff --git a/src/test/web/rewards.test.cjs b/src/test/web/rewards.test.cjs new file mode 100644 index 00000000..37c332b2 --- /dev/null +++ b/src/test/web/rewards.test.cjs @@ -0,0 +1,353 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const {create, FILES} = require('../../main/resources/web/rewards.js'); + +function fixture(nodes = [{nodeId: 'a', sessionId: 'sa', online: true}, {nodeId: 'b', sessionId: 'sb', online: true}]) { + const calls = []; let context = 'workspace'; let revision = 'r1'; let failNode = ''; let changed = new Set(); + const commands = new Map(nodes.map(node => [node.nodeId, node.nodeId === 'a' ? ['say a'] : ['say b']])); + const adapter = {targets: () => nodes, context: () => context, active: () => true, changed() {}, + operation: async (path, body) => { + calls.push([path, body]); + if (path.endsWith('/read')) return {operationId: `${body.configuration.fileName}-${revision}`, results: Object.fromEntries(body.nodeIds.map(nodeId => [nodeId, {success: nodeId !== failNode, revision, message: 'READ_FAILED'}]))}; + if (path.endsWith('/preview')) return {operationId: `p-${body.nodeId}`, state: 'SUCCEEDED', approvalToken: `token-${body.nodeId}`, results: {[body.nodeId]: {success: true, revision}}}; + if (path.endsWith('/apply')) { + const nodeId = body.previewOperationId.slice(2); changed.add(nodeId); commands.get(nodeId).push('say new'); + return {operationId: `a-${nodeId}`, state: 'SUCCEEDED', results: {[nodeId]: {success: true, reloaded: true}}}; + } + }, request: async (path, body) => { + calls.push([path, body]); if (path.endsWith('/discard-preview')) return {}; + const scope = body.fileName === 'VoteSites.yml' ? [{path: 'VoteSites.Alpha.Rewards', status: 'PRESENT', editable: true, + fields: {Commands: commands.get(body.nodeId)}, advancedKeys: ['AdvancedPriority']}] : []; + return {readOperationId: `${body.fileName}-${revision}`, nodeId: body.nodeId, fileName: body.fileName, + sessionId: nodes.find(node => node.nodeId === body.nodeId).sessionId, revision, scopes: scope}; + }}; + return {adapter, calls, nodes, commands, changed, setContext(value) { context = value; }, setRevision(value) { revision = value; }, fail(value) { failNode = value; }}; +} + +test('reads each target and each real file once, then caches without preview/apply', async () => { + const f = fixture(); const editor = create(f.adapter); await editor.read(); await editor.read(); + assert.equal(f.calls.filter(([path]) => path.endsWith('/read')).length, FILES.length); + assert.equal(f.calls.filter(([path]) => path.endsWith('/state')).length, FILES.length * 2); + assert.equal(f.calls.some(([path]) => path.endsWith('/preview') || path.endsWith('/apply')), false); +}); +test('mixed command lists stay separate and append is one exact dirty operation', async () => { + const f = fixture(); const editor = create(f.adapter); await editor.read(); editor.select('VoteSites.yml', 'VoteSites.Alpha.Rewards'); + assert.equal(editor.aggregate('Commands').status, 'MIXED'); + editor.setEdit('APPEND_LIST_ENTRY', 'Commands', 'say new'); + assert.equal(editor.plan().length, 2); assert.equal(editor.plan()[0].status, 'READY'); + assert.equal(await editor.preview(), true); + const previews = f.calls.filter(([path]) => path.endsWith('/rewards/preview')); + assert.equal(previews.length, 2); assert.equal(previews[0][1].readOperationId, 'VoteSites.yml-r1'); + assert.deepEqual(previews[0][1].value, 'say new'); assert.equal(f.calls.some(([path]) => path.endsWith('/apply')), false); + assert.equal(await editor.apply(false), true); + assert.deepEqual(f.commands.get('a'), ['say a', 'say new']); assert.deepEqual(f.commands.get('b'), ['say b', 'say new']); + assert.equal(editor.edit, null); assert.equal(editor.state.results.every(item => item.confirmed), true); +}); +test('mixed scalar and replace-list plans exclude unchanged targets before preview', async () => { + const f = fixture(); const originalRequest = f.adapter.request; + f.adapter.request = async (path, body) => { + const response = await originalRequest(path, body); + if (path.endsWith('/state') && body.fileName === 'VoteSites.yml') { + response.scopes[0].fields.Money = body.nodeId === 'a' ? '5' : '8'; + } + return response; + }; + const editor = create(f.adapter); await editor.read(); editor.select('VoteSites.yml', 'VoteSites.Alpha.Rewards'); + editor.setEdit('SET_SCALAR', 'Money', 5); + assert.deepEqual(editor.plan().map(item => item.status), ['UNCHANGED', 'READY']); + assert.equal(await editor.preview(), true); + assert.deepEqual(f.calls.filter(([path]) => path.endsWith('/rewards/preview')).map(([, body]) => body.nodeId), ['b']); + editor.setEdit('REPLACE_LIST', 'Commands', ['say a']); + assert.deepEqual(editor.plan().map(item => item.status), ['UNCHANGED', 'READY']); + assert.equal(await editor.preview(), true); + assert.deepEqual(f.calls.filter(([path]) => path.endsWith('/rewards/preview')).map(([, body]) => body.nodeId), ['b', 'b']); +}); +test('a confirmed mixed replacement clears the draft when excluded targets already match', async () => { + const f = fixture(); const originalOperation = f.adapter.operation; + f.adapter.operation = async (path, body) => { + const response = await originalOperation(path, body); + if (path.endsWith('/apply')) f.commands.set('b', ['say a']); + return response; + }; + const editor = create(f.adapter); await editor.read(); editor.select('VoteSites.yml', 'VoteSites.Alpha.Rewards'); + editor.setEdit('REPLACE_LIST', 'Commands', ['say a']); + assert.deepEqual(editor.plan().map(item => item.status), ['UNCHANGED', 'READY']); + assert.equal(await editor.preview(), true); + assert.equal(await editor.apply(true), true); + assert.equal(editor.edit, null); + assert.deepEqual(editor.state.results.map(item => [item.status, item.confirmed]), + [['UNCHANGED', true], ['APPLIED', true]]); +}); +test('edit and workspace changes dispose preview; revision and reconnect reject apply', async () => { + const f = fixture(); const editor = create(f.adapter); await editor.read(); editor.select('VoteSites.yml', 'VoteSites.Alpha.Rewards'); + editor.setEdit('APPEND_LIST_ENTRY', 'Commands', 'say new'); await editor.preview(); editor.setEdit('APPEND_LIST_ENTRY', 'Commands', 'say other'); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(f.calls.filter(([path]) => path.endsWith('/discard-preview')).length, 2); + await editor.preview(); f.setRevision('r2'); assert.equal(await editor.apply(true), false); + assert.equal(f.calls.filter(([path]) => path.endsWith('/apply')).length, 0); + await editor.read(true); await editor.preview(); f.nodes[0].sessionId = 'reconnected'; f.setContext('new-workspace'); editor.scopeChanged(); + assert.equal(editor.edit, null); assert.equal(editor.state.previewState, 'Not previewed'); +}); +test('failed READ remains explicit and partial apply never becomes green', async () => { + const f = fixture(); f.fail('b'); const editor = create(f.adapter); await editor.read(); editor.select('VoteSites.yml', 'VoteSites.Alpha.Rewards'); + editor.setEdit('APPEND_LIST_ENTRY', 'Commands', 'say new'); assert.equal(editor.plan()[1].status, 'ERROR'); + assert.equal(await editor.preview(), true); assert.equal(editor.state.ackRequired, true); + assert.equal(await editor.apply(false), false); + assert.equal(f.calls.filter(([path]) => path.endsWith('/apply')).length, 0); + assert.equal(await editor.apply(true), false); assert.equal(editor.edit.operation, 'APPEND_LIST_ENTRY'); + assert.equal(editor.state.results[0].confirmed, true); +}); +test('reload failure with rollback stays failed and never retries APPLY automatically', async () => { + const f = fixture(); const original = f.adapter.operation; + f.adapter.operation = async (path, body) => path.endsWith('/apply') && body.previewOperationId === 'p-b' + ? {operationId: 'failed-b', state: 'COMPLETED_WITH_ERRORS', + results: {b: {success: false, code: 'RELOAD_FAILED', rolledBack: true, reloaded: false}}} + : original(path, body); + const editor = create(f.adapter); await editor.read(); editor.select('VoteSites.yml', 'VoteSites.Alpha.Rewards'); + editor.setEdit('APPEND_LIST_ENTRY', 'Commands', 'say new'); assert.equal(await editor.preview(), true); + assert.equal(await editor.apply(true), false); + assert.equal(editor.state.results.find(item => item.nodeId === 'a').confirmed, true); + const failure = editor.state.results.find(item => item.nodeId === 'b'); + assert.equal(failure.confirmed, false); assert.equal(failure.result.rolledBack, true); + assert.equal(editor.edit.operation, 'APPEND_LIST_ENTRY'); + assert.equal(f.calls.filter(([path]) => path.endsWith('/apply')).length, 1); +}); +test('successful write response is not confirmation if exact appended list is absent', async () => { + const f = fixture([{nodeId: 'a', sessionId: 'sa', online: true}]); const original = f.adapter.operation; + f.adapter.operation = async (path, body) => path.endsWith('/apply') + ? {operationId: 'not-applied', state: 'SUCCEEDED', results: {a: {success: true, reloaded: true}}} + : original(path, body); + const editor = create(f.adapter); await editor.read(); editor.select('VoteSites.yml', 'VoteSites.Alpha.Rewards'); + editor.setEdit('APPEND_LIST_ENTRY', 'Commands', 'say new'); await editor.preview(); + assert.equal(await editor.apply(false), false); + assert.equal(editor.state.results[0].confirmed, false); + assert.equal(editor.edit.operation, 'APPEND_LIST_ENTRY'); +}); +test('draft controls cannot change while approved apply is in flight', async () => { + const f = fixture([{nodeId: 'a', sessionId: 'sa', online: true}]); + const original = f.adapter.operation; + let releaseApply; let enteredApply; + const applying = new Promise(resolve => { enteredApply = resolve; }); + const gate = new Promise(resolve => { releaseApply = resolve; }); + f.adapter.operation = async (path, body) => { + if (path.endsWith('/apply')) { enteredApply(); await gate; } + return original(path, body); + }; + const editor = create(f.adapter); await editor.read(); editor.select('VoteSites.yml', 'VoteSites.Alpha.Rewards'); + editor.setEdit('APPEND_LIST_ENTRY', 'Commands', 'say new'); await editor.preview(); + const pending = editor.apply(false); await applying; + editor.setEdit('APPEND_LIST_ENTRY', 'Commands', 'say different'); + editor.select('Config.yml', 'EverySiteReward'); editor.reset(); + assert.equal(await editor.preview(), false); + assert.equal(editor.selected.rewardPath, 'VoteSites.Alpha.Rewards'); + assert.equal(editor.edit.value, 'say new'); + releaseApply(); assert.equal(await pending, true); + assert.equal(editor.edit, null); +}); + +test('APPLY waits for a coalesced forced read after an in-flight cached read', async () => { + const node = {nodeId: 'a', sessionId: 'sa', online: true, rewardFilesSupported: true}; + const f = fixture([node]); + let inspections = 0; let entered; let release; + const waiting = new Promise(resolve => { entered = resolve; }); + const gate = new Promise(resolve => { release = resolve; }); + f.adapter.inspect = async () => { + if (++inspections === 2) { entered(); await gate; } + return {files: []}; + }; + const editor = create(f.adapter); await editor.read(); editor.select('VoteSites.yml', 'VoteSites.Alpha.Rewards'); + editor.setEdit('APPEND_LIST_ENTRY', 'Commands', 'say new'); assert.equal(await editor.preview(), true); + (await editor.inventory(node)).loadedAt = 0; + const cachedRead = editor.read(false); await waiting; + const applying = editor.apply(false); + const sameRefresh = editor.read(true); + assert.equal(f.calls.some(([path]) => path.endsWith('/apply')), false); + release(); + assert.equal(await applying, true); await cachedRead; await sameRefresh; + const applyIndex = f.calls.findIndex(([path]) => path.endsWith('/apply')); + assert.equal(f.calls.slice(0, applyIndex).filter(([path]) => path.endsWith('/read')).length, + FILES.length * 2, 'one forced refresh must complete before APPLY'); + assert.deepEqual(f.commands.get('a'), ['say a', 'say new']); +}); + +test('clearing a session lets a new reward read proceed without an old flight overwriting it', async () => { + const f = fixture([{nodeId: 'a', sessionId: 'sa', online: true}]); + const original = f.adapter.operation; + let entered; let release; let hold = true; + const waiting = new Promise(resolve => { entered = resolve; }); + const gate = new Promise(resolve => { release = resolve; }); + f.adapter.operation = async (path, body) => { + if (hold && path.endsWith('/read') && body.configuration.fileName === 'VoteSites.yml') { + hold = false; entered(); await gate; + } + return original(path, body); + }; + const editor = create(f.adapter); const oldRead = editor.read(); await waiting; + editor.clear(); await editor.read(); + assert.equal(editor.record('a', 'VoteSites.yml').status, 'AVAILABLE'); + release(); await oldRead; + assert.equal(editor.record('a', 'VoteSites.yml').status, 'AVAILABLE'); + assert.equal(f.calls.filter(([path, body]) => path.endsWith('/state') && body.fileName === 'VoteSites.yml').length, 1); +}); + +test('named reward inventory is bounded, per-target, and read without writes', async () => { + const nodes = [ + {nodeId: 'a', sessionId: 'sa', online: true, rewardFilesSupported: true}, + {nodeId: 'b', sessionId: 'sb', online: true, rewardFilesSupported: false}, + {nodeId: 'c', sessionId: 'sc', online: true, rewardFilesSupported: true} + ]; + const f = fixture(nodes); const inventoryCalls = []; + f.adapter.inspect = async target => { + inventoryCalls.push(target.nodeId); + if (target.nodeId === 'a') return {files: ['StandardVote.yml']}; + if (target.nodeId === 'c') return {files: ['Other.yml']}; + throw new Error('inventory unavailable'); + }; + const originalRequest = f.adapter.request; + f.adapter.request = async (path, body) => { + if (path.endsWith('/state') && String(body.fileName).startsWith('Rewards/')) { + const node = nodes.find(item => item.nodeId === body.nodeId); + return {readOperationId: `${body.fileName}-r1`, nodeId: body.nodeId, fileName: body.fileName, + sessionId: node.sessionId, revision: 'r1', scopes: [{path: '$', status: 'PRESENT', editable: true, + fields: {Commands: [`say ${body.fileName}`]}}]}; + } + return originalRequest(path, body); + }; + const editor = create(f.adapter); editor.select('Rewards/StandardVote.yml', '$'); await editor.read(); + assert.deepEqual(editor.files(), ['VoteSites.yml', 'SpecialRewards.yml', 'Config.yml', 'Rewards/StandardVote.yml', 'Rewards/Other.yml']); + assert.deepEqual(inventoryCalls.sort(), ['a', 'c']); + assert.equal(editor.record('a', 'Rewards/StandardVote.yml').status, 'AVAILABLE'); + assert.equal(editor.record('c', 'Rewards/StandardVote.yml').status, 'MISSING'); + assert.equal(editor.record('b', 'Rewards/StandardVote.yml').status, 'UNSUPPORTED'); + assert.equal(editor.record('c', 'Rewards/Other.yml').status, 'UNLOADED'); + assert.equal(f.calls.filter(([path]) => path.endsWith('/preview') || path.endsWith('/apply')).length, 0); + const namedReads = f.calls.filter(([path, body]) => path.endsWith('/read') && String(body.configuration.fileName).startsWith('Rewards/')); + assert.deepEqual(namedReads.map(([, body]) => body.configuration.fileName).sort(), ['Rewards/StandardVote.yml']); +}); + +test('named reward discovery requires the inspection capability supplied by the production adapter', async () => { + const node = {nodeId: 'a', sessionId: 'sa', online: true, rewardFilesSupported: true, rewardFileInventorySupported: false}; + const f = fixture([node]); let inspections = 0; + f.adapter.inspect = async () => { inspections++; return {files: ['StandardVote.yml']}; }; + const editor = create(f.adapter); await editor.read(); + assert.equal(inspections, 0); + assert.equal((await editor.inventory(node)).status, 'UNSUPPORTED'); + assert.equal(editor.files().includes('Rewards/StandardVote.yml'), false); +}); + +test('named reward read and preview use reward-file capability without managed-file capability', async () => { + const node = {nodeId: 'a', sessionId: 'sa', online: true, supported: false, rewardFilesSupported: true}; + const f = fixture([node]); + f.adapter.inspect = async () => ({files: ['StandardVote.yml']}); + const originalRequest = f.adapter.request; + f.adapter.request = async (path, body) => path.endsWith('/state') && body.fileName === 'Rewards/StandardVote.yml' + ? {readOperationId: 'Rewards/StandardVote.yml-r1', nodeId: 'a', fileName: body.fileName, + sessionId: 'sa', revision: 'r1', scopes: [{path: '$', status: 'PRESENT', editable: true, + fields: {Commands: ['say old']}}]} + : originalRequest(path, body); + const editor = create(f.adapter); editor.select('Rewards/StandardVote.yml', '$'); await editor.read(); + assert.equal(editor.record('a', 'VoteSites.yml').status, 'UNSUPPORTED'); + assert.equal(editor.record('a', 'Rewards/StandardVote.yml').status, 'AVAILABLE'); + editor.setEdit('APPEND_LIST_ENTRY', 'Commands', 'say new'); + assert.deepEqual(editor.plan().map(item => item.status), ['READY']); + assert.equal(await editor.preview(), true); + assert.deepEqual(f.calls.filter(([path, body]) => path.endsWith('/read')).map(([, body]) => body.configuration.fileName), + ['Rewards/StandardVote.yml']); +}); + +test('invalid named inventory fails closed and forced reads refresh the cached union', async () => { + const nodes = [{nodeId: 'a', sessionId: 'sa', online: true, rewardFilesSupported: true}]; + const f = fixture(nodes); let listing = ['StandardVote.yml']; let inspections = 0; + f.adapter.inspect = async () => { inspections++; return {files: listing}; }; + const editor = create(f.adapter); await editor.read(); + assert.equal(editor.files().includes('Rewards/StandardVote.yml'), true); + listing = ['../escape.yml']; await editor.read(true); + assert.equal(inspections, 2); + assert.equal((await editor.inventory(nodes[0])).status, 'ERROR'); + assert.equal(editor.files().includes('Rewards/StandardVote.yml'), false); +}); + +test('named reward files allow existing-root edits but reject create/remove operations', async () => { + const f = fixture([{nodeId: 'a', sessionId: 'sa', online: true, rewardFilesSupported: true}]); + f.adapter.inspect = async () => ({files: ['StandardVote.yml']}); + const originalRequest = f.adapter.request; + f.adapter.request = async (path, body) => { + if (path.endsWith('/state') && body.fileName === 'Rewards/StandardVote.yml') return { + readOperationId: 'Rewards/StandardVote.yml-r1', nodeId: 'a', fileName: body.fileName, sessionId: 'sa', revision: 'r1', + scopes: [{path: '$', status: 'PRESENT', editable: true, fields: {Commands: ['say old']}}] + }; + return originalRequest(path, body); + }; + const editor = create(f.adapter); editor.select('Rewards/StandardVote.yml', '$'); await editor.read(); + editor.setEdit('APPEND_LIST_ENTRY', 'Commands', 'say new'); assert.equal(editor.plan()[0].status, 'READY'); + editor.setEdit('CREATE_REWARD', '', {Commands: ['say created']}); assert.equal(editor.plan()[0].status, 'UNSUPPORTED'); + editor.setEdit('REMOVE_REWARD', '', null); assert.equal(editor.plan()[0].status, 'UNSUPPORTED'); +}); + +test('refresh before APPLY preserves other loaded named-file snapshots', async () => { + const f = fixture([{nodeId: 'a', sessionId: 'sa', online: true, rewardFilesSupported: true}]); + f.adapter.inspect = async () => ({files: ['First.yml', 'Second.yml']}); + const originalRequest = f.adapter.request; + f.adapter.request = async (path, body) => { + if (path.endsWith('/state') && String(body.fileName).startsWith('Rewards/')) return { + readOperationId: `${body.fileName}-r1`, nodeId: 'a', fileName: body.fileName, sessionId: 'sa', revision: 'r1', + scopes: [{path: '$', status: 'PRESENT', editable: true, fields: {Commands: f.commands.get('a')}}] + }; + return originalRequest(path, body); + }; + const editor = create(f.adapter); + editor.select('Rewards/Second.yml', '$'); await editor.read(); + assert.equal(editor.record('a', 'Rewards/Second.yml').status, 'AVAILABLE'); + editor.select('Rewards/First.yml', '$'); await editor.read(); + editor.setEdit('APPEND_LIST_ENTRY', 'Commands', 'say new'); + assert.equal(await editor.preview(), true); + assert.equal(await editor.apply(false), true); + assert.equal(editor.record('a', 'Rewards/Second.yml').status, 'AVAILABLE'); +}); + +test('item leaf edit excludes a target without that exact leaf instead of inventing item metadata', async () => { + const f = fixture(); + const originalRequest = f.adapter.request; + f.adapter.request = async (path, body) => { + const response = await originalRequest(path, body); + if (path.endsWith('/state') && body.fileName === 'VoteSites.yml') { + response.scopes[0].fields['Items.Diamond.Amount'] = body.nodeId === 'a' ? '1' : undefined; + if (body.nodeId === 'b') delete response.scopes[0].fields['Items.Diamond.Amount']; + } + return response; + }; + const editor = create(f.adapter); await editor.read(); editor.select('VoteSites.yml', 'VoteSites.Alpha.Rewards'); + editor.setEdit('SET_SCALAR', 'Items.Diamond.Amount', 2); + assert.deepEqual(editor.plan().map(item => item.status), ['READY', 'UNSUPPORTED']); + assert.equal(await editor.preview(), true); + assert.equal(editor.state.ackRequired, true); + assert.deepEqual(f.calls.filter(([path]) => path.endsWith('/rewards/preview')).map(([, body]) => body.nodeId), ['a']); +}); + +test('advanced reward fields and unavailable list entries are excluded without losing eligible previews', async () => { + const f = fixture(); + const originalRequest = f.adapter.request; + f.adapter.request = async (path, body) => { + const response = await originalRequest(path, body); + if (path.endsWith('/state') && body.fileName === 'VoteSites.yml') { + response.scopes[0].fields.Money = body.nodeId === 'a' ? '1' : undefined; + if (body.nodeId === 'b') { + delete response.scopes[0].fields.Money; + delete response.scopes[0].fields.Commands; + response.scopes[0].advancedKeys.push('Money', 'Commands', 'Messages'); + } + } + return response; + }; + const editor = create(f.adapter); await editor.read(); editor.select('VoteSites.yml', 'VoteSites.Alpha.Rewards'); + editor.setEdit('SET_SCALAR', 'Money', 5); + assert.deepEqual(editor.plan().map(item => item.status), ['READY', 'UNSUPPORTED']); + assert.equal(await editor.preview(), true); + assert.deepEqual(f.calls.filter(([path]) => path.endsWith('/rewards/preview')).map(([, body]) => body.nodeId), ['a']); + editor.setEdit('APPEND_LIST_ENTRY', 'Commands', 'say new'); + assert.deepEqual(editor.plan().map(item => item.status), ['READY', 'UNSUPPORTED']); + editor.setEdit('SET_SCALAR', 'Messages.Player', 'hello'); + assert.deepEqual(editor.plan().map(item => item.status), ['UNSUPPORTED', 'UNSUPPORTED']); + editor.setEdit('REMOVE_LIST_ENTRY', 'Commands', 'say absent'); + assert.deepEqual(editor.plan().map(item => item.status), ['CONFLICT', 'UNSUPPORTED']); +}); diff --git a/src/test/web/vote-sites-state.test.cjs b/src/test/web/vote-sites-state.test.cjs new file mode 100644 index 00000000..43bff2c5 --- /dev/null +++ b/src/test/web/vote-sites-state.test.cjs @@ -0,0 +1,110 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const {VoteSitesState, ADD_FIELDS, parseIntegerField, parseStringField} = require('../../main/resources/web/vote-sites-state.js'); + +test('Vote Site text input matches server-side constraints before staging', () => { + for (const path of ['ServiceSite', 'VoteURL', 'VoteDelay']) { + assert.equal(parseStringField(path, ''), null); + assert.equal(parseStringField(path, 'bad|value'), null); + assert.equal(parseStringField(path, 'bad\nvalue'), null); + assert.equal(parseStringField(path, 'valid value'), 'valid value'); + } + assert.equal(parseStringField('ServiceSite', 'x'.repeat(2049)), null); + assert.equal(parseStringField('VoteURL', 'x'.repeat(501)), null); + assert.equal(parseStringField('VoteDelay', 'x'.repeat(65)), null); + assert.equal(parseStringField('DisplayItem.Material', 'STONE BLOCK'), null); + assert.equal(parseStringField('DisplayItem.Material', 'minecraft:diamond'), 'minecraft:diamond'); + assert.equal(parseStringField('Name', ''), ''); + assert.equal(parseStringField('Name', 'x'.repeat(201)), null); + assert.equal(parseStringField('Name', 'bad\0name'), null); +}); + +test('Vote Site integer input matches server-side field ranges before staging', () => { + assert.equal(parseIntegerField('Priority', '-2147483648'), -2147483648); + assert.equal(parseIntegerField('Priority', '2147483647'), 2147483647); + assert.equal(parseIntegerField('Priority', '2147483648'), null); + assert.equal(parseIntegerField('Priority', '-2147483649'), null); + assert.equal(parseIntegerField('DisplayItem.Amount', '1'), 1); + assert.equal(parseIntegerField('DisplayItem.Amount', '64'), 64); + for (const raw of ['0', '65', '-1', '1.5', '', 'abc']) assert.equal(parseIntegerField('DisplayItem.Amount', raw), null); + assert.equal(parseIntegerField('Enabled', '1'), null); +}); + +const allFields = (overrides = {}) => Object.assign({Enabled: true, Name: 'Planet', ServiceSite: 'PlanetMinecraft', VoteURL: 'https://vote.example', VoteDelay: 24, Priority: 1, Hidden: false, 'DisplayItem.Material': 'DIAMOND', 'DisplayItem.Amount': 2}, overrides); +const site = (key, overrides = {}) => { + const {fields = {}, ...rest} = overrides; + return {siteKey: key, editable: true, rewardsConfigured: false, + fields: Object.fromEntries(Object.entries(allFields(fields)).map(([name, value]) => [name, {status: 'AVAILABLE', value}])), ...rest}; +}; +const read = (nodeId, sites, extra = {}) => ({nodeId, sessionId: 's-' + nodeId, revision: 'r-' + nodeId, status: 'AVAILABLE', sites, ...extra}); +const targets = () => [{nodeId: 'a', sessionId: 's-a'}, {nodeId: 'b', sessionId: 's-b'}]; + +test('same values are ALL/SAME and partial inventory retains every target', () => { + const state = new VoteSitesState(targets()); state.setRead('a', read('a', [site('exact')])); state.setRead('b', read('b', [site('exact')])); state.selectSite('exact'); + assert.equal(state.aggregate().presence, 'ALL'); assert.equal(state.aggregateField('Enabled').state, 'SAME'); + state.setRead('b', read('b', [])); assert.equal(state.aggregate().presence, 'PARTIAL'); assert.equal(state.aggregate().targets.length, 2); + assert.equal(state.aggregateField('Priority').state, 'MISSING'); + assert.equal(state.aggregateField('Priority').supportedState, 'SAME'); + assert.equal(state.aggregateField('Priority').value, 1); +}); +test('mixed Enabled and target ERROR remain explicit', () => { + const state = new VoteSitesState(targets()); state.setRead('a', read('a', [site('x')])); state.setRead('b', read('b', [site('x', {fields: {Enabled: false}})])); state.selectSite('x'); + assert.equal(state.aggregateField('Enabled').state, 'MIXED'); state.setRead('b', read('b', [], {status: 'ERROR', code: 'OFFLINE'})); + assert.equal(state.aggregateField('Enabled').state, 'ERROR'); assert.equal(state.aggregateField('Enabled').targets[1].status, 'ERROR'); +}); +test('site identity is exact siteKey, never Name or ServiceSite', () => { + const state = new VoteSitesState(targets()); state.setRead('a', read('a', [site('one', {fields: {Name: 'same', ServiceSite: 'same'}})])); state.setRead('b', read('b', [site('two', {fields: {Name: 'same', ServiceSite: 'same'}})])); state.selectSite('one'); + assert.equal(state.aggregate().presence, 'PARTIAL'); +}); +test('edit plans are dirty-only and preserve unrelated target-specific fields', () => { + const state = new VoteSitesState(targets()); state.setRead('a', read('a', [site('x')])); state.setRead('b', read('b', [site('x', {fields: {Enabled: false, Priority: 99}})])); state.selectSite('x').edit('Enabled', false); + const plans = state.plans(); assert.deepEqual(plans[0].changes, {Enabled: false}); assert.equal(plans[1].operation, 'UNCHANGED'); assert.equal(plans[0].changes.Priority, undefined); +}); +test('create missing is explicit, never overwrites existing unless requested', () => { + const state = new VoteSitesState(targets()); state.setRead('a', read('a', [site('x')])); state.setRead('b', read('b', [])); state.selectSite('x').setWorkflow('CREATE_MISSING').setAddFields(allFields()).edit('Enabled', false); + assert.equal(state.plans()[0].operation, 'UNCHANGED'); assert.equal(state.plans()[1].operation, 'ADD'); state.setCreateEditExisting(true); assert.equal(state.plans()[0].operation, 'EDIT'); +}); +test('remove leaves missing unchanged and keeps unsupported/noneditable targets visible', () => { + const state = new VoteSitesState(targets()); state.setRead('a', read('a', [site('x', {editable: false})])); state.setRead('b', read('b', [])); state.selectSite('x').setWorkflow('REMOVE'); + assert.equal(state.plans()[0].operation, 'SKIP'); assert.equal(state.plans()[1].operation, 'UNCHANGED'); state.setRead('b', read('b', [], {status: 'UNSUPPORTED'})); assert.equal(state.plans()[1].skipped[0].status, 'UNSUPPORTED'); +}); +test('single target is supported', () => { const state = new VoteSitesState([{nodeId: 'a', sessionId: 's-a'}]); state.setRead('a', read('a', [site('x')])); state.selectSite('x'); assert.equal(state.aggregate().presence, 'ALL'); assert.equal(state.aggregateField('Name').state, 'SAME'); }); +test('preview signature invalidates for edit, target, revision, session, inventory, mode, and site key', () => { + const state = new VoteSitesState(targets()); state.setRead('a', read('a', [site('x')])); state.setRead('b', read('b', [site('x')])); state.selectSite('x'); + const check = change => { const sig = state.beginPreview(); assert.equal(state.finishPreview(sig, []), true); change(); assert.equal(state.previewCurrent(), null); }; + check(() => state.edit('Enabled', false)); check(() => state.setTargets([{nodeId: 'a', sessionId: 's-a'}])); state.setTargets(targets()); state.setRead('a', read('a', [site('x')])); state.setRead('b', read('b', [site('x')])); + check(() => state.setRead('a', read('a', [site('x')], {revision: 'new'}))); check(() => state.setRead('a', read('a', [site('x')], {sessionId: 'new-session'}))); check(() => state.setRead('a', read('a', [site('y')]))); check(() => state.setMode('REMOVE')); check(() => state.selectSite('y')); +}); +test('partial apply retains dirty state until every requested edit has fresh confirmation', () => { + const state = new VoteSitesState(targets()); state.setRead('a', read('a', [site('x', {fields: {Enabled: false}})])); state.setRead('b', read('b', [site('x', {fields: {Enabled: false}})])); state.selectSite('x').edit('Enabled', true).markApplyRequested().setApplyResult('a', {status: 'APPLIED'}).setApplyResult('b', {status: 'FAILED'}); + state.setRead('a', read('a', [site('x')])); state.results.get('a').confirmed = true; + state.clearConfirmedDirty(); assert.equal(state.dirty.has('Enabled'), true); + state.setRead('b', read('b', [site('x')])); state.clearConfirmedDirty(); assert.equal(state.dirty.has('Enabled'), true); + state.results.get('b').status = 'APPLIED'; state.results.get('b').confirmed = true; + state.clearConfirmedDirty(); assert.equal(state.dirty.has('Enabled'), false); +}); +test('removal workflow clears only after every requested deletion is confirmed absent', () => { + const state = new VoteSitesState(targets()); + state.setRead('a', read('a', [site('x')])); state.setRead('b', read('b', [site('x')])); + state.selectSite('x').setWorkflow('REMOVE').markApplyRequested(); + state.setApplyResult('a', {status: 'APPLIED', confirmed: true}); + state.setApplyResult('b', {status: 'ERROR', confirmed: false}); + state.setRead('a', read('a', [])); state.clearConfirmedDirty(); + assert.equal(state.workflow, 'REMOVE'); + state.setRead('b', read('b', [])); state.setApplyResult('b', {status: 'APPLIED', confirmed: true}); + state.clearConfirmedDirty(); + assert.equal(state.workflow, 'EDIT_EXISTING'); assert.equal(state.mode, 'EDIT_EXISTING'); +}); +test('changing the workspace or connector session discards a previous workspace draft', () => { + const state = new VoteSitesState(targets()); state.setRead('a', read('a', [site('x')])); + state.selectSite('x').edit('Priority', 20).setWorkflow('CREATE_MISSING').setAddFields(allFields()); + state.setTargets([{nodeId: 'a', sessionId: 'reconnected'}]); + assert.equal(state.dirty.size, 0); assert.deepEqual(state.addFields, {}); + assert.equal(state.workflow, 'EDIT_EXISTING'); assert.equal(state.previewCurrent(), null); +}); +test('changed-target helper reports more than eight without truncating plans', () => { + const ids = Array.from({length: 9}, (_, index) => ({nodeId: 'n' + index, sessionId: 's' + index})); const state = new VoteSitesState(ids); ids.forEach(target => state.setRead(target.nodeId, read(target.nodeId, [site('x', {fields: {Enabled: false}})]))); state.selectSite('x').edit('Enabled', true); + assert.equal(state.changedCount(), 9); assert.equal(state.exceedsChangedTargetLimit(), true); assert.equal(state.plans().length, 9); +}); +test('add map must be complete', () => { const state = new VoteSitesState([{nodeId: 'a', sessionId: 's-a'}]); state.setRead('a', read('a', [])).selectSite('x').setWorkflow('CREATE_MISSING').setAddFields({Enabled: true}); assert.equal(state.plans()[0].operation, 'SKIP'); state.setAddFields(allFields()); assert.equal(Object.keys(state.plans()[0].fields).length, ADD_FIELDS.length); }); +test('editing a missing field on an existing editable site proposes an insertion', () => { const state = new VoteSitesState([{nodeId: 'a', sessionId: 's-a'}]); const value = site('x'); value.fields.Extra = {status: 'MISSING'}; state.setRead('a', read('a', [value])).selectSite('x').edit('Extra', {nested: true}); assert.deepEqual(state.plans()[0].changes, {Extra: {nested: true}}); }); diff --git a/src/test/web/vote-sites.test.cjs b/src/test/web/vote-sites.test.cjs new file mode 100644 index 00000000..b34ce413 --- /dev/null +++ b/src/test/web/vote-sites.test.cjs @@ -0,0 +1,120 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const {create} = require('../../main/resources/web/vote-sites.js'); +function deferred() { let resolve; return {promise: new Promise(r => { resolve = r; }), resolve}; } +const fields = (enabled = false) => ({Enabled: {status: 'AVAILABLE', value: enabled}, Name: {status: 'AVAILABLE', value: 'N'}, ServiceSite: {status: 'AVAILABLE', value: 'S'}, VoteURL: {status: 'AVAILABLE', value: 'u'}, VoteDelay: {status: 'AVAILABLE', value: 1}, Priority: {status: 'AVAILABLE', value: 1}, Hidden: {status: 'AVAILABLE', value: false}, 'DisplayItem.Material': {status: 'AVAILABLE', value: 'STONE'}, 'DisplayItem.Amount': {status: 'AVAILABLE', value: 1}}); +const add = () => Object.fromEntries(Object.entries(fields()).map(([key, value]) => [key, value.value])); +function fixture(nodes = [{nodeId: 'a', sessionId: 'sa', online: true}]) { + const calls = []; let ctx = 'signed-in'; let revision = 'r1'; const values = new Map(nodes.map(node => [node.nodeId, false])); + const adapter = {targets: () => nodes, context: () => ctx, active: () => ctx !== 'logout', changed() {}, operation: async (path, body) => { calls.push([path, body]); + if (path.endsWith('/read')) return {operationId: 'read-' + revision, results: Object.fromEntries(body.nodeIds.map(nodeId => [nodeId, {success: true, revision, nodeId}]))}; + if (path.endsWith('/preview')) return {operationId: 'preview-' + body.nodeId, state: 'SUCCEEDED', approvalToken: 'token-' + body.nodeId, results: {[body.nodeId]: {success: true, revision}}}; + if (path.endsWith('/apply')) { if (body.previewOperationId.includes('a')) values.set('a', true); return {operationId: 'apply', results: {a: {success: true}, b: {success: true}}}; } + }, request: async (path, body) => { + calls.push([path, body]); if (path.endsWith('/discard-preview')) return {}; + const nodeId = body.nodeId; return {nodeId, sessionId: nodes.find(node => node.nodeId === nodeId).sessionId, revision, status: 'AVAILABLE', sites: [{siteKey: 'x', editable: true, rewardsConfigured: false, fields: fields(values.get(nodeId))}]}; + }}; + return {adapter, calls, logout: () => { ctx = 'logout'; }, setContext: value => { ctx = value; }, setRevision: value => { revision = value; }, values}; +} +test('READ is single-flight, cacheable, and never creates a write operation', async () => { + const f = fixture(); const editor = create(f.adapter); const one = editor.read(); const two = editor.read(); assert.equal(one, two); await one; await editor.read(); + assert.equal(f.calls.filter(([path]) => path.endsWith('/read')).length, 1); assert.equal(f.calls.some(([path]) => /preview|apply/.test(path)), false); +}); +test('concurrent forced Vote Sites reads share one queued refresh', async () => { + const f = fixture(); const gate = deferred(); let calls = 0; + const original = f.adapter.operation; + f.adapter.operation = async (path, body) => { + if (path.endsWith('/read')) { calls++; if (calls === 1) await gate.promise; } + return original(path, body); + }; + const editor = create(f.adapter); const first = editor.read(); + const forced = editor.read(true); const another = editor.read(true); const retry = editor.read(false, true); + assert.equal(forced, another); assert.equal(forced, retry); + gate.resolve(); await Promise.all([first, forced, another, retry]); + assert.equal(calls, 2); + assert.equal(f.calls.filter(([path]) => path.endsWith('/read')).length, 2); +}); +test('late reads from a stale context do not populate state', async () => { + const f = fixture(); const gate = deferred(); f.adapter.operation = async () => { await gate.promise; return {operationId: 'r', results: {a: {success: true, revision: 'r1'}}}; }; + const editor = create(f.adapter); const read = editor.read(); f.setContext('other'); await editor.scopeChanged(); gate.resolve(); await read; assert.equal(editor.model.targets.get('a').status, 'MISSING'); +}); +test('preview sends exact per-target EDIT, ADD, and REMOVE bodies without automatic apply', async () => { + const f = fixture([{nodeId: 'a', sessionId: 'sa', online: true}, {nodeId: 'b', sessionId: 'sb', online: true}]); const editor = create(f.adapter); await editor.read(); editor.selectSite('x'); editor.edit('Enabled', true); await editor.preview(); + let preview = f.calls.find(([path]) => path.endsWith('/vote-sites/preview')); assert.deepEqual(preview[1], {readOperationId: 'read-r1', nodeId: 'a', action: 'EDIT', siteKey: 'x', fields: {Enabled: true}}); assert.equal(f.calls.some(([path]) => path.endsWith('/apply')), false); + editor.beginAdd('missing', add(), 'missing'); await editor.preview(); preview = f.calls.filter(([path]) => path.endsWith('/vote-sites/preview')).at(-1); assert.equal(preview[1].action, 'ADD'); assert.deepEqual(preview[1].fields, add()); + editor.remove('x'); await editor.preview(); preview = f.calls.filter(([path]) => path.endsWith('/vote-sites/preview')).at(-1); assert.deepEqual(preview[1], {readOperationId: 'read-r1', nodeId: 'b', action: 'REMOVE', siteKey: 'x', fields: {}}); +}); +test('draft invalidation disposes exact approval and over-eight targets are not truncated', async () => { + const f = fixture(); const editor = create(f.adapter); await editor.read(); editor.selectSite('x'); editor.edit('Enabled', true); await editor.preview(); editor.reset('Enabled'); await Promise.resolve(); + assert.deepEqual(f.calls.find(([path]) => path.endsWith('/discard-preview'))[1], {previewOperationId: 'preview-a', approvalToken: 'token-a'}); + const nodes = Array.from({length: 9}, (_, n) => ({nodeId: 'n' + n, sessionId: 's' + n, online: true})); const many = fixture(nodes); const manyEditor = create(many.adapter); await manyEditor.read(); manyEditor.selectSite('x').edit('Enabled', true); assert.equal(await manyEditor.preview(), false); assert.equal(manyEditor.model.plans().length, 9); +}); +test('apply requires acknowledgement, forces a fresh read, and confirms refreshed values', async () => { + const f = fixture([{nodeId: 'a', sessionId: 'sa', online: true}, {nodeId: 'b', sessionId: 'sb', online: false}]); const editor = create(f.adapter); await editor.read(); editor.selectSite('x'); editor.edit('Enabled', true); await editor.preview(); assert.equal(await editor.apply(), false); f.setRevision('r2'); assert.equal(await editor.apply(true), false); assert.equal(f.calls.filter(([path]) => path.endsWith('/apply')).length, 0); + const ok = fixture(); const applying = create(ok.adapter); await applying.read(); applying.selectSite('x').edit('Enabled', true); await applying.preview(); await applying.apply(); assert.equal(applying.model.results.get('a').confirmed, true); assert.equal(applying.model.dirty.size, 0); +}); +test('confirmed site removal leaves no stale removal draft', async () => { + const f = fixture(); let removed = false; + const originalOperation = f.adapter.operation; const originalRequest = f.adapter.request; + f.adapter.operation = async (path, body) => { + const response = await originalOperation(path, body); + if (path.endsWith('/apply')) removed = true; + return response; + }; + f.adapter.request = async (path, body) => { + const response = await originalRequest(path, body); + if (path.endsWith('/state') && removed) response.sites = []; + return response; + }; + const editor = create(f.adapter); await editor.read(); editor.remove('x'); + assert.equal(editor.model.workflow, 'REMOVE'); + assert.equal(await editor.preview(), true); + assert.equal(await editor.apply(), true); + assert.equal(editor.model.results.get('a').confirmed, true); + assert.equal(editor.model.workflow, 'EDIT_EXISTING'); +}); +test('a new apply reports only outcomes from its own requested targets', async () => { + const f = fixture(); const editor = create(f.adapter); await editor.read(); + editor.model.results.set('previously-failed-target', {status: 'ERROR', confirmed: false}); + editor.selectSite('x').edit('Enabled', true); + assert.equal(await editor.preview(), true); + assert.equal(await editor.apply(), true); + assert.deepEqual([...editor.model.results.keys()], ['a']); + assert.equal(editor.model.results.get('a').confirmed, true); +}); +test('global read-cache invalidation during APPLY does not revoke its approved preview', async () => { + const f = fixture(); let editor; + const operation = f.adapter.operation; + f.adapter.operation = async (path, body) => { + const response = await operation(path, body); + if (path.endsWith('/apply')) editor.invalidateReads(); + return response; + }; + editor = create(f.adapter); + await editor.read(); editor.selectSite('x').edit('Enabled', true); + assert.equal(await editor.preview(), true); + assert.equal(await editor.apply(), true); + assert.equal(editor.model.results.get('a').confirmed, true); + assert.equal(f.calls.filter(([path]) => path.endsWith('/read')).length, 3); + assert.equal(f.calls.filter(([path]) => path.endsWith('/discard-preview')).length, 0); +}); +test('partial reload failure and rollback do not clear the draft or imply confirmation', async () => { + const f = fixture([{nodeId: 'a', sessionId: 'sa', online: true}, {nodeId: 'b', sessionId: 'sb', online: true}]); + const operation = f.adapter.operation; + f.adapter.operation = (path, body) => path.endsWith('/apply') && body.previewOperationId.includes('b') + ? Promise.resolve({operationId: 'failed-b', results: {b: {success: false, code: 'RELOAD_FAILED', reloaded: false, rolledBack: true}}}) + : operation(path, body); + const editor = create(f.adapter); + await editor.read(); editor.selectSite('x').edit('Enabled', true); + assert.equal(await editor.preview(), true); + assert.equal(await editor.apply(), false); + assert.equal(editor.model.results.get('a').confirmed, true); + assert.equal(editor.model.results.get('b').confirmed, false); + assert.equal(editor.model.results.get('b').result.rolledBack, true); + assert.equal(editor.model.dirty.has('Enabled'), true); + assert.match(editor.state.message, /failures or unconfirmed/); +}); +test('late logout apply result is ignored and MISSING fields are editable insertions', async () => { + const f = fixture(); const editor = create(f.adapter); await editor.read(); editor.selectSite('x'); editor.edit('Enabled', true); await editor.preview(); const gate = deferred(); const original = f.adapter.operation; f.adapter.operation = async (path, body) => path.endsWith('/apply') ? (await gate.promise, {results: {a: {success: true}}}) : original(path, body); const applying = editor.apply(); await Promise.resolve(); f.logout(); await editor.scopeChanged(); gate.resolve(); assert.equal(await applying, false); assert.equal(editor.model.results.size, 0); + const direct = create(fixture().adapter); await direct.read(); direct.selectSite('x'); direct.model.targets.get('a').sites[0].fields.NewField = {status: 'MISSING'}; direct.edit('NewField', 'new'); assert.deepEqual(direct.model.plans()[0].changes, {NewField: 'new'}); +}); diff --git a/src/test/web/workspace.test.cjs b/src/test/web/workspace.test.cjs new file mode 100644 index 00000000..79cb9085 --- /dev/null +++ b/src/test/web/workspace.test.cjs @@ -0,0 +1,178 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const ControlWorkspace = require('../../main/resources/web/workspace.js'); +const {Workspace, parseRoute, formatRoute} = ControlWorkspace; + +const backend = (nodeId, online = true, capabilities = []) => ({nodeId, platform: 'BUKKIT', online, capabilities}); +const session = () => { + const values = new Map(); + return { + getItem: key => values.has(key) ? values.get(key) : null, + setItem: (key, value) => values.set(key, String(value)), + removeItem: key => values.delete(key), + has: key => values.has(key) + }; +}; + +test('login and logout clear all workspace state and Home is the default route', () => { + const workspace = new Workspace().setTargets(['one']).inspect('one').enterGlobal(); + assert.deepEqual(parseRoute(''), {page: 'home', scope: null, inspectedServerId: '', section: ''}); + workspace.login(); + assert.equal(workspace.managementScope, null); + assert.deepEqual([...workspace.selectedTargetIds], []); + assert.equal(workspace.inspectedServerId, ''); + assert.deepEqual([...workspace.previousTargetIds], []); + workspace.setTargets(['two']).inspect('two').enterGlobal().logout(); + assert.equal(workspace.managementScope, null); + assert.deepEqual([...workspace.selectedTargetIds], []); + assert.equal(workspace.inspectedServerId, ''); + assert.deepEqual([...workspace.previousTargetIds], []); +}); + +test('target selection covers single, multi, clear, global, and restore', () => { + const workspace = new Workspace().setTargets(['one']); + assert.equal(workspace.managementScope, 'SERVER'); + workspace.toggleTarget('two'); + assert.equal(workspace.managementScope, 'MULTI_SERVER'); + workspace.enterGlobal(); + assert.equal(workspace.managementScope, 'GLOBAL'); + assert.deepEqual([...workspace.selectedTargetIds], []); + workspace.returnToServers(); + assert.equal(workspace.managementScope, 'MULTI_SERVER'); + assert.deepEqual([...workspace.selectedTargetIds], ['one', 'two']); + workspace.clearTargets(); + assert.equal(workspace.managementScope, null); +}); + +test('inspecting a server never changes selected targets or management scope', () => { + const workspace = new Workspace().setTargets(['one', 'two']); + workspace.inspect('three'); + assert.equal(workspace.inspectedServerId, 'three'); + assert.equal(workspace.managementScope, 'MULTI_SERVER'); + assert.deepEqual([...workspace.selectedTargetIds], ['one', 'two']); +}); + +test('workspace routes map to dedicated Vote Sites and Rewards pages', () => { + assert.deepEqual(parseRoute('#workspace/settings'), + {page: 'general-settings', scope: 'WORKSPACE', inspectedServerId: '', section: 'settings'}); + assert.deepEqual(parseRoute('#workspace/vote-sites'), + {page: 'vote-sites', scope: 'WORKSPACE', inspectedServerId: '', section: 'vote-sites'}); + assert.deepEqual(parseRoute('#workspace/rewards'), + {page: 'rewards', scope: 'WORKSPACE', inspectedServerId: '', section: 'rewards'}); + assert.deepEqual(parseRoute('#workspace/sync'), + {page: 'quick-setup', scope: 'WORKSPACE', inspectedServerId: '', section: 'sync'}); + assert.deepEqual(parseRoute('#global/synchronization'), + {page: 'quick-setup', scope: 'GLOBAL', inspectedServerId: '', section: 'sync'}); +}); + +test('reconcile preserves known offline Bukkit targets and cleans disappeared targets and inspection', () => { + const workspace = new Workspace().setTargets(['online', 'offline', 'proxy', 'gone']).inspect('gone'); + workspace.reconcile([ + backend('online'), backend('offline', false), + {nodeId: 'proxy', platform: 'VELOCITY', online: true} + ]); + assert.deepEqual([...workspace.selectedTargetIds], ['online', 'offline']); + assert.equal(workspace.managementScope, 'MULTI_SERVER'); + assert.equal(workspace.inspectedServerId, ''); +}); + +test('selectEligible chooses only online Bukkit nodes with an eligible versioned capability', () => { + const workspace = new Workspace(); + workspace.selectEligible([ + backend('files', true, ['config.files.v1']), + backend('quick', true, ['config.quick-setup.v2']), + backend('inspect', true, ['data.inspect.v1']), + backend('offline', false, ['config.files.v1']), + backend('none', true, []), + backend('unsupported', true, ['config.files.v9']), + {nodeId: 'proxy', platform: 'BUNGEECORD', online: true, capabilities: ['config.files.v1']} + ], 2); + assert.deepEqual([...workspace.selectedTargetIds], ['files', 'quick']); + assert.equal(workspace.managementScope, 'MULTI_SERVER'); +}); + +test('navigation parse and format preserve selected scope when separately inspecting a server', () => { + const workspace = new Workspace().setTargets(['one', 'two']); + const routes = ['#home', '#workspace/overview/settings', '#workspace/vote-sites', + '#workspace/configuration', '#workspace/data', '#workspace/activity', + '#global/network', '#global/routing', '#global/synchronization', '#global/configuration', + '#global/activity', '#servers/server%201/overview', '#overview']; + for (const hash of routes) { + const route = parseRoute(hash); + assert.deepEqual(parseRoute(formatRoute(route)), route, hash); + assert.deepEqual([...workspace.selectedTargetIds], ['one', 'two']); + assert.equal(workspace.managementScope, 'MULTI_SERVER'); + } + workspace.inspect('server 1'); + assert.equal(workspace.inspectedServerId, 'server 1'); + assert.deepEqual([...workspace.selectedTargetIds], ['one', 'two']); + assert.equal(workspace.managementScope, 'MULTI_SERVER'); +}); + +test('unknown or malformed routes fail closed to Home', () => { + for (const hash of ['#workspace/nope', '#servers/%E0%A4%A/overview', '#servers//overview', + '#global/network/extra', '#not-a-panel', '#workspace/overview/nope']) { + assert.deepEqual(parseRoute(hash), {page: 'home', scope: null, inspectedServerId: '', section: ''}); + } +}); + +test('session persistence restores a server workspace, inspection, and route after reload', () => { + const storage = session(); + const original = new Workspace(storage).setTargets(['one', 'two']).inspect('two').setRoute('#workspace/settings'); + const restored = new Workspace(storage).restore([ + backend('one'), backend('two'), backend('three') + ]); + assert.equal(restored.managementScope, 'MULTI_SERVER'); + assert.deepEqual([...restored.selectedTargetIds], ['one', 'two']); + assert.equal(restored.inspectedServerId, 'two'); + assert.equal(restored.currentRoute, '#workspace/settings'); + assert.equal(original.currentRoute, restored.currentRoute); +}); + +test('session persistence restores Global without pretending all backends are selected', () => { + const storage = session(); + new Workspace(storage).setTargets(['one', 'two']).enterGlobal().setRoute('#global/network'); + const restored = new Workspace(storage).restore([backend('one'), backend('two')]); + assert.equal(restored.managementScope, 'GLOBAL'); + assert.deepEqual([...restored.selectedTargetIds], []); + assert.deepEqual([...restored.previousTargetIds], ['one', 'two']); + assert.equal(restored.currentRoute, '#global/network'); + restored.returnToServers(); + assert.equal(restored.managementScope, 'MULTI_SERVER'); + assert.deepEqual([...restored.selectedTargetIds], ['one', 'two']); +}); + +test('Global restore filters disappeared and non-Bukkit previous targets', () => { + const storage = session(); + new Workspace(storage).setTargets(['one', 'gone', 'proxy']).enterGlobal().setRoute('#global/network'); + const restored = new Workspace(storage).restore([backend('one'), {nodeId: 'proxy', platform: 'VELOCITY', online: true}]); + assert.deepEqual([...restored.previousTargetIds], ['one']); + assert.deepEqual(JSON.parse(storage.getItem(ControlWorkspace.SESSION_KEY)).previousTargetIds, ['one']); + restored.returnToServers(); + assert.deepEqual([...restored.selectedTargetIds], ['one']); +}); + +test('session restore removes stale or non-Bukkit IDs and rewrites the safe snapshot', () => { + const storage = session(); + new Workspace(storage).setTargets(['one', 'gone', 'proxy']).inspect('gone').setRoute('#servers/gone/overview'); + const restored = new Workspace(storage).restore([ + backend('one'), {nodeId: 'proxy', platform: 'VELOCITY', online: true} + ]); + assert.equal(restored.managementScope, 'SERVER'); + assert.deepEqual([...restored.selectedTargetIds], ['one']); + assert.equal(restored.inspectedServerId, ''); + const saved = JSON.parse(storage.getItem(ControlWorkspace.SESSION_KEY)); + assert.deepEqual(saved.selectedTargetIds, ['one']); + assert.equal(saved.inspectedServerId, ''); +}); + +test('logout clears persisted workspace and does not restore it for the next session', () => { + const storage = session(); + const workspace = new Workspace(storage).setTargets(['one']).inspect('one').setRoute('#servers/one/overview'); + workspace.logout(); + assert.equal(storage.has(ControlWorkspace.SESSION_KEY), false); + const next = new Workspace(storage).restore([backend('one')]); + assert.equal(next.managementScope, null); + assert.deepEqual([...next.selectedTargetIds], []); + assert.equal(next.inspectedServerId, ''); +});