James Read 12 saat önce
ebeveyn
işleme
c21e8fc3b3
91 değiştirilmiş dosya ile 2835 ekleme ve 958 silme
  1. 18 0
      .github/SECURITY_ADVISORY_DUPLICATES.md
  2. 8 1
      Makefile
  3. 40 13
      README.md
  4. 6 0
      SECURITY.md
  5. 36 0
      docs/modules/ROOT/examples/solutions/latching-toggle/config.yaml
  6. 2 0
      docs/modules/ROOT/examples/solutions/latching-toggle/water_valve.yaml
  7. 1 1
      docs/modules/ROOT/images/screenshots.mk
  8. 3 0
      docs/modules/ROOT/images/solutions/latching-toggle/.gitignore
  9. 18 0
      docs/modules/ROOT/images/solutions/latching-toggle/Makefile
  10. BIN
      docs/modules/ROOT/images/solutions/latching-toggle/closed.png
  11. 43 0
      docs/modules/ROOT/images/solutions/latching-toggle/config.yaml
  12. BIN
      docs/modules/ROOT/images/solutions/latching-toggle/open.png
  13. 16 0
      docs/modules/ROOT/images/solutions/latching-toggle/screenshots.ini
  14. 29 0
      docs/modules/ROOT/images/solutions/latching-toggle/setup_closed.py
  15. 34 0
      docs/modules/ROOT/images/solutions/latching-toggle/setup_open.py
  16. 2 0
      docs/modules/ROOT/nav.adoc
  17. 9 2
      docs/modules/ROOT/pages/advanced_configuration/logs.adoc
  18. 15 1
      docs/modules/ROOT/pages/config.adoc
  19. 3 1
      docs/modules/ROOT/pages/security/content_security_policy.adoc
  20. 190 0
      docs/modules/ROOT/pages/security/embedding_iframe.adoc
  21. 58 0
      docs/modules/ROOT/pages/solutions/latching-toggle/index.adoc
  22. 502 358
      frontend/package-lock.json
  23. 13 13
      frontend/package.json
  24. 1 1
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts
  25. 1 1
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js
  26. 2 0
      frontend/resources/vue/components/ChoiceCombobox.vue
  27. 22 0
      frontend/resources/vue/utils/argumentInputType.js
  28. 29 0
      frontend/resources/vue/utils/argumentInputType.test.mjs
  29. 17 22
      frontend/resources/vue/views/ArgumentForm.vue
  30. 212 153
      integration-tests/package-lock.json
  31. 6 6
      integration-tests/package.json
  32. 2 2
      lang/go.mod
  33. 4 4
      lang/go.sum
  34. 109 0
      logo.svg
  35. 1 1
      proto/buf.gen.yaml
  36. 6 0
      renovate.json
  37. 3 3
      service/.golangci.yml
  38. 77 76
      service/go.mod
  39. 160 158
      service/go.sum
  40. 3 2
      service/internal/api/api.go
  41. 1 4
      service/internal/api/api_entities_list.go
  42. 12 8
      service/internal/api/api_entity_related_actions_test.go
  43. 3 3
      service/internal/api/api_init_search_hints_test.go
  44. 6 6
      service/internal/api/api_log_arguments_test.go
  45. 2 2
      service/internal/api/api_test.go
  46. 1 1
      service/internal/api/dashboard_acl_test.go
  47. 4 0
      service/internal/api/dashboards.go
  48. 44 0
      service/internal/api/dashboards_race_test.go
  49. 1 1
      service/internal/auth/authpublic/authenticateduser.go
  50. 6 6
      service/internal/auth/otoauth2/restapi_auth_oauth2_test.go
  51. 25 2
      service/internal/config/config_reloader.go
  52. 8 8
      service/internal/config/config_reloader_user_test.go
  53. 4 3
      service/internal/config/entity_argument_choices_test.go
  54. 83 0
      service/internal/config/load_failure_issues_test.go
  55. 43 0
      service/internal/config/loaded_sources.go
  56. 22 0
      service/internal/config/loaded_sources_test.go
  57. 2 0
      service/internal/configissues/issue.go
  58. 2 2
      service/internal/entities/entities_test.go
  59. 3 3
      service/internal/executor/executor.go
  60. 3 0
      service/internal/executor/executor_actions.go
  61. 42 34
      service/internal/executor/executor_test.go
  62. 1 1
      service/internal/executor/group_concurrency_test.go
  63. 6 6
      service/internal/executor/loadlogs.go
  64. 5 10
      service/internal/logfilter/filter.go
  65. 2 2
      service/internal/tpl/templates.go
  66. 3 2
      service/internal/tpl/templates_test.go
  67. 7 7
      service/internal/webhooks/github.go
  68. 106 27
      service/main.go
  69. 82 0
      service/syntaxcheck.go
  70. 11 1
      specs/config-issues.md
  71. 2 0
      var/marketing/screenshots/.gitignore
  72. 48 0
      var/marketing/screenshots/Makefile
  73. 41 0
      var/marketing/screenshots/README.md
  74. BIN
      var/marketing/screenshots/argument-form.png
  75. BIN
      var/marketing/screenshots/dashboard-dark.png
  76. BIN
      var/marketing/screenshots/dashboard-mobile.png
  77. BIN
      var/marketing/screenshots/dashboard.png
  78. BIN
      var/marketing/screenshots/entities.png
  79. BIN
      var/marketing/screenshots/execution.png
  80. 74 0
      var/marketing/screenshots/generate-manifest.py
  81. BIN
      var/marketing/screenshots/logs.png
  82. 60 0
      var/marketing/screenshots/screenshot-manifest.yaml
  83. 58 0
      var/marketing/screenshots/screenshots.ini
  84. 10 0
      var/marketing/screenshots/scripts/argument_form.py
  85. 10 0
      var/marketing/screenshots/scripts/dark.py
  86. 10 0
      var/marketing/screenshots/scripts/light.py
  87. 19 0
      var/marketing/screenshots/scripts/prepare.py
  88. 12 0
      var/marketing/screenshots/scripts/theme.py
  89. 36 0
      var/marketing/screenshots/seed.sh
  90. 204 0
      var/marketing/screenshots/seed/config.yaml
  91. 10 0
      var/marketing/screenshots/seed/entities/servers.yaml

+ 18 - 0
.github/SECURITY_ADVISORY_DUPLICATES.md

@@ -107,6 +107,24 @@ No known duplicates.
 
 ---
 
+## PasswordHash argon2 resource exhaustion / login lockout
+
+**Canonical:** [GHSA-pc8g-78pf-4xrp](https://github.com/OliveTin/OliveTin/security/advisories/GHSA-pc8g-78pf-4xrp) / CVE-2026-28342 — reporter **fg0x0** (2026-02-25)
+
+Unauthenticated `PasswordHash` performs Argon2id hashing. Fixed in CVE-2026-28342 by a 10-slot semaphore shared with `LocalUserLogin`. Later reports argue that saturating the shared pool denies logins; this is the intended fail-fast trade-off and is not accepted as a separate vulnerability.
+
+| GHSA | Reporter | Status | Notes |
+|------|----------|--------|-------|
+| GHSA-pc8g-78pf-4xrp | fg0x0 | published (canonical) | Unbounded memory; fixed by semaphore |
+| GHSA-j746-2pj2-p94h | 0xkakash1 | closed | CPU exhaustion, post-fix |
+| GHSA-r2h8-8h64-fxx8 | offset | closed | Shared pool denies `LocalUserLogin` |
+| GHSA-p7jx-32j2-jq49 | fg0x0 | closed | Shared pool denies `LocalUserLogin` |
+| GHSA-8656-625g-3h52 | muzi5622 | closed | Shared pool denies `LocalUserLogin` |
+
+**Why not accepted:** the endpoint is intentionally unauthenticated (documented hashing utility, needed before any user exists); worst case is bounded at ~640 MiB and 10 concurrent hashes; login refusal lasts only while the flood is sustained, with no bypass or C/I impact. Volumetric floods belong to reverse-proxy rate limiting.
+
+---
+
 ## Easy to confuse (not duplicates)
 
 | Topic | Advisories | Distinction |

+ 8 - 1
Makefile

@@ -28,6 +28,10 @@ frontend-codestyle:
 frontend-unittests:
 	$(MAKE) -wC frontend unittests
 
+lint: service-codestyle frontend-codestyle
+
+test: service-unittests frontend-unittests
+
 docs-check:
 	python3 docs/modules/ROOT/check_config_keys.py
 	python3 docs/modules/ROOT/check_chevron_links.py
@@ -93,7 +97,10 @@ clean:
 config-tool:
 	cd service && go run cmd/config-tool/main.go
 
+screenshots:
+	$(MAKE) -wC var/marketing/screenshots
+
 devcheck:
 	python3 scripts/devcheck.py $(ARGS)
 
-.PHONY: proto proto-tools lang-generate generated-check default service windows-resources windows-msi frontend-unittests docs-check it devcheck
+.PHONY: screenshots proto proto-tools lang-generate generated-check default service windows-resources windows-msi frontend-unittests docs-check it devcheck lint test service-unittests service-codestyle frontend-codestyle

+ 40 - 13
README.md

@@ -15,7 +15,7 @@
 [OliveTin 2k to 3k upgrade guide](https://docs.olivetin.app/upgrade/2k3k.html)
 </div>
 
-<img alt = "screenshot" src = "https://github.com/OliveTin/OliveTin/blob/main/var/marketing/screenshots/mainpage-laptop_framed.png" />
+<img alt = "OliveTin action buttons" src = "var/marketing/screenshots/dashboard.png" />
 <a href = "#screenshots">More screenshots below</a>
 
 All documentation can be found at [docs.olivetin.app](https://docs.olivetin.app). This includes installation and usage guide, etc.
@@ -55,23 +55,50 @@ The AsciiDoc sources for that site live in this repository under [`docs/`](docs/
 
 ## Screenshots
 
-Desktop web browser;
+### Action buttons
 
-<p align = "center">
-<img alt = "screenshot" src = "https://github.com/OliveTin/OliveTin/blob/main/var/marketing/screenshots/mainpage-laptop_framed.png" />
-</p>
+![Action buttons](var/marketing/screenshots/dashboard.png)
 
-Desktop web browser (dark mode);
+Your predefined shell commands appear as simple, touch-friendly buttons, so anyone can run them safely without a terminal.
 
-<p align = "center">
-<img alt = "screenshot" src = "https://github.com/OliveTin/OliveTin/blob/main/var/marketing/screenshots/mainpage-darkop_framed.png" />
-</p>
+### Arguments
 
-Mobile screen size (responsive layout);
+![Arguments](var/marketing/screenshots/argument-form.png)
 
-<p align = "center">
-<img alt = "screenshot" src = "https://github.com/OliveTin/OliveTin/blob/main/var/marketing/screenshots/mainpage-phone_framed.png" style = "width: 700px;" />
-</p>
+Actions can ask for input before they run, with typed and validated fields, defaults and dropdowns, so commands only ever run with sensible values.
+
+### Command output
+
+![Command output](var/marketing/screenshots/execution.png)
+
+Watch a command's output live as it runs, along with whether it succeeded and how long it took.
+
+### Dashboards and entities
+
+![Dashboards and entities](var/marketing/screenshots/entities.png)
+
+Build dashboards that group actions by the things you manage, such as servers or containers, with one set of buttons generated for each of them.
+
+### Execution logs
+
+![Execution logs](var/marketing/screenshots/logs.png)
+
+Every action that runs is logged, so you can see who ran what, when, and whether it succeeded.
+
+### Dark mode
+
+![Dark mode](var/marketing/screenshots/dashboard-dark.png)
+
+OliveTin follows your browser's dark mode preference automatically.
+
+### Mobile layout
+
+![Mobile layout](var/marketing/screenshots/dashboard-mobile.png)
+
+The responsive layout works well on phones and on wall-mounted tablets, so commands are a tap away wherever you are.
+
+Screenshots use fictional test data and are regenerated with `make screenshots`
+(see [`var/marketing/screenshots`](var/marketing/screenshots/README.md)).
 
 ## No-Nonsense Software Principles
 

+ 6 - 0
SECURITY.md

@@ -58,6 +58,12 @@ If you are reporting via GitHub Security Advisories, search existing [repository
 
 Maintainers: see [.github/SECURITY_ADVISORY_DUPLICATES.md](.github/SECURITY_ADVISORY_DUPLICATES.md) for known duplicate pairs, triage steps, and OAuth2 issues that are easy to confuse with each other.
 
+## Known behaviour that is not considered a vulnerability
+
+The following have been reported several times and reviewed. Please only report them if you have found something beyond what is described here.
+
+* **`PasswordHash` is unauthenticated and can make local login busy.** The `PasswordHash` API is a documented utility for generating Argon2id hashes, and is needed before any user exists. Unbounded resource use was fixed in CVE-2026-28342 by limiting all Argon2id work, including `LocalUserLogin`, to 10 concurrent operations. Flooding `PasswordHash` can make logins return `ResourceExhausted` while the flood continues; this fail-fast behaviour is intentional, keeps memory bounded, and has no lasting effect. Rate limit request floods at your reverse proxy.
+
 ## Process
 
 Once a vulnerability is reported, the process is;

+ 36 - 0
docs/modules/ROOT/examples/solutions/latching-toggle/config.yaml

@@ -0,0 +1,36 @@
+entities:
+  - file: /etc/OliveTin/entities/water_valve.yaml
+    name: water_valve
+
+actions:
+  - title: Open valve
+    icon: '&#128167;'
+    entity: water_valve
+    enabledExpression: '{{ eq .CurrentEntity.state "closed" }}'
+    shell: |
+      set -e
+      echo "Opening {{ .CurrentEntity.title }}"
+      printf -- '- title: %s\n  state: open\n' "{{ .CurrentEntity.title }}" > /etc/OliveTin/entities/water_valve.yaml
+
+  - title: Close valve
+    icon: '&#128683;'
+    entity: water_valve
+    enabledExpression: '{{ eq .CurrentEntity.state "open" }}'
+    shell: |
+      set -e
+      echo "Closing {{ .CurrentEntity.title }}"
+      printf -- '- title: %s\n  state: closed\n' "{{ .CurrentEntity.title }}" > /etc/OliveTin/entities/water_valve.yaml
+
+dashboards:
+  - title: Water Valve
+    contents:
+      - title: "{{ water_valve.title }}"
+        type: fieldset
+        entity: water_valve
+        contents:
+          - type: display
+            title: |
+              State: <strong>{{ water_valve.state }}</strong>
+
+          - title: Open valve
+          - title: Close valve

+ 2 - 0
docs/modules/ROOT/examples/solutions/latching-toggle/water_valve.yaml

@@ -0,0 +1,2 @@
+- title: Garden water valve
+  state: closed

+ 1 - 1
docs/modules/ROOT/images/screenshots.mk

@@ -45,5 +45,5 @@ stop:
 	exit 0
 
 update-screenshots: stop start
-	cd "$(CONFIGDIR)" && repo-helper screenshot --config screenshots.ini
+	@cd "$(CONFIGDIR)" && repo-helper screenshot --config screenshots.ini || { $(MAKE) stop; exit 1; }
 	@$(MAKE) stop

+ 3 - 0
docs/modules/ROOT/images/solutions/latching-toggle/.gitignore

@@ -0,0 +1,3 @@
+custom-webui/
+__pycache__/
+water_valve.yaml

+ 18 - 0
docs/modules/ROOT/images/solutions/latching-toggle/Makefile

@@ -0,0 +1,18 @@
+CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
+ENTITY_SEED := $(CONFIGDIR)../../../examples/solutions/latching-toggle/water_valve.yaml
+
+# The screenshot config reads and writes this fixture, so captures never touch
+# a real /etc/OliveTin install.
+export WATER_VALVE_ENTITY_FILE := $(CONFIGDIR)water_valve.yaml
+
+include ../../screenshots.mk
+
+start: seed-entity
+
+.DEFAULT_GOAL := screenshots
+.PHONY: screenshots seed-entity
+
+seed-entity:
+	cp "$(ENTITY_SEED)" "$(WATER_VALVE_ENTITY_FILE)"
+
+screenshots: update-screenshots

BIN
docs/modules/ROOT/images/solutions/latching-toggle/closed.png


+ 43 - 0
docs/modules/ROOT/images/solutions/latching-toggle/config.yaml

@@ -0,0 +1,43 @@
+---
+listenAddressSingleHTTPFrontend: 127.0.0.1:11337
+
+logLevel: "WARN"
+checkForUpdates: false
+showFooter: false
+
+entities:
+  - file: ${{ WATER_VALVE_ENTITY_FILE }}
+    name: water_valve
+
+actions:
+  - title: Open valve
+    icon: '&#128167;'
+    entity: water_valve
+    enabledExpression: '{{ eq .CurrentEntity.state "closed" }}'
+    shell: |
+      set -e
+      echo "Opening {{ .CurrentEntity.title }}"
+      printf -- '- title: %s\n  state: open\n' "{{ .CurrentEntity.title }}" > "${{ WATER_VALVE_ENTITY_FILE }}"
+
+  - title: Close valve
+    icon: '&#128683;'
+    entity: water_valve
+    enabledExpression: '{{ eq .CurrentEntity.state "open" }}'
+    shell: |
+      set -e
+      echo "Closing {{ .CurrentEntity.title }}"
+      printf -- '- title: %s\n  state: closed\n' "{{ .CurrentEntity.title }}" > "${{ WATER_VALVE_ENTITY_FILE }}"
+
+dashboards:
+  - title: Water Valve
+    contents:
+      - title: "{{ water_valve.title }}"
+        type: fieldset
+        entity: water_valve
+        contents:
+          - type: display
+            title: |
+              State: <strong>{{ water_valve.state }}</strong>
+
+          - title: Open valve
+          - title: Close valve

BIN
docs/modules/ROOT/images/solutions/latching-toggle/open.png


+ 16 - 0
docs/modules/ROOT/images/solutions/latching-toggle/screenshots.ini

@@ -0,0 +1,16 @@
+[DEFAULT]
+base_url = http://localhost:11337/
+dir = .
+width = 900
+height = 260
+post_script_sleep = 0.5
+
+[closed]
+url = /dashboards/Water%20Valve
+name = closed
+script = setup_closed.py
+
+[open]
+url = /dashboards/Water%20Valve
+name = open
+script = setup_open.py

+ 29 - 0
docs/modules/ROOT/images/solutions/latching-toggle/setup_closed.py

@@ -0,0 +1,29 @@
+#!/usr/bin/env python3
+"""Open the Water Valve dashboard with the valve closed."""
+
+import time
+
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+
+def wait_for_dashboard(driver, timeout=30):
+    WebDriverWait(driver, timeout).until(
+        lambda d: bool(d.find_element(By.TAG_NAME, "body").get_attribute("loaded-dashboard"))
+    )
+
+
+def wait_for_state(driver, state, timeout=30):
+    def ready(d):
+        try:
+            return f"State: {state}" in d.find_element(By.TAG_NAME, "main").text
+        except Exception:
+            return False
+
+    WebDriverWait(driver, timeout).until(ready)
+
+
+def run(driver):
+    wait_for_dashboard(driver)
+    wait_for_state(driver, "closed")
+    time.sleep(0.2)

+ 34 - 0
docs/modules/ROOT/images/solutions/latching-toggle/setup_open.py

@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+"""Click Open valve and wait for the dashboard to show the valve open."""
+
+import time
+
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+
+def wait_for_dashboard(driver, timeout=30):
+    WebDriverWait(driver, timeout).until(
+        lambda d: bool(d.find_element(By.TAG_NAME, "body").get_attribute("loaded-dashboard"))
+    )
+
+
+def wait_for_state(driver, state, timeout=30):
+    def ready(d):
+        try:
+            return f"State: {state}" in d.find_element(By.TAG_NAME, "main").text
+        except Exception:
+            return False
+
+    WebDriverWait(driver, timeout).until(ready)
+
+
+def run(driver):
+    wait_for_dashboard(driver)
+    wait_for_state(driver, "closed")
+    driver.find_element(By.CSS_SELECTOR, 'button[title="Open valve"]').click()
+    wait_for_state(driver, "open")
+    WebDriverWait(driver, 10).until(
+        lambda d: d.find_element(By.CSS_SELECTOR, 'button[title="Close valve"]').is_enabled()
+    )
+    time.sleep(0.5)

+ 2 - 0
docs/modules/ROOT/nav.adoc

@@ -121,6 +121,7 @@
 *** xref:security/example_some_admin_actions.adoc[Example: Some actions require admin]
 ** xref:security/design_choices.adoc[Security Design & Hardening Recommendations]
 ** xref:security/content_security_policy.adoc[Content Security Policy headers]
+*** xref:security/embedding_iframe.adoc[Embedding OliveTin in an iframe]
 * Integrations
 ** xref:integrations/homeassistant-integration.adoc[Home Assistant (HACS Integration)]
 ** xref:integrations/homeassistant.adoc[Home Assistant (REST)]
@@ -137,6 +138,7 @@
 ** xref:solutions/systemd-control-panel/index.adoc[Systemd Control Panel]
 ** xref:solutions/heating-control-panel/index.adoc[Heating Control Panel]
 ** xref:solutions/human-in-the-control-loop/index.adoc[Human in the Control Loop]
+** xref:solutions/latching-toggle/index.adoc[Latching Toggle]
 ** xref:solutions/k8s-control-panel-hosted/index.adoc[Kubernetes Control Panel (hosted)]
 ** xref:solutions/primitive-password/index.adoc[Primitive Password Protection]
 ** xref:solutions/wol/index.adoc[Wake on LAN]

+ 9 - 2
docs/modules/ROOT/pages/advanced_configuration/logs.adoc

@@ -6,6 +6,14 @@ There are two different types of logs in OliveTin - xref:advanced_configuration/
 
 OliveTin supports a few different log levels. The default logLevel is `INFO`.
 
+Before configuration is loaded, OliveTin also logs at `INFO` by default. Pass `-startuptrace` if you need `TRACE` messages while OliveTin is searching for and loading `config.yaml` (for example to see which config paths were checked). After configuration loads, `logLevel` from `config.yaml` takes over as usual.
+
+.`Example: enable startup TRACE logging`
+[source,bash]
+----
+OliveTin -startuptrace -configdir /etc/OliveTin
+----
+
 You can set a `logLevel` in config.yaml like this;
 
 .`config.yaml`
@@ -19,6 +27,7 @@ actions:
 
 The supported log levels are;
 
+* `TRACE` - Extremely detailed messages (for example config decode hooks). Usually only useful with `-startuptrace`.
 * `DEBUG` - Every possible log message will be shown. This will use a lot of disk space and is not recommended unless you are a developer / like reading code.
 * `ERROR` - OliveTin rarely uses the `ERROR` log level.
 * `WARN` - Very few messages, only warnings are shown.
@@ -43,8 +52,6 @@ You can enable JSON log format by setting the `OLIVETIN_LOG_FORMAT` environment
 ----
 root@server: ./OliveTin
 {"commit":"nocommit","date":"nodate","level":"info","msg":"OliveTin initializing","time":"2025-10-30T10:09:55Z","version":"dev"}
-{"level":"debug","msg":"Value of -configdir flag","time":"2025-10-30T10:09:55Z","value":"."}
-{"level":"debug","msg":"servicehost nonwin","time":"2025-10-30T10:09:55Z"}
 {"level":"info","msg":"Setting log level to info","time":"2025-10-30T10:09:55Z"}
 {"level":"info","msg":"OliveTin initialization complete","time":"2025-10-30T10:09:55Z"}
 {"configDir":"/home/xconspirisist/sandbox/Development/OliveTin/OliveTin","level":"info","msg":"OliveTin started","time":"2025-10-30T10:09:55Z"}

+ 15 - 1
docs/modules/ROOT/pages/config.adoc

@@ -10,6 +10,18 @@ file in the following locations;
 
 For a complete annotated example, see the https://github.com/OliveTin/OliveTin/blob/main/config.yaml[`config.yaml` in the OliveTin repository].
 
+[#syntaxcheck]
+== Check configuration without starting the server
+
+Use `-syntaxcheck` to load configuration, print the files OliveTin found and read, list any configuration warnings or errors (the same Diagnostics issue list), and exit without starting the web UI.
+
+[source,bash]
+----
+OliveTin -configdir /etc/OliveTin -syntaxcheck
+----
+
+The process exits with `0` when no configuration issues are found, and `1` when one or more issues are found. A final line always reports how many issues were found, including `0 configuration issues found`. If configuration cannot be loaded (for example no `config.yaml` is found), OliveTin still prints the searched paths and a load-failure message, then exits with a non-zero status.
+
 The most simple `config.yaml` would be something like this;
 
 .The most simple `config.yaml` file.
@@ -87,6 +99,8 @@ All configuration options are covered in the solution sections
 | `accessControlLists` | The list of access control lists. | `[]` | Requires restart | xref:security/acl.adoc[Access Control Lists]
 | `security.headerContentSecurityPolicy` | Whether to send a `Content-Security-Policy` header from the single HTTP frontend. | `true` | Live reloadable | xref:security/content_security_policy.adoc[Content Security Policy headers]
 | `security.contentSecurityPolicy` | CSP header value when `security.headerContentSecurityPolicy` is enabled. If empty, a built-in default is used. | (built-in default) | Live reloadable | xref:security/content_security_policy.adoc[Content Security Policy headers]
+| `security.headerXFrameOptions` | Whether to send an `X-Frame-Options` header from the single HTTP frontend. | `true` | Live reloadable | xref:security/embedding_iframe.adoc[Embedding OliveTin in an iframe]
+| `security.xFrameOptions` | `X-Frame-Options` header value when `security.headerXFrameOptions` is enabled. | `DENY` | Live reloadable | xref:security/embedding_iframe.adoc[Embedding OliveTin in an iframe]
 |===
 
 == Networking Configuration
@@ -107,7 +121,7 @@ All configuration options are covered in the solution sections
 |===
 | Option | Description | Default | Live Reloadable | Documentation
 
-| `logLevel` | The log level to use. `INFO`, `DEBUG`, `WARN` | `INFO` | Requires Restart | -
+| `logLevel` | The log level to use. `INFO`, `DEBUG`, `WARN`, `TRACE` | `INFO` | Requires Restart | xref:advanced_configuration/logs.adoc[Logging]
 | `logDebugOptions` | Enable various debug logs. | `-` | Requires Restart | xref:troubleshooting/advanced.adoc[Advanced Troubleshooting]
 | `Insecure*` | Various options to disable security features. | `false` | Restart recommended | xref:troubleshooting/advanced.adoc[Advanced Troubleshooting]
 |===

+ 3 - 1
docs/modules/ROOT/pages/security/content_security_policy.adoc

@@ -3,6 +3,8 @@
 
 When xref:reference/network-ports.adoc[the single HTTP frontend] is enabled (the default), OliveTin adds several browser security headers to every response, including `Content-Security-Policy`. This page explains how to turn that header off or replace it with a less strict policy when you need to (for example, custom scripts, different API hosts, or embedding in an iframe).
 
+TIP: If you want to show OliveTin inside another page, such as a Home Assistant dashboard, follow xref:security/embedding_iframe.adoc[Embedding OliveTin in an iframe] instead. It has a ready-to-paste example.
+
 [WARNING]
 Relaxing or removing CSP weakens protection against cross-site scripting and related attacks. Prefer the smallest change that fixes your issue, and keep the rest of the policy as tight as you can.
 
@@ -43,7 +45,7 @@ Build your policy from the https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
 Typical reasons people adjust this setting:
 
 * **Custom JavaScript or third-party scripts** — You may need to extend `script-src` (and sometimes `connect-src` for XHR/fetch). See xref:advanced_configuration/webui.adoc#custom-js[Custom JavaScript].
-* **Embedding OliveTin in another site** — The default includes `frame-ancestors 'none'`, which blocks iframes. You must change that directive (and may need to relax `X-Frame-Options` using `security.headerXFrameOptions` and `security.xFrameOptions`) if embedding is required.
+* **Embedding OliveTin in another site** — The default includes `frame-ancestors 'none'`, which blocks iframes. See xref:security/embedding_iframe.adoc[Embedding OliveTin in an iframe] for step-by-step instructions.
 * **API or auth on another origin** — Extend `connect-src` (and possibly `form-action` or others) to include those URLs.
 * **Reverse proxy also sets CSP** — Your proxy might add a second policy; browsers combine them. Align OliveTin and the proxy so you do not get conflicting or unexpectedly strict effective policies.
 

+ 190 - 0
docs/modules/ROOT/pages/security/embedding_iframe.adoc

@@ -0,0 +1,190 @@
+[#embedding-iframe]
+= Embedding OliveTin in an iframe
+
+You might want to show OliveTin inside another web page, such as a Home Assistant dashboard (Webpage card), Homarr, Organizr, Heimdall, or your own HTML page. These pages show OliveTin using an `<iframe>`.
+
+By default, **OliveTin tells web browsers not to allow this**. This protects you from "clickjacking", where a malicious website hides your OliveTin buttons inside its own page and tricks you into clicking them. If you try to embed OliveTin without changing anything, the iframe will be blank and the browser console will show an error like this:
+
+[source,text]
+----
+Framing violates the following Content Security Policy directive: "frame-ancestors 'none'". The request has been blocked.
+----
+
+This page explains how to allow *your* dashboard to embed OliveTin, while still blocking every other website.
+
+TIP: You do not need to understand Content Security Policy (CSP) to follow this page. You only need to change one web address in the example below.
+
+== Step 1: Find the address of your dashboard
+
+You need the **origin** of the page that will contain OliveTin, which we'll call the "parent page". This is *not* the address of OliveTin itself.
+
+The easiest way to get it exactly right:
+
+. Open your dashboard (the parent page) in your web browser.
+. Open the browser's developer tools (press kbd:[F12]) and go to the **Console** tab.
+. Type `location.origin` and press kbd:[Enter].
+
+The browser will print something like `"https://homeassistant.example.com"` or `"http://192.168.1.50:8123"`. Copy it, without the quotes. That is your origin.
+
+An origin is made up of three parts, and **all three must match exactly**:
+
+[cols="1,2,2"]
+|===
+| Part | Example | Notes
+
+| Scheme
+| `http://` or `https://`
+| `http` and `https` are treated as different sites.
+
+| Host
+| `homeassistant.example.com` or `192.168.1.50`
+| If you sometimes open your dashboard by IP address and sometimes by name, those are two different origins. Allow both (see <<multiple-dashboards>>).
+
+| Port
+| `:8123`
+| Only included if it is not the default (`80` for http, `443` for https).
+|===
+
+Paths such as `/lovelace/home` are not part of the origin. Leave them out.
+
+== Step 2: Add this to your config.yaml
+
+Copy this into your OliveTin `config.yaml`. If you already have a `security:` section, merge these keys into it rather than adding a second `security:` section.
+
+include::partial$config-start.adoc[]
+----
+security:
+  headerContentSecurityPolicy: true
+  headerXFrameOptions: false
+  contentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'self' https://dashboard.example.com; base-uri 'self'"
+----
+
+**The only thing you need to change** is `https://dashboard.example.com`. Replace it with the origin you found in Step 1. Leave everything else on that line exactly as it is, including the quotes and semicolons.
+
+For example, if your Home Assistant is at `http://192.168.1.50:8123`, the `frame-ancestors` part becomes:
+
+[source,text]
+----
+frame-ancestors 'self' http://192.168.1.50:8123;
+----
+
+What these settings do:
+
+* `headerContentSecurityPolicy: true` makes sure OliveTin sends the security policy below. This is already the default, but it is included in case an older config turned it off.
+* `contentSecurityPolicy` is OliveTin's normal security policy, with one change. `frame-ancestors 'none'` (nobody may embed OliveTin) has become `frame-ancestors 'self' <your dashboard>` (only OliveTin itself and your dashboard may embed it).
+* `headerXFrameOptions: false` turns off an older header, `X-Frame-Options: DENY`, which also blocks iframes. The `frame-ancestors` setting above does the same job in a more flexible way, so this older header is no longer needed.
+
+[#multiple-dashboards]
+=== Allowing more than one dashboard
+
+To allow several parent pages, list them after `frame-ancestors`, separated by spaces:
+
+[source,text]
+----
+frame-ancestors 'self' https://homeassistant.example.com http://192.168.1.50:8123 https://homarr.example.com;
+----
+
+To allow every subdomain of your own domain, use a `*` wildcard:
+
+[source,text]
+----
+frame-ancestors 'self' https://*.example.com;
+----
+
+=== Allowing any website (not recommended)
+
+If you are testing on a private network and just want it to work, you can allow any page to embed OliveTin:
+
+[source,text]
+----
+frame-ancestors *;
+----
+
+WARNING: This means *any* website you visit can embed your OliveTin and trick you into clicking its buttons. Only use this for testing, and never on an OliveTin instance that is reachable from the internet.
+
+== Step 3: Reload and test
+
+Changes to the `security` section are picked up automatically when you save `config.yaml`, so you normally do not need to restart OliveTin. If you are unsure, restarting is harmless.
+
+Then reload your dashboard page in the browser. If you use the browser's cache heavily, a hard refresh (kbd:[Ctrl+Shift+R]) helps.
+
+If you're writing your own HTML page, the iframe looks like this:
+
+[source,html]
+----
+<iframe src="https://olivetin.example.com/" style="width: 100%; height: 600px; border: 0"></iframe>
+----
+
+=== Checking what OliveTin is sending
+
+If it still does not work, check the headers that are actually being sent. Run this from a terminal, using the address you use to reach OliveTin:
+
+[source,shell]
+----
+curl -sI https://olivetin.example.com/ | grep -i -E 'content-security|x-frame'
+----
+
+You should see your dashboard's origin in the `frame-ancestors` part, and no `X-Frame-Options` line. If you still see `frame-ancestors 'none'`, your config change has not been applied. See the troubleshooting section below.
+
+== Troubleshooting
+
+[cols="2,3"]
+|===
+| What you see | What to do
+
+| The error still says `frame-ancestors 'none'`
+a| OliveTin is still sending its default policy. Check that:
+
+* the keys are indented under `security:` and spelled exactly as shown, including upper and lower case letters (`contentSecurityPolicy` starts with a lower case `c`).
+* you edited the `config.yaml` that OliveTin is actually using (check the OliveTin startup logs).
+* you replaced `'none'` in the line you pasted. Older examples on the xref:security/content_security_policy.adoc[Content Security Policy] page still contain `'none'`.
+* a reverse proxy is not adding its own `Content-Security-Policy` header (see below).
+
+| The error lists your dashboard, but it is still blocked
+| The origin does not match exactly. Compare it carefully with the output of `location.origin` from Step 1. The most common mistakes are `http` vs `https`, a missing port number, or using an IP address in one place and a hostname in the other.
+
+| `Refused to display ... in a frame because it set 'X-Frame-Options' to 'deny'`
+| Add `headerXFrameOptions: false` under `security:`, as shown in Step 2. You may also see this if your reverse proxy adds its own `X-Frame-Options` header.
+
+| `Mixed Content: The page at 'https://...' was loaded over HTTPS, but requested an insecure frame 'http://...'`
+| Your dashboard uses `https://` but OliveTin uses `http://`. Browsers do not allow this, and no OliveTin setting can change it. Serve OliveTin over HTTPS too, for example behind a xref:reverse-proxies/intro.adoc[reverse proxy].
+
+| The iframe works, but you have to log in every time or logging in does nothing
+| See <<logins-in-iframes>>.
+|===
+
+=== Reverse proxies
+
+Some reverse proxies and proxy add-ons (for example Traefik security header middlewares, Nginx Proxy Manager "block common exploits", or authentication proxies) add their own `Content-Security-Policy` or `X-Frame-Options` headers. Browsers enforce *every* policy they receive, so a strict policy from your proxy will still block the iframe even after you have changed OliveTin.
+
+To find out if this is happening, run the `curl` command from Step 3 twice: once directly against OliveTin (for example `http://192.168.1.10:1337/`) and once against the address that goes through your proxy. If the results are different, change the headers in your proxy configuration as well.
+
+[#logins-in-iframes]
+=== Logins inside an iframe
+
+If you use xref:security/local.adoc[local users] or xref:security/oauth2.adoc[OAuth2], OliveTin remembers that you are logged in using a cookie. Browsers only send OliveTin's cookie to an iframe when the parent page and OliveTin are on the **same site**. The same site means the same scheme (`http` or `https`) and the same main domain. Subdomains and port numbers can differ.
+
+[cols="2,2,1"]
+|===
+| Dashboard | OliveTin | Login works in iframe?
+
+| `https://ha.example.com`
+| `https://olivetin.example.com`
+| Yes
+
+| `http://192.168.1.50:8123`
+| `http://192.168.1.50:1337`
+| Yes
+
+| `https://ha.example.com`
+| `https://olivetin.otherdomain.net`
+| No
+
+| `https://ha.example.com`
+| `http://192.168.1.50:1337`
+| No
+|===
+
+If your setup is in the "No" rows, the easiest fix is to make OliveTin available on a subdomain of the same domain as your dashboard, usually with a xref:reverse-proxies/intro.adoc[reverse proxy].
+
+Many OAuth2 login providers also refuse to show their login page inside an iframe. If the login page does not appear, open OliveTin in a normal browser tab, log in there, and then reload your dashboard. This only works when the parent page and OliveTin are on the same site, as described above.

+ 58 - 0
docs/modules/ROOT/pages/solutions/latching-toggle/index.adoc

@@ -0,0 +1,58 @@
+[#latching-toggle]
+= Latching Toggle
+
+A latching toggle controls something that has two states, using two actions: one sets the state and the other resets it. The current state is _latched_ - it is remembered between button presses - and the dashboard always shows it.
+
+The two actions are also _interlocked_: only the action that is valid for the current state can be clicked. You cannot open a valve that is already open, or close one that is already closed.
+
+In OliveTin, the latched state is stored in an xref:entities/intro.adoc[entity file], which the actions update themselves, and the interlock is done with xref:action_customization/enabledExpression.adoc[enabled expressions].
+
+This differs from xref:solutions/human-in-the-control-loop/index.adoc[Human in the Control Loop], which shows a live reading next to a single manual action that can always be run.
+
+The example on this page controls a garden water valve.
+
+[cols="1,1"]
+|===
+| Valve closed | Valve open
+
+a| image::solutions/latching-toggle/closed.png[]
+a| image::solutions/latching-toggle/open.png[]
+|===
+
+== How it works
+
+* The entity file `/etc/OliveTin/entities/water_valve.yaml` holds the state of the valve (`open` or `closed`). It is the single source of truth for the dashboard.
+* **Open valve** is only enabled when the state is `closed`, and **Close valve** is only enabled when the state is `open`. This is done with `enabledExpression`, which reads the state from `.CurrentEntity`.
+* Each action writes the new state back to the entity file after it has done its work. OliveTin watches entity files, so the dashboard and the enabled buttons update as soon as the file changes.
+
+== Entity file
+
+[source,yaml]
+.`/etc/OliveTin/entities/water_valve.yaml`
+----
+include::example$solutions/latching-toggle/water_valve.yaml[]
+----
+
+== Configuration
+
+[source,yaml]
+.`config.yaml`
+----
+include::example$solutions/latching-toggle/config.yaml[]
+----
+
+== Customising this pattern
+
+* The same pattern works for anything with two states, such as lights, relays, services, or a maintenance mode.
+* Replace the `echo` commands with your real hardware control, for example a GPIO command, an MQTT publish, or a call to a smart plug API.
+* Only write the new state to the entity file once the hardware command has succeeded. If the command fails, the shell exits before the state changes and the dashboard keeps showing the real state.
+* If something else can change the device (a physical switch, a schedule), have a script update the entity file too. The dashboard will then follow it.
+* These actions rewrite the whole entity file, so to control several valves, give each one its own entity file (or update the file in place with a tool such as `yq`).
+* Add ACLs if only certain users should be able to operate the valve.
+
+== See also
+
+* xref:entities/intro.adoc[Entities]
+* xref:action_customization/enabledExpression.adoc[Enabled expressions]
+* xref:solutions/heating-control-panel/index.adoc[Heating Control Panel]
+* xref:solutions/human-in-the-control-loop/index.adoc[Human in the Control Loop]

Dosya farkı çok büyük olduğundan ihmal edildi
+ 502 - 358
frontend/package-lock.json


+ 13 - 13
frontend/package.json

@@ -5,13 +5,13 @@
 	"repository": "https://github.com/OliveTin/OliveTin",
 	"type": "module",
 	"devDependencies": {
-		"@vitejs/plugin-vue": "^6.0.8",
-		"eslint": "^9.39.5",
-		"eslint-plugin-vue": "^10.11.0",
-		"neostandard": "^0.13.0",
-		"stylelint": "^17.15.0",
+		"@vitejs/plugin-vue": "^6.0.10",
+		"eslint": "^10.13.0",
+		"eslint-plugin-vue": "^10.11.1",
+		"neostandard": "^0.14.1",
+		"stylelint": "^17.16.0",
 		"stylelint-config-standard": "^40.0.0",
-		"vite": "^8.2.2"
+		"vite": "^8.3.5"
 	},
 	"scripts": {
 		"build": "vite build",
@@ -25,18 +25,18 @@
 	"dependencies": {
 		"@connectrpc/connect": "^2.2.0",
 		"@connectrpc/connect-web": "^2.2.0",
-		"@hugeicons/core-free-icons": "^4.3.2",
+		"@hugeicons/core-free-icons": "^4.3.5",
 		"@hugeicons/vue": "^1.0.8",
 		"@xterm/addon-fit": "^0.11.0",
 		"@xterm/addon-web-links": "^0.12.0",
 		"@xterm/xterm": "^6.0.0",
-		"iconify-icon": "^3.0.2",
-		"picocrank": "^1.29.0",
-		"vue": "^3.5.42",
-		"vue-i18n": "^11.4.10",
-		"vue-router": "^5.3.1"
+		"iconify-icon": "^3.0.4",
+		"picocrank": "^1.37.0",
+		"vue": "^3.5.44",
+		"vue-i18n": "^11.4.16",
+		"vue-router": "^5.4.0"
 	},
 	"engines": {
-		"node": "^20.19.0 || >=22.12.0"
+		"node": "^22.13.0 || >=24"
 	}
 }

+ 1 - 1
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts

@@ -1,4 +1,4 @@
-// @generated by protoc-gen-es v2.14.1
+// @generated by protoc-gen-es v2.16.0
 // @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3)
 /* eslint-disable */
 

+ 1 - 1
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js

@@ -1,4 +1,4 @@
-// @generated by protoc-gen-es v2.14.1
+// @generated by protoc-gen-es v2.16.0
 // @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3)
 /* eslint-disable */
 

+ 2 - 0
frontend/resources/vue/components/ChoiceCombobox.vue

@@ -313,6 +313,8 @@ onBeforeUnmount(() => {
 
 .choice-combobox-input {
   width: 100%;
+  max-width: 100%;
+  box-sizing: border-box;
 }
 
 .choice-combobox-list {

+ 22 - 0
frontend/resources/vue/utils/argumentInputType.js

@@ -0,0 +1,22 @@
+const typesWithoutInputType = ['html', 'raw_string_multiline', 'select']
+
+const htmlInputTypesByArgumentType = {
+  checkbox: 'checkbox',
+  confirmation: 'checkbox',
+  datetime: 'datetime-local',
+  email: 'email',
+  int: 'number',
+  password: 'password',
+  url: 'url'
+}
+
+// Argument types are OliveTin validation types, not HTML input types; anything
+// without a matching HTML type renders as text so that it is styled and the
+// browser does not have to guess.
+export function argumentInputType (arg) {
+  if (typesWithoutInputType.includes(arg.type)) {
+    return undefined
+  }
+
+  return htmlInputTypesByArgumentType[arg.type] ?? 'text'
+}

+ 29 - 0
frontend/resources/vue/utils/argumentInputType.test.mjs

@@ -0,0 +1,29 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import { argumentInputType } from './argumentInputType.js'
+
+test('argumentInputType renders int arguments as number inputs', () => {
+  assert.equal(argumentInputType({ type: 'int' }), 'number')
+})
+
+test('argumentInputType maps argument types to valid HTML input types', () => {
+  assert.equal(argumentInputType({ type: 'confirmation' }), 'checkbox')
+  assert.equal(argumentInputType({ type: 'checkbox' }), 'checkbox')
+  assert.equal(argumentInputType({ type: 'datetime' }), 'datetime-local')
+  assert.equal(argumentInputType({ type: 'email' }), 'email')
+  assert.equal(argumentInputType({ type: 'password' }), 'password')
+  assert.equal(argumentInputType({ type: 'url' }), 'url')
+})
+
+test('argumentInputType renders validation-only types as text inputs', () => {
+  for (const type of ['', 'ascii', 'ascii_identifier', 'ascii_sentence', 'dnsname', 'shell_safe_identifier', 'unicode_identifier', 'very_dangerous_raw_string', 'regex:^[a-z]+$']) {
+    assert.equal(argumentInputType({ type }), 'text', type)
+  }
+})
+
+test('argumentInputType has no input type for non-input arguments', () => {
+  for (const type of ['html', 'raw_string_multiline', 'select']) {
+    assert.equal(argumentInputType({ type }), undefined, type)
+  }
+})

+ 17 - 22
frontend/resources/vue/views/ArgumentForm.vue

@@ -87,7 +87,7 @@
               :value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
               :checked="(arg.type === 'checkbox' || arg.type === 'confirmation') ? getArgumentValue(arg) : undefined"
               :list="(arg.suggestions || getBrowserSuggestions(arg).length > 0) ? argumentFieldChoicesId(arg.name) : undefined"
-              :type="getInputComponent(arg) !== 'select' ? getInputType(arg) : undefined"
+              :type="getInputComponent(arg) !== 'select' ? argumentInputType(arg) : undefined"
               :rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
               :step="arg.type === 'datetime' ? 1 : undefined"
               :pattern="getPattern(arg)"
@@ -158,6 +158,7 @@ import {
   argumentFieldId,
   argumentFieldValidationElementId
 } from '../utils/argumentFieldIds.js'
+import { argumentInputType } from '../utils/argumentInputType.js'
 import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from '../utils/prefilledArguments.js'
 
 const router = useRouter()
@@ -285,26 +286,6 @@ function getInputComponent (arg) {
   }
 }
 
-function getInputType (arg) {
-  if (arg.type === 'html' || arg.type === 'raw_string_multiline' || arg.type === 'select') {
-    return undefined
-  }
-
-  if (arg.type === 'confirmation') {
-    return 'checkbox'
-  }
-
-  if (arg.type === 'ascii_identifier' || arg.type === 'dnsname' || arg.type === 'shell_safe_identifier' || arg.type === 'ascii' || arg.type === 'ascii_sentence') {
-    return 'text'
-  }
-
-  if (arg.type === 'datetime') {
-    return 'datetime-local'
-  }
-
-  return arg.type
-}
-
 function getPattern (arg) {
   if (arg.type && arg.type.startsWith('regex:')) {
     return arg.type.replace('regex:', '')
@@ -700,7 +681,21 @@ onUnmounted(() => {
 }
 
 form {
-  grid-template-columns: max-content auto auto;
+  grid-template-columns: minmax(0, max-content) minmax(0, auto) minmax(0, auto);
+}
+
+@media (max-width: 600px) {
+  form {
+    grid-template-columns: minmax(0, 1fr);
+  }
+
+  form input:not([type="checkbox"]):not([type="radio"]),
+  form textarea,
+  form select {
+    width: 100%;
+    max-width: 100%;
+    box-sizing: border-box;
+  }
 }
 
 .argument-description {

+ 212 - 153
integration-tests/package-lock.json

@@ -9,24 +9,15 @@
       "version": "1.0.0",
       "license": "AGPL-3.0-only",
       "dependencies": {
-        "wait-on": "^9.1.0"
+        "wait-on": "^9.5.1"
       },
       "devDependencies": {
         "@eslint/js": "^10.0.1",
-        "chai": "^6.2.2",
-        "eslint": "^10.10.0",
-        "globals": "^17.3.0",
-        "mocha": "^12.0.0",
-        "selenium-webdriver": "^4.49.0"
-      }
-    },
-    "node_modules/@aashutoshrathi/word-wrap": {
-      "version": "1.2.6",
-      "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz",
-      "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==",
-      "dev": true,
-      "engines": {
-        "node": ">=0.10.0"
+        "chai": "^6.3.0",
+        "eslint": "^10.13.0",
+        "globals": "^17.13.0",
+        "mocha": "^12.0.3",
+        "selenium-webdriver": "^4.51.0"
       }
     },
     "node_modules/@bazel/runfiles": {
@@ -61,9 +52,9 @@
       }
     },
     "node_modules/@eslint-community/eslint-utils": {
-      "version": "4.9.0",
-      "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
-      "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
+      "version": "4.10.1",
+      "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
+      "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -279,6 +270,7 @@
       "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
       "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
       "dev": true,
+      "license": "Apache-2.0",
       "engines": {
         "node": ">=12.22"
       },
@@ -288,9 +280,9 @@
       }
     },
     "node_modules/@humanwhocodes/retry": {
-      "version": "0.4.2",
-      "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz",
-      "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==",
+      "version": "0.4.3",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+      "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
       "dev": true,
       "license": "Apache-2.0",
       "engines": {
@@ -339,9 +331,9 @@
       "license": "MIT"
     },
     "node_modules/@types/estree": {
-      "version": "1.0.8",
-      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
-      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "version": "1.0.9",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+      "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
       "dev": true,
       "license": "MIT"
     },
@@ -353,9 +345,9 @@
       "license": "MIT"
     },
     "node_modules/acorn": {
-      "version": "8.16.0",
-      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
-      "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+      "version": "8.19.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
+      "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
       "dev": true,
       "license": "MIT",
       "bin": {
@@ -388,9 +380,9 @@
       }
     },
     "node_modules/ajv": {
-      "version": "6.14.0",
-      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
-      "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
+      "version": "6.15.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+      "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -405,11 +397,21 @@
       }
     },
     "node_modules/argparse": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
-      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.3.tgz",
+      "integrity": "sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w==",
       "dev": true,
-      "license": "Python-2.0"
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/puzrin"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/nodeca"
+        }
+      ],
+      "license": "PSF-2.0"
     },
     "node_modules/asynckit": {
       "version": "0.4.0",
@@ -418,45 +420,46 @@
       "license": "MIT"
     },
     "node_modules/axios": {
-      "version": "1.18.1",
-      "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
-      "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
+      "version": "1.20.0",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
+      "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
       "license": "MIT",
       "dependencies": {
         "follow-redirects": "^1.16.0",
-        "form-data": "^4.0.5",
+        "form-data": "^4.0.6",
         "https-proxy-agent": "^5.0.1",
         "proxy-from-env": "^2.1.0"
       }
     },
-    "node_modules/brace-expansion": {
-      "version": "5.0.9",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
-      "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+    "node_modules/balanced-match": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+      "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
       "dev": true,
       "license": "MIT",
-      "dependencies": {
-        "balanced-match": "^4.0.2"
-      },
       "engines": {
-        "node": "20 || >=22"
+        "node": "18 || 20 || >=22"
       }
     },
-    "node_modules/brace-expansion/node_modules/balanced-match": {
-      "version": "4.0.4",
-      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
-      "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+    "node_modules/brace-expansion": {
+      "version": "5.0.12",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+      "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
       "dev": true,
       "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^4.0.2"
+      },
       "engines": {
-        "node": "18 || 20 || >=22"
+        "node": "20 || >=22"
       }
     },
     "node_modules/browser-stdout": {
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz",
       "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==",
-      "dev": true
+      "dev": true,
+      "license": "ISC"
     },
     "node_modules/cacheable": {
       "version": "2.5.0",
@@ -486,9 +489,9 @@
       }
     },
     "node_modules/chai": {
-      "version": "6.2.2",
-      "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
-      "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
+      "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -527,7 +530,8 @@
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
       "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
-      "dev": true
+      "dev": true,
+      "license": "MIT"
     },
     "node_modules/cross-spawn": {
       "version": "7.0.6",
@@ -545,9 +549,9 @@
       }
     },
     "node_modules/debug": {
-      "version": "4.4.0",
-      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
-      "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
       "license": "MIT",
       "dependencies": {
         "ms": "^2.1.3"
@@ -565,7 +569,8 @@
       "version": "0.1.4",
       "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
       "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
-      "dev": true
+      "dev": true,
+      "license": "MIT"
     },
     "node_modules/delayed-stream": {
       "version": "1.0.0",
@@ -650,6 +655,7 @@
       "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
       "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
       "dev": true,
+      "license": "MIT",
       "engines": {
         "node": ">=10"
       },
@@ -658,9 +664,9 @@
       }
     },
     "node_modules/eslint": {
-      "version": "10.10.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz",
-      "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==",
+      "version": "10.13.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.13.0.tgz",
+      "integrity": "sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==",
       "dev": true,
       "license": "MIT",
       "workspaces": [
@@ -830,7 +836,8 @@
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
       "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
-      "dev": true
+      "dev": true,
+      "license": "MIT"
     },
     "node_modules/file-entry-cache": {
       "version": "11.1.5",
@@ -847,6 +854,7 @@
       "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
       "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
         "locate-path": "^6.0.0",
         "path-exists": "^4.0.0"
@@ -858,6 +866,19 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/find-up-simple": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz",
+      "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/flat-cache": {
       "version": "6.1.23",
       "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz",
@@ -878,9 +899,9 @@
       "license": "ISC"
     },
     "node_modules/follow-redirects": {
-      "version": "1.16.0",
-      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
-      "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+      "version": "1.16.1",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
+      "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
       "funding": [
         {
           "type": "individual",
@@ -982,6 +1003,7 @@
       "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
       "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
       "dev": true,
+      "license": "ISC",
       "dependencies": {
         "is-glob": "^4.0.3"
       },
@@ -990,9 +1012,9 @@
       }
     },
     "node_modules/globals": {
-      "version": "17.12.0",
-      "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz",
-      "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==",
+      "version": "17.13.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz",
+      "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -1019,6 +1041,7 @@
       "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
       "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
       "dev": true,
+      "license": "MIT",
       "engines": {
         "node": ">=8"
       }
@@ -1109,13 +1132,15 @@
       "version": "3.0.6",
       "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
       "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
-      "dev": true
+      "dev": true,
+      "license": "MIT"
     },
     "node_modules/imurmurhash": {
       "version": "0.1.4",
       "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
       "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
       "dev": true,
+      "license": "MIT",
       "engines": {
         "node": ">=0.8.19"
       }
@@ -1124,13 +1149,15 @@
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
       "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-      "dev": true
+      "dev": true,
+      "license": "ISC"
     },
     "node_modules/is-extglob": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
       "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
       "dev": true,
+      "license": "MIT",
       "engines": {
         "node": ">=0.10.0"
       }
@@ -1140,6 +1167,7 @@
       "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
       "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
         "is-extglob": "^2.1.1"
       },
@@ -1148,13 +1176,16 @@
       }
     },
     "node_modules/is-path-inside": {
-      "version": "3.0.3",
-      "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
-      "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz",
+      "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==",
       "dev": true,
       "license": "MIT",
       "engines": {
-        "node": ">=8"
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
       }
     },
     "node_modules/is-unicode-supported": {
@@ -1174,18 +1205,20 @@
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
       "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
-      "dev": true
+      "dev": true,
+      "license": "MIT"
     },
     "node_modules/isexe": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
       "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
-      "dev": true
+      "dev": true,
+      "license": "ISC"
     },
     "node_modules/joi": {
-      "version": "18.2.8",
-      "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.8.tgz",
-      "integrity": "sha512-G2TX62h58ZHuwqetJgP2F4ualakqAmZtBYe3jWen7gxQRw5xApX6crnFtuB91WC0c3ESBnva+kGSnb3+6pIQDQ==",
+      "version": "18.3.0",
+      "resolved": "https://registry.npmjs.org/joi/-/joi-18.3.0.tgz",
+      "integrity": "sha512-/RwAK4BAfzcdbdFg073tzq4/xhY6Q7lMd5AmjqTL6pu0QFJdEmtswgm8BvgwXvOKuPNYQ8XrdQrrW/gF65LiNw==",
       "license": "BSD-3-Clause",
       "dependencies": {
         "@hapi/address": "^5.1.1",
@@ -1201,9 +1234,9 @@
       }
     },
     "node_modules/js-yaml": {
-      "version": "5.4.1",
-      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.1.tgz",
-      "integrity": "sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==",
+      "version": "5.5.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.5.0.tgz",
+      "integrity": "sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg==",
       "dev": true,
       "funding": [
         {
@@ -1217,7 +1250,7 @@
       ],
       "license": "MIT",
       "dependencies": {
-        "argparse": "^2.0.1"
+        "argparse": "^3.0.3"
       },
       "bin": {
         "js-yaml": "bin/js-yaml.mjs"
@@ -1234,13 +1267,15 @@
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
       "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
-      "dev": true
+      "dev": true,
+      "license": "MIT"
     },
     "node_modules/jszip": {
-      "version": "3.10.1",
-      "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
-      "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+      "version": "3.10.2",
+      "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
+      "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
       "dev": true,
+      "license": "(MIT OR GPL-3.0-or-later)",
       "dependencies": {
         "lie": "~3.3.0",
         "pako": "~1.0.2",
@@ -1263,6 +1298,7 @@
       "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
       "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
         "prelude-ls": "^1.2.1",
         "type-check": "~0.4.0"
@@ -1276,6 +1312,7 @@
       "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
       "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
         "immediate": "~3.0.5"
       }
@@ -1285,6 +1322,7 @@
       "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
       "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
         "p-locate": "^5.0.0"
       },
@@ -1302,9 +1340,9 @@
       "license": "MIT"
     },
     "node_modules/lru-cache": {
-      "version": "11.5.2",
-      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
-      "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+      "version": "11.5.3",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
+      "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
       "dev": true,
       "license": "BlueOak-1.0.0",
       "engines": {
@@ -1342,13 +1380,13 @@
       }
     },
     "node_modules/minimatch": {
-      "version": "10.2.5",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
-      "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+      "version": "10.2.6",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+      "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
       "dev": true,
       "license": "BlueOak-1.0.0",
       "dependencies": {
-        "brace-expansion": "^5.0.5"
+        "brace-expansion": "^5.0.8"
       },
       "engines": {
         "node": "18 || 20 || >=22"
@@ -1357,14 +1395,6 @@
         "url": "https://github.com/sponsors/isaacs"
       }
     },
-    "node_modules/minimist": {
-      "version": "1.2.8",
-      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
-      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
     "node_modules/minipass": {
       "version": "7.1.3",
       "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
@@ -1376,9 +1406,9 @@
       }
     },
     "node_modules/mocha": {
-      "version": "12.0.0",
-      "resolved": "https://registry.npmjs.org/mocha/-/mocha-12.0.0.tgz",
-      "integrity": "sha512-NYNh5IFt6WYqm9bi4601m7vix8MZdXC0DwS4gY6WhXO2RgJWhivhISVmq1oklCif18OSI9l+vx5Mdm5oh1XGiQ==",
+      "version": "12.0.3",
+      "resolved": "https://registry.npmjs.org/mocha/-/mocha-12.0.3.tgz",
+      "integrity": "sha512-beammieDdiWdkjANWeWPpkxUROYGi5FN88fPOkLiticz7HTg3Y+Pho3EYE6SUdcuWnZ3h2WD8EXCA55JF4o6wg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -1386,15 +1416,15 @@
         "chokidar": "^5.0.0",
         "debug": "^4.3.5",
         "diff": "^9.0.0",
-        "find-up": "^5.0.0",
+        "find-up-simple": "^1.0.1",
         "glob": "^13.0.0",
-        "is-path-inside": "^3.0.3",
+        "is-path-inside": "^4.0.0",
         "is-unicode-supported": "^0.1.0",
         "js-yaml": "^5.0.0",
         "minimatch": "^10.2.2",
         "ms": "^2.1.3",
         "picocolors": "^1.1.1",
-        "serialize-javascript": "^7.0.2",
+        "serialize-javascript": "^7.1.1",
         "strip-json-comments": "^5.0.3",
         "supports-color": "^8.1.1",
         "workerpool": "^10.0.0"
@@ -1406,21 +1436,6 @@
         "node": "^20.19.0 || >=22.12.0"
       }
     },
-    "node_modules/mocha/node_modules/supports-color": {
-      "version": "8.1.1",
-      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
-      "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
-      "dev": true,
-      "dependencies": {
-        "has-flag": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/supports-color?sponsor=1"
-      }
-    },
     "node_modules/ms": {
       "version": "2.1.3",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1431,20 +1446,22 @@
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
       "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
-      "dev": true
+      "dev": true,
+      "license": "MIT"
     },
     "node_modules/optionator": {
-      "version": "0.9.3",
-      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
-      "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==",
+      "version": "0.9.4",
+      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+      "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
-        "@aashutoshrathi/word-wrap": "^1.2.3",
         "deep-is": "^0.1.3",
         "fast-levenshtein": "^2.0.6",
         "levn": "^0.4.1",
         "prelude-ls": "^1.2.1",
-        "type-check": "^0.4.0"
+        "type-check": "^0.4.0",
+        "word-wrap": "^1.2.5"
       },
       "engines": {
         "node": ">= 0.8.0"
@@ -1455,6 +1472,7 @@
       "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
       "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
         "yocto-queue": "^0.1.0"
       },
@@ -1470,6 +1488,7 @@
       "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
       "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
         "p-limit": "^3.0.2"
       },
@@ -1484,13 +1503,15 @@
       "version": "1.0.11",
       "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
       "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
-      "dev": true
+      "dev": true,
+      "license": "(MIT AND Zlib)"
     },
     "node_modules/path-exists": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
       "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
       "dev": true,
+      "license": "MIT",
       "engines": {
         "node": ">=8"
       }
@@ -1500,6 +1521,7 @@
       "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
       "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
       "dev": true,
+      "license": "MIT",
       "engines": {
         "node": ">=8"
       }
@@ -1533,6 +1555,7 @@
       "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
       "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
       "dev": true,
+      "license": "MIT",
       "engines": {
         "node": ">= 0.8.0"
       }
@@ -1541,7 +1564,8 @@
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
       "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
-      "dev": true
+      "dev": true,
+      "license": "MIT"
     },
     "node_modules/proxy-from-env": {
       "version": "2.1.0",
@@ -1587,6 +1611,7 @@
       "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
       "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
         "core-util-is": "~1.0.0",
         "inherits": "~2.0.3",
@@ -1624,12 +1649,13 @@
       "version": "5.1.2",
       "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
       "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
-      "dev": true
+      "dev": true,
+      "license": "MIT"
     },
     "node_modules/selenium-webdriver": {
-      "version": "4.49.0",
-      "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.49.0.tgz",
-      "integrity": "sha512-16XqeOEMj+4+p+pzLLyvNWmF5jCY05EKIl3e1anZBgv4+zq7PUTtiNjKtQhMpwosZ0wIMKDFAQ2xdyogOHD4DA==",
+      "version": "4.51.0",
+      "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.51.0.tgz",
+      "integrity": "sha512-2FY/x+THHk9uWj+WLBib0r+53D1G0pidox796H5AfbRHhJdPc7CVQPNJi31ip1Tb9duBYRw0zX0wJovnbwSDHg==",
       "dev": true,
       "funding": [
         {
@@ -1644,18 +1670,18 @@
       "license": "Apache-2.0",
       "dependencies": {
         "@bazel/runfiles": "^6.5.0",
-        "jszip": "^3.10.1",
+        "jszip": "^3.10.2",
         "tmp": "^0.2.7",
-        "ws": "^8.21.3"
+        "ws": "^8.22.0"
       },
       "engines": {
         "node": ">= 22.0.0"
       }
     },
     "node_modules/serialize-javascript": {
-      "version": "7.1.1",
-      "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.1.tgz",
-      "integrity": "sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==",
+      "version": "7.1.3",
+      "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.3.tgz",
+      "integrity": "sha512-kYEnegpAxWFjUw3dSYdSM9zVlTrsxDbsSkq/qd3B7VanqyJ+JdDy6qVyuqVGBhSfuD/rLZQEKuXsO5KZFCrVYQ==",
       "dev": true,
       "license": "BSD-3-Clause",
       "engines": {
@@ -1666,13 +1692,15 @@
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
       "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
-      "dev": true
+      "dev": true,
+      "license": "MIT"
     },
     "node_modules/shebang-command": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
       "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
         "shebang-regex": "^3.0.0"
       },
@@ -1685,6 +1713,7 @@
       "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
       "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
       "dev": true,
+      "license": "MIT",
       "engines": {
         "node": ">=8"
       }
@@ -1694,6 +1723,7 @@
       "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
       "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
         "safe-buffer": "~5.1.0"
       }
@@ -1711,6 +1741,22 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/supports-color": {
+      "version": "8.1.1",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+      "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/supports-color?sponsor=1"
+      }
+    },
     "node_modules/tmp": {
       "version": "0.2.7",
       "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
@@ -1732,6 +1778,7 @@
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
       "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
         "prelude-ls": "^1.2.1"
       },
@@ -1753,18 +1800,18 @@
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
       "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
-      "dev": true
+      "dev": true,
+      "license": "MIT"
     },
     "node_modules/wait-on": {
-      "version": "9.1.0",
-      "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.1.0.tgz",
-      "integrity": "sha512-PymrLXHLBM1Ju/Xspb2ADUhbPSMvbnuNvy/mN2hWtpbJ3da0h3Ky1LqwKPG5QSVR57liyO0iUpfipYl/s5qNvA==",
+      "version": "9.5.1",
+      "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.5.1.tgz",
+      "integrity": "sha512-bOtj1wRY6u+GPnNskZLcgZNHOaL5NC4+5hRCbEpVoVX6MbMrCLCqIBM2bjsAfrgst3ZC0siIo7vLb4LuYFfIow==",
       "license": "MIT",
       "dependencies": {
-        "axios": "^1.18.1",
-        "joi": "^18.2.3",
+        "axios": "^1.20.0",
+        "joi": "^18.2.9",
         "lodash": "^4.18.1",
-        "minimist": "^1.2.8",
         "rxjs": "^7.8.2"
       },
       "bin": {
@@ -1779,6 +1826,7 @@
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
       "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
       "dev": true,
+      "license": "ISC",
       "dependencies": {
         "isexe": "^2.0.0"
       },
@@ -1789,6 +1837,16 @@
         "node": ">= 8"
       }
     },
+    "node_modules/word-wrap": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+      "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/workerpool": {
       "version": "10.0.3",
       "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-10.0.3.tgz",
@@ -1797,9 +1855,9 @@
       "license": "Apache-2.0"
     },
     "node_modules/ws": {
-      "version": "8.21.3",
-      "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
-      "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
+      "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -1823,6 +1881,7 @@
       "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
       "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
       "dev": true,
+      "license": "MIT",
       "engines": {
         "node": ">=10"
       },

+ 6 - 6
integration-tests/package.json

@@ -14,13 +14,13 @@
   "license": "AGPL-3.0-only",
   "devDependencies": {
     "@eslint/js": "^10.0.1",
-    "chai": "^6.2.2",
-    "eslint": "^10.10.0",
-    "globals": "^17.3.0",
-    "mocha": "^12.0.0",
-    "selenium-webdriver": "^4.49.0"
+    "chai": "^6.3.0",
+    "eslint": "^10.13.0",
+    "globals": "^17.13.0",
+    "mocha": "^12.0.3",
+    "selenium-webdriver": "^4.51.0"
   },
   "dependencies": {
-    "wait-on": "^9.1.0"
+    "wait-on": "^9.5.1"
   }
 }

+ 2 - 2
lang/go.mod

@@ -3,9 +3,9 @@ module github.com/OliveTin/OliveTin/langtool
 go 1.26.0
 
 require (
-	github.com/jamesread/golure v0.0.0-20260902093223-27b18778c9a5
+	github.com/jamesread/golure v0.0.0-20260927082702-900b761e432f
 	github.com/sirupsen/logrus v1.10.2
 	gopkg.in/yaml.v3 v3.0.1
 )
 
-require golang.org/x/sys v0.48.0 // indirect
+require golang.org/x/sys v0.49.0 // indirect

+ 4 - 4
lang/go.sum

@@ -1,13 +1,13 @@
-github.com/jamesread/golure v0.0.0-20260902093223-27b18778c9a5 h1:AN7u+AYEs94l0CFrAavOcCsriZAiF+sJCmb0gXfRulk=
-github.com/jamesread/golure v0.0.0-20260902093223-27b18778c9a5/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE=
+github.com/jamesread/golure v0.0.0-20260927082702-900b761e432f h1:9eoL7vPEKO2JXRJjfLCmONS5jGpMXMsQzHwjTIOpN98=
+github.com/jamesread/golure v0.0.0-20260927082702-900b761e432f/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE=
 github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
 github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
 github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
 github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
 go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
 go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
-golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
-golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
+golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

+ 109 - 0
logo.svg

@@ -0,0 +1,109 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="100mm"
+   height="100mm"
+   viewBox="0 0 100 100"
+   version="1.1"
+   id="svg8"
+   inkscape:version="1.0.2 (e86c870879, 2021-01-15)"
+   sodipodi:docname="OliveTinLogo.svg"
+   inkscape:export-filename="/home/xconspirisist/sandbox/OliveTin/webui/OliveTinLogo.png"
+   inkscape:export-xdpi="96"
+   inkscape:export-ydpi="96">
+  <defs
+     id="defs2" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="1.979899"
+     inkscape:cx="219.78495"
+     inkscape:cy="104.46496"
+     inkscape:document-units="mm"
+     inkscape:current-layer="layer1"
+     inkscape:document-rotation="0"
+     showgrid="false"
+     inkscape:pagecheckerboard="true"
+     inkscape:window-width="1600"
+     inkscape:window-height="1200"
+     inkscape:window-x="3520"
+     inkscape:window-y="1080"
+     inkscape:window-maximized="1" />
+  <metadata
+     id="metadata5">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1">
+    <path
+       id="rect1402"
+       style="fill:#faffff;fill-opacity:1;stroke:#000000;stroke-width:5.79097;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       d="m 24.325719,13.460641 h 53.884802 v 74.866538 c 0,0 -0.333558,7.417739 -26.071829,7.668294 -25.738271,0.250554 -27.812973,-7.668294 -27.812973,-7.668294 z"
+       sodipodi:nodetypes="ccczcc" />
+    <path
+       id="rect1469-6"
+       style="fill:#72b7f4;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       d="M 27.040421,58.263084 50.9643,61.8872 75.494083,58.263084 v 29.12149 c 0,0 -0.404835,6.088102 -24.431009,6.343392 -24.026173,0.25529 -24.022653,-6.343392 -24.022653,-6.343392 z"
+       sodipodi:nodetypes="cccczcc" />
+    <path
+       id="rect1469-7"
+       style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       d="m 26.873646,41.794388 23.923879,2.635333 24.529783,-2.635333 v 21.176145 c 0,0 -0.404835,4.427058 -24.431009,4.612696 -24.026173,0.185639 -24.022653,-4.612696 -24.022653,-4.612696 z"
+       sodipodi:nodetypes="cccczcc" />
+    <path
+       id="rect1469"
+       style="fill:#72b7f4;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       d="m 26.873646,16.335179 23.923879,3.168352 24.529783,-3.168352 v 25.459209 c 0,0 -0.404835,5.32247 -24.431009,5.545655 -24.026173,0.223185 -24.022653,-5.545655 -24.022653,-5.545655 z"
+       sodipodi:nodetypes="cccczcc" />
+    <ellipse
+       style="fill:#588000;fill-opacity:1;stroke:#000000;stroke-width:4.00217;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       id="path1436"
+       ry="19.291208"
+       rx="13.695867"
+       cy="28.679092"
+       cx="71.078308"
+       transform="rotate(25.917116)" />
+    <ellipse
+       style="fill:#aa0000;fill-opacity:1;stroke:#000000;stroke-width:1.84188;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       id="path1436-5"
+       ry="4.4607549"
+       rx="4.933064"
+       cy="35.115124"
+       cx="-50.609451"
+       transform="matrix(-0.49066507,-0.87134826,0.99928809,-0.03772683,0,0)" />
+    <ellipse
+       style="fill:#e6e6e6;fill-opacity:1;stroke:#000000;stroke-width:5.79097;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       id="path1440"
+       cx="51.2528"
+       cy="10.636649"
+       rx="26.897503"
+       ry="6.525641" />
+    <path
+       id="path1436-3"
+       style="fill:#e6e6e6;fill-opacity:1;stroke:#000000;stroke-width:0.574412;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       inkscape:transform-center-x="-1.1166908"
+       inkscape:transform-center-y="0.49206772"
+       d="m 36.494243,8.7784525 c 1.841786,-0.1158042 2.469235,0.5295096 2.656071,1.4191125 0.186835,0.889603 -0.0974,1.788639 -1.939188,1.904443 -0.848904,0.05338 -2.422996,-0.462298 -3.064668,-0.677573 -0.750505,-0.251787 -1.565122,-0.327933 -1.665841,-0.807506 -0.09913,-0.472029 0.614422,-0.7401021 1.22232,-1.0768269 0.537767,-0.2978804 1.926784,-0.7072919 2.791306,-0.7616496 z"
+       sodipodi:nodetypes="sssssss" />
+  </g>
+</svg>

+ 1 - 1
proto/buf.gen.yaml

@@ -4,7 +4,7 @@ plugins:
     out: ../service/gen/
     opt: paths=source_relative
 
-  - remote: buf.build/connectrpc/go
+  - remote: buf.build/connectrpc/go:v1.21.0
     out: ../service/gen/
     opt: paths=source_relative
 

+ 6 - 0
renovate.json

@@ -0,0 +1,6 @@
+{
+  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+  "extends": ["config:recommended", ":semanticCommits", "group:allNonMajor"],
+  "schedule": ["before 6am on monday"],
+  "postUpdateOptions": ["gomodTidy"]
+}

+ 3 - 3
service/.golangci.yml

@@ -28,15 +28,15 @@ linters:
     - govet
     - ineffassign
     - misspell
-#    - modernize
+    - modernize
     - nilerr
     - noctx
 #    - promlinter
     - staticcheck
-#    - testifylint
+    - testifylint
     - thelper
     - unconvert
-#    - unparam
+    - unparam
     - unused
     - usestdlibvars
   settings:

+ 77 - 76
service/go.mod

@@ -1,6 +1,6 @@
 module github.com/OliveTin/OliveTin
 
-go 1.26.0
+go 1.26.7
 
 exclude google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884
 
@@ -8,58 +8,59 @@ exclude google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884
 exclude github.com/alecthomas/go-check-sumtype v0.5.0
 
 require (
-	connectrpc.com/connect v1.21.0
+	connectrpc.com/connect v1.21.1
 	github.com/Masterminds/semver v1.5.0
 	github.com/MicahParks/keyfunc/v3 v3.8.2
 	github.com/PaesslerAG/jsonpath v0.1.1
 	github.com/alexedwards/argon2id v1.0.0
-	github.com/bufbuild/buf v1.72.0
+	github.com/bufbuild/buf v1.75.0
 	github.com/expr-lang/expr v1.17.8
 	github.com/fsnotify/fsnotify v1.10.1
 	github.com/go-viper/mapstructure/v2 v2.5.0
 	github.com/golang-jwt/jwt/v5 v5.3.1
-	github.com/golangci/golangci-lint/v2 v2.13.2
+	github.com/golangci/golangci-lint/v2 v2.15.0
 	github.com/google/uuid v1.6.0
-	github.com/jamesread/golure v0.0.0-20260902093223-27b18778c9a5
+	github.com/jamesread/golure v0.0.0-20260927082702-900b761e432f
 	github.com/knadh/koanf/parsers/yaml v1.1.1
 	github.com/knadh/koanf/providers/env v1.1.0
 	github.com/knadh/koanf/providers/file v1.2.1
 	github.com/knadh/koanf/providers/rawbytes v1.0.0
-	github.com/knadh/koanf/v2 v2.3.6
-	github.com/prometheus/client_golang v1.24.1
+	github.com/knadh/koanf/v2 v2.4.1
+	github.com/prometheus/client_golang v1.25.0
 	github.com/robfig/cron/v3 v3.0.1
 	github.com/sirupsen/logrus v1.10.2
 	github.com/stretchr/testify v1.12.1
 	go.akshayshah.org/connectproto v0.6.0
 	golang.org/x/oauth2 v0.37.0
-	golang.org/x/sys v0.48.0
-	google.golang.org/protobuf v1.36.12
+	golang.org/x/sys v0.49.0
+	google.golang.org/protobuf v1.36.13-0.20261016172727-e9ee2a76ab8e
 	gopkg.in/yaml.v3 v3.0.1
 )
 
 require (
 	4d63.com/gocheckcompilerdirectives v1.4.0 // indirect
 	4d63.com/gochecknoglobals v0.2.2 // indirect
-	buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2 // indirect
-	buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20250109164928-1da0de137947.2 // indirect
+	buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20261002170247-538130002972.2 // indirect
+	buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20261012172039-ff528c23443f.2 // indirect
 	buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect
-	buf.build/gen/go/bufbuild/registry/connectrpc/go v1.21.0-20260831211851-b4432e12a6e7.1 // indirect
-	buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20260831211851-b4432e12a6e7.2 // indirect
+	buf.build/gen/go/bufbuild/registry/connectrpc/go/v2 v2.0.1-20261021182822-0b1ad74125ef.1 // indirect
+	buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20261021182822-0b1ad74125ef.2 // indirect
 	buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.12-20241007202033-cf42259fcbfc.2 // indirect
-	buf.build/go/app v0.2.1-0.20260626143626-be153867abea // indirect
-	buf.build/go/bufplugin v0.10.0 // indirect
+	buf.build/go/app v0.3.0 // indirect
+	buf.build/go/bufplugin v0.11.1 // indirect
 	buf.build/go/bufprivateusage v0.1.0 // indirect
 	buf.build/go/interrupt v1.1.0 // indirect
 	buf.build/go/protovalidate v1.4.0 // indirect
 	buf.build/go/protoyaml v0.7.0 // indirect
-	buf.build/go/spdx v0.2.0 // indirect
+	buf.build/go/spdx v0.3.0 // indirect
 	buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect
 	cel.dev/cel-go v0.32.0 // indirect
 	cel.dev/expr v0.25.3 // indirect
 	charm.land/lipgloss/v2 v2.0.6 // indirect
 	codeberg.org/chavacava/garif v0.2.1 // indirect
 	codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
-	connectrpc.com/otelconnect v0.9.0 // indirect
+	connectrpc.com/connect/v2 v2.0.2 // indirect
+	connectrpc.com/otelconnect v0.12.0 // indirect
 	dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
 	dev.gaijin.team/go/exhaustruct/v5 v5.2.0 // indirect
 	dev.gaijin.team/go/golib v0.8.1 // indirect
@@ -75,12 +76,12 @@ require (
 	github.com/Djarvur/go-err113 v0.1.1 // indirect
 	github.com/Masterminds/semver/v3 v3.5.0 // indirect
 	github.com/MicahParks/jwkset v0.11.3 // indirect
-	github.com/Microsoft/go-winio v0.6.2 // indirect
+	github.com/Microsoft/go-winio v0.6.4 // indirect
 	github.com/MirrexOne/unqueryvet v1.5.4 // indirect
 	github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
 	github.com/PaesslerAG/gval v1.2.4 // indirect
 	github.com/alecthomas/chroma/v2 v2.27.0 // indirect
-	github.com/alecthomas/go-check-sumtype v0.4.0 // indirect
+	github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // indirect
 	github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
 	github.com/alexkohler/prealloc v1.1.0 // indirect
 	github.com/alfatraining/structtag v1.0.0 // indirect
@@ -94,9 +95,9 @@ require (
 	github.com/blizzy78/varnamelen v0.8.0 // indirect
 	github.com/bombsimon/wsl/v4 v4.7.0 // indirect
 	github.com/bombsimon/wsl/v5 v5.9.0 // indirect
-	github.com/breml/bidichk v0.3.3 // indirect
-	github.com/breml/errchkjson v0.4.1 // indirect
-	github.com/bufbuild/protocompile v0.14.2-0.20260716165721-bb5762d29672 // indirect
+	github.com/breml/bidichk v0.3.4 // indirect
+	github.com/breml/errchkjson v0.4.2 // indirect
+	github.com/bufbuild/protocompile v0.14.2-0.20261020145055-56248e843b50 // indirect
 	github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b // indirect
 	github.com/butuzov/ireturn v0.4.1 // indirect
 	github.com/butuzov/mirror v1.3.3 // indirect
@@ -105,8 +106,8 @@ require (
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/charithe/durationcheck v0.0.11 // indirect
 	github.com/charmbracelet/colorprofile v0.4.3 // indirect
-	github.com/charmbracelet/ultraviolet v0.0.0-20260906173415-0277a179edd9 // indirect
-	github.com/charmbracelet/x/ansi v0.11.8 // indirect
+	github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
+	github.com/charmbracelet/x/ansi v0.11.9 // indirect
 	github.com/charmbracelet/x/term v0.2.2 // indirect
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
 	github.com/charmbracelet/x/windows v0.2.2 // indirect
@@ -119,13 +120,13 @@ require (
 	github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
 	github.com/curioswitch/go-reassign v0.3.0 // indirect
 	github.com/daixiang0/gci v0.14.0 // indirect
-	github.com/dave/dst v0.27.4 // indirect
+	github.com/dave/dst v0.28.0 // indirect
 	github.com/denis-tingaikin/go-header v1.0.0 // indirect
 	github.com/distribution/reference v0.6.0 // indirect
-	github.com/dlclark/regexp2/v2 v2.8.0 // indirect
-	github.com/docker/cli v29.8.0+incompatible // indirect
-	github.com/docker/docker-credential-helpers v0.9.9 // indirect
-	github.com/docker/go-connections v0.8.1 // indirect
+	github.com/dlclark/regexp2/v2 v2.9.1 // indirect
+	github.com/docker/cli v29.9.2+incompatible // indirect
+	github.com/docker/docker-credential-helpers v0.9.12 // indirect
+	github.com/docker/go-connections v0.8.2 // indirect
 	github.com/docker/go-units v0.5.0 // indirect
 	github.com/ettle/strcase v0.2.0 // indirect
 	github.com/fatih/color v1.19.0 // indirect
@@ -150,18 +151,17 @@ require (
 	github.com/godoc-lint/godoc-lint v0.11.4 // indirect
 	github.com/gofrs/flock v0.13.1 // indirect
 	github.com/golangci/asciicheck v0.5.0 // indirect
-	github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6 // indirect
+	github.com/golangci/canonicalheader v0.0.0-20260928145534-e18e5c5a078f // indirect
 	github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
 	github.com/golangci/go-printf-func-name v0.1.1 // indirect
 	github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 // indirect
-	github.com/golangci/golines v0.15.0 // indirect
+	github.com/golangci/golines v0.16.0 // indirect
 	github.com/golangci/misspell v0.8.0 // indirect
 	github.com/golangci/plugin-module-register v0.1.2 // indirect
 	github.com/golangci/revgrep v0.8.0 // indirect
-	github.com/golangci/rowserrcheck v0.0.0-20260901154044-241134db7241 // indirect
+	github.com/golangci/rowserrcheck v0.0.0-20261005233414-e0740d52dee8 // indirect
 	github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
 	github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
-	github.com/google/cel-go v0.31.0 // indirect
 	github.com/google/go-cmp v0.7.0 // indirect
 	github.com/google/go-containerregistry v0.22.1 // indirect
 	github.com/gordonklaus/ineffassign v0.2.0 // indirect
@@ -179,18 +179,18 @@ require (
 	github.com/jjti/go-spancheck v0.6.5 // indirect
 	github.com/julz/importas v0.2.0 // indirect
 	github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
-	github.com/kisielk/errcheck v1.20.0 // indirect
+	github.com/kisielk/errcheck v1.30.0 // indirect
 	github.com/kkHAIKE/contextcheck v1.1.6 // indirect
-	github.com/klauspost/compress v1.20.0 // indirect
-	github.com/klauspost/pgzip v1.2.6 // indirect
+	github.com/klauspost/compress v1.20.3 // indirect
+	github.com/klauspost/pgzip v1.3.0 // indirect
 	github.com/knadh/koanf/maps v0.1.3 // indirect
 	github.com/kulti/thelper v0.7.1 // indirect
-	github.com/kunwardeep/paralleltest v1.0.15 // indirect
+	github.com/kunwardeep/paralleltest v1.0.16 // indirect
 	github.com/ldez/exptostd v0.4.5 // indirect
-	github.com/ldez/gomoddirectives v0.9.0 // indirect
+	github.com/ldez/gomoddirectives v0.10.0 // indirect
 	github.com/ldez/grignotin v0.10.1 // indirect
 	github.com/ldez/structtags v0.6.1 // indirect
-	github.com/ldez/tagliatelle v0.7.2 // indirect
+	github.com/ldez/tagliatelle v0.8.0 // indirect
 	github.com/ldez/usetesting v0.5.0 // indirect
 	github.com/leonklingele/grouper v1.1.2 // indirect
 	github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
@@ -200,30 +200,30 @@ require (
 	github.com/maratori/testableexamples v1.0.1 // indirect
 	github.com/maratori/testpackage v1.1.2 // indirect
 	github.com/matoous/godox v1.1.0 // indirect
-	github.com/mattn/go-colorable v0.1.15 // indirect
+	github.com/mattn/go-colorable v0.1.16 // indirect
 	github.com/mattn/go-isatty v0.0.24 // indirect
-	github.com/mattn/go-runewidth v0.0.30 // indirect
-	github.com/mgechev/revive v1.16.0 // indirect
+	github.com/mattn/go-runewidth v0.0.31 // indirect
+	github.com/mgechev/revive v1.17.1 // indirect
 	github.com/mitchellh/copystructure v1.2.0 // indirect
 	github.com/mitchellh/go-homedir v1.1.0 // indirect
 	github.com/mitchellh/reflectwalk v1.0.2 // indirect
 	github.com/moby/docker-image-spec v1.3.1 // indirect
-	github.com/moby/moby/api v1.56.0 // indirect
-	github.com/moby/moby/client v0.6.0 // indirect
+	github.com/moby/moby/api v1.56.1 // indirect
+	github.com/moby/moby/client v0.6.2 // indirect
 	github.com/moricho/tparallel v0.3.2 // indirect
 	github.com/muesli/cancelreader v0.2.2 // indirect
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
 	github.com/nakabonne/nestif v0.3.1 // indirect
-	github.com/nishanths/exhaustive v0.12.0 // indirect
-	github.com/nishanths/predeclared v0.2.2 // indirect
+	github.com/nishanths/exhaustive v0.16.1 // indirect
+	github.com/nishanths/predeclared v0.3.1 // indirect
 	github.com/nunnatsa/ginkgolinter v0.24.0 // indirect
 	github.com/opencontainers/go-digest v1.0.0 // indirect
 	github.com/opencontainers/image-spec v1.1.1 // indirect
 	github.com/pelletier/go-toml/v2 v2.4.3 // indirect
-	github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect
+	github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b // indirect
 	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
 	github.com/prometheus/client_model v0.6.3 // indirect
-	github.com/prometheus/common v0.71.0 // indirect
+	github.com/prometheus/common v0.72.1 // indirect
 	github.com/prometheus/procfs v0.22.0 // indirect
 	github.com/quasilyte/go-ruleguard v0.4.5 // indirect
 	github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect
@@ -231,7 +231,7 @@ require (
 	github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
 	github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
 	github.com/quic-go/qpack v0.6.0 // indirect
-	github.com/quic-go/quic-go v0.62.0 // indirect
+	github.com/quic-go/quic-go v0.64.1 // indirect
 	github.com/raeperd/recvcheck v0.3.1 // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
 	github.com/rogpeppe/go-internal v1.16.0 // indirect
@@ -246,7 +246,7 @@ require (
 	github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
 	github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
 	github.com/securego/gosec/v2 v2.29.0 // indirect
-	github.com/shopspring/decimal v1.4.0 // indirect
+	github.com/shopspring/decimal v1.6.0 // indirect
 	github.com/sivchari/containedctx v1.0.3 // indirect
 	github.com/sonatard/noctx v0.5.1 // indirect
 	github.com/sourcegraph/go-diff v0.9.0 // indirect
@@ -257,21 +257,21 @@ require (
 	github.com/spf13/viper v1.21.0 // indirect
 	github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
 	github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect
-	github.com/stretchr/objx v0.5.3 // indirect
+	github.com/stretchr/objx v0.8.0 // indirect
 	github.com/subosito/gotenv v1.6.0 // indirect
 	github.com/tetafro/godot v1.5.6 // indirect
 	github.com/tetratelabs/wazero v1.12.0 // indirect
-	github.com/tidwall/btree v1.8.1 // indirect
-	github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c // indirect
-	github.com/timonwong/loggercheck v0.12.0 // indirect
-	github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
+	github.com/tidwall/btree v1.8.2 // indirect
+	github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 // indirect
+	github.com/timonwong/loggercheck v0.13.1 // indirect
+	github.com/tomarrell/wrapcheck/v2 v2.13.0 // indirect
 	github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
 	github.com/ultraware/funlen v0.2.0 // indirect
 	github.com/ultraware/whitespace v0.2.0 // indirect
-	github.com/uudashr/gocognit v1.2.1 // indirect
-	github.com/uudashr/iface v1.5.1 // indirect
+	github.com/uudashr/gocognit v1.3.0 // indirect
+	github.com/uudashr/iface v1.5.3 // indirect
 	github.com/xen0n/gosmopolitan v1.3.0 // indirect
-	github.com/xo/terminfo v1.0.0 // indirect
+	github.com/xo/terminfo v1.2.0 // indirect
 	github.com/yagipy/maintidx v1.0.0 // indirect
 	github.com/yeya24/promlinter v0.3.0 // indirect
 	github.com/ykadowak/zerologlint v0.1.5 // indirect
@@ -284,29 +284,30 @@ require (
 	go.lsp.dev/protocol v1.0.1 // indirect
 	go.lsp.dev/uri v1.0.1 // indirect
 	go.opentelemetry.io/auto/sdk v1.2.1 // indirect
-	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 // indirect
-	go.opentelemetry.io/otel v1.46.0 // indirect
-	go.opentelemetry.io/otel/metric v1.46.0 // indirect
-	go.opentelemetry.io/otel/trace v1.46.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 // indirect
+	go.opentelemetry.io/otel v1.47.0 // indirect
+	go.opentelemetry.io/otel/log v1.47.0 // indirect
+	go.opentelemetry.io/otel/metric v1.47.0 // indirect
+	go.opentelemetry.io/otel/trace v1.47.0 // indirect
 	go.uber.org/mock v0.6.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
 	go.uber.org/zap v1.28.0 // indirect
 	go.yaml.in/yaml/v3 v3.0.5 // indirect
-	golang.org/x/crypto v0.57.0 // indirect
-	golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect
-	golang.org/x/exp/typeparams v0.0.0-20260908205506-85c1c2202aba // indirect
-	golang.org/x/mod v0.41.0 // indirect
-	golang.org/x/net v0.59.0 // indirect
-	golang.org/x/sync v0.23.0 // indirect
-	golang.org/x/term v0.46.0 // indirect
-	golang.org/x/text v0.42.0 // indirect
+	golang.org/x/crypto v0.58.0 // indirect
+	golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect
+	golang.org/x/exp/typeparams v0.0.0-20261014143641-f0251eb4aa3e // indirect
+	golang.org/x/mod v0.42.0 // indirect
+	golang.org/x/net v0.61.0 // indirect
+	golang.org/x/sync v0.24.0 // indirect
+	golang.org/x/term v0.47.0 // indirect
+	golang.org/x/text v0.43.0 // indirect
 	golang.org/x/time v0.16.0 // indirect
-	golang.org/x/tools v0.50.0 // indirect
-	google.golang.org/genproto/googleapis/api v0.0.0-20260908043556-f8649ddbbfe6 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6 // indirect
-	honnef.co/go/tools v0.8.1 // indirect
+	golang.org/x/tools v0.52.0 // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b // indirect
+	honnef.co/go/tools v0.8.2 // indirect
 	mvdan.cc/gofumpt v0.12.0 // indirect
-	mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 // indirect
+	mvdan.cc/unparam v0.0.0-20261011093844-7a2c2c0b7aa6 // indirect
 	mvdan.cc/xurls/v2 v2.6.0 // indirect
-	pluginrpc.com/pluginrpc v0.5.0 // indirect
+	pluginrpc.com/pluginrpc v0.6.0 // indirect
 )

+ 160 - 158
service/go.sum

@@ -2,22 +2,22 @@
 4d63.com/gocheckcompilerdirectives v1.4.0/go.mod h1:9ZOAiMOjqC/nRwci2fcUXVHUNLG/cH6r6rhUh+jTFtQ=
 4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU=
 4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0=
-buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2 h1:z5+rVknD0AvorY/QV9cb3ohUXw4E7HvxU4+2JUH6EHU=
-buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2/go.mod h1:Owcuj2phVyvThVYCD74iSV8ki0dAXw/uxkdcdZScdCc=
-buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20250109164928-1da0de137947.2 h1:tE4A2RVukQOSd4K+6ndK+8s65FbjiBt2k5CiddXxCCo=
-buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20250109164928-1da0de137947.2/go.mod h1:Gjxz0o7qPHokNsmlX8D+/aKd29NRLRr9CCUkPeMAzJ0=
+buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20261002170247-538130002972.2 h1:nNNsSGs3l9F8q4NfHaIa8RdGfucw957dlFuNf9Z3H/w=
+buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20261002170247-538130002972.2/go.mod h1:Owcuj2phVyvThVYCD74iSV8ki0dAXw/uxkdcdZScdCc=
+buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20261012172039-ff528c23443f.2 h1:V79UG2jSfa0l9sH7leKvETpJZZriK6t+WGm4P17l/Hw=
+buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20261012172039-ff528c23443f.2/go.mod h1:Gjxz0o7qPHokNsmlX8D+/aKd29NRLRr9CCUkPeMAzJ0=
 buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 h1:NbnlmV26O7oZ1iM5tsCI+GEx+3ZSdrhvnKQ/eWSrLiY=
 buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw=
-buf.build/gen/go/bufbuild/registry/connectrpc/go v1.21.0-20260831211851-b4432e12a6e7.1 h1:kgJQK3lRFEkfai/GRS+gF+kblo8GX2/x/eBHkZCpXSA=
-buf.build/gen/go/bufbuild/registry/connectrpc/go v1.21.0-20260831211851-b4432e12a6e7.1/go.mod h1:5JkV6992ol+BT3K0UiWwh9S+ARt6w3OpRnyMMIadwVM=
-buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20260831211851-b4432e12a6e7.2 h1:cik2XvKM1UV8HgLWD2vnBvV4GgT5NfpLJBb9UgLKzzg=
-buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20260831211851-b4432e12a6e7.2/go.mod h1:91H8i3iR9GoomNkM9X13RTFL8cVNAHnS44BdgAnbqz4=
+buf.build/gen/go/bufbuild/registry/connectrpc/go/v2 v2.0.1-20261021182822-0b1ad74125ef.1 h1:zXGHYEI4xdb/zY5+nEnWUCIf7WJkZab7CrC2r+oCXjI=
+buf.build/gen/go/bufbuild/registry/connectrpc/go/v2 v2.0.1-20261021182822-0b1ad74125ef.1/go.mod h1:wYEKexzRZ5Ug0wm6A+PMH04FJMWs2uzaB+rCUR6vmK0=
+buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20261021182822-0b1ad74125ef.2 h1:LSu/wfp0AFF8/byeR+qPAiP35gusp68CDgpN90PGJdw=
+buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20261021182822-0b1ad74125ef.2/go.mod h1:v7IaNcBKy5G3G8gpG2m92P0R3HK2qtMGclWzf+1Aut4=
 buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.12-20241007202033-cf42259fcbfc.2 h1:9xw8g8Q6zHLC7CO/jtpl2UK0kQticxIG+mI6N86f2/M=
 buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.12-20241007202033-cf42259fcbfc.2/go.mod h1:+0bw+k8FY5MU3tojFh33HeA5xMfGWjAUQpr+maW5Z3o=
-buf.build/go/app v0.2.1-0.20260626143626-be153867abea h1:krHaIyUJSTnurb0fbYQtsXkZWiWQ/ydOgfm61TPSODc=
-buf.build/go/app v0.2.1-0.20260626143626-be153867abea/go.mod h1:V32mBaPWsfq6REAeZvvs/rQl7ZCl9Dn7eW1BBrmH0GQ=
-buf.build/go/bufplugin v0.10.0 h1:vZBX0mq9as5UIBug8U+/DkGRaHNlM/HVOw59O8fvOIU=
-buf.build/go/bufplugin v0.10.0/go.mod h1:ax7obVurKDH1I2nR4pFTS+TE6K3kZhTmwDCN2YgdV8I=
+buf.build/go/app v0.3.0 h1:XGdGg3iI/465w+PPipShXqGwuT43/Omk+5EtHYYly9Q=
+buf.build/go/app v0.3.0/go.mod h1:KuUkGoXxZnxNVGN/AB7s4UCUG2/1xtk8NjxqdSCSSyM=
+buf.build/go/bufplugin v0.11.1 h1:fegT6xiiGVwyNiLlxBz07ochKTX/DAtIKe/oPdrqrCs=
+buf.build/go/bufplugin v0.11.1/go.mod h1:0xoAhb/CPqXwS/FUup9vwIRBYxDcXYXeOhYXHca5uuc=
 buf.build/go/bufprivateusage v0.1.0 h1:SzCoCcmzS3zyXHEXHeSQhGI7OTkgtljoknLzsUz9Gg4=
 buf.build/go/bufprivateusage v0.1.0/go.mod h1:GlCCJ3VVF7EqqU0CoRmo1FzAwwaKymEWSr+ty69xU5w=
 buf.build/go/interrupt v1.1.0 h1:olBuhgv9Sav4/9pkSLoxgiOsZDgM5VhRhvRpn3DL0lE=
@@ -26,8 +26,8 @@ buf.build/go/protovalidate v1.4.0 h1:UjLrYbt5VX7+TMOs2+pG5FhZhIG1mSfK4EIopbb4LcM
 buf.build/go/protovalidate v1.4.0/go.mod h1:8vJfzNT6NIG2qm3uFsJDXMlRmG+bQJzbcIn1Aa0vPGs=
 buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38=
 buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0=
-buf.build/go/spdx v0.2.0 h1:IItqM0/cMxvFJJumcBuP8NrsIzMs/UYjp/6WSpq8LTw=
-buf.build/go/spdx v0.2.0/go.mod h1:bXdwQFem9Si3nsbNy8aJKGPoaPi5DKwdeEp5/ArZ6w8=
+buf.build/go/spdx v0.3.0 h1:vVxy1W0x1n3sqhZmJ7VZ/UtzNwK3xm0QowGGtwric4Y=
+buf.build/go/spdx v0.3.0/go.mod h1:bXdwQFem9Si3nsbNy8aJKGPoaPi5DKwdeEp5/ArZ6w8=
 buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 h1:njYKSWoLiq2i5O7y2bPPU2Yzp7iAU0Wk9KJ2OoAhNiU=
 buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5/go.mod h1:DQmodNT9EHX94WzUaWiZK+/4EaFa/xZTc1gzfCxZVXU=
 cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk=
@@ -40,12 +40,14 @@ codeberg.org/chavacava/garif v0.2.1 h1:K9oYxSlvlXrHXyW26Z4q61bTpJDo1wbvXcYKar/F/
 codeberg.org/chavacava/garif v0.2.1/go.mod h1:oHnDSmc0f9K1MeE+MQD/yjkiIB5Xsn5y3S9Dg96Xk84=
 codeberg.org/polyfloyd/go-errorlint v1.9.0 h1:VkdEEmA1VBpH6ecQoMR4LdphVI3fA4RrCh2an7YmodI=
 codeberg.org/polyfloyd/go-errorlint v1.9.0/go.mod h1:GPRRu2LzVijNn4YkrZYJfatQIdS+TrcK8rL5Xs24qw8=
-connectrpc.com/connect v1.21.0 h1:LhqSJt7jHf5NJBo9Jq/t/9FjcYAideif0mg+qe2jCUs=
-connectrpc.com/connect v1.21.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
-connectrpc.com/grpcreflect v1.3.0 h1:Y4V+ACf8/vOb1XOc251Qun7jMB75gCUNw6llvB9csXc=
-connectrpc.com/grpcreflect v1.3.0/go.mod h1:nfloOtCS8VUQOQ1+GTdFzVg2CJo4ZGaat8JIovCtDYs=
-connectrpc.com/otelconnect v0.9.0 h1:NggB3pzRC3pukQWaYbRHJulxuXvmCKCKkQ9hbrHAWoA=
-connectrpc.com/otelconnect v0.9.0/go.mod h1:AEkVLjCPXra+ObGFCOClcJkNjS7zPaQSqvO0lCyjfZc=
+connectrpc.com/connect v1.21.1 h1:h2kacdlzXpZU2OQFSv98xkjYXNUSr/ptxmGVbS/+CRM=
+connectrpc.com/connect v1.21.1/go.mod h1:fhMcVJ5w6r/yTYhnkEbW6+sIF7OZ5Ay7KYgmfebh2ek=
+connectrpc.com/connect/v2 v2.0.2 h1:PoPDmOlhc46elsuCdKCKTxjNBb3HJlKF+s/sP2oucPw=
+connectrpc.com/connect/v2 v2.0.2/go.mod h1:N32OLrstcPUzkykFynnefFro3+xzSdOrZlK2jHmS/xo=
+connectrpc.com/grpcreflect/v2 v2.0.0 h1:Ah7U5kSd/kslH5KFNS8jh/e3u6UYVoX7lwkjcIfSq6A=
+connectrpc.com/grpcreflect/v2 v2.0.0/go.mod h1:V7Hj+ibxuZH3667Ng2zKKvxJQJIri20iG50vzG7i8GM=
+connectrpc.com/otelconnect v0.12.0 h1:SOQ2LvyPUON2WekzWUWhXEy2NHusTevV64xa6kAHsrQ=
+connectrpc.com/otelconnect v0.12.0/go.mod h1:BB9a3Z9DDsx8AAJREAdKVIENfa70XB1KQXWKRbfpLxw=
 dev.gaijin.team/go/exhaustruct/v4 v4.0.0 h1:873r7aNneqoBB3IaFIzhvt2RFYTuHgmMjoKfwODoI1Y=
 dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88YdiB0Ai4fXOzPI=
 dev.gaijin.team/go/exhaustruct/v5 v5.2.0 h1:HZB49Fshn8qHJCi9VzYtrcnR1jbLteRHXfnQz8Adams=
@@ -80,8 +82,8 @@ github.com/MicahParks/jwkset v0.11.3 h1:Phli4RdTDdIdLXZpuO7abkwZyzIk0RDTUPVVBHPR
 github.com/MicahParks/jwkset v0.11.3/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
 github.com/MicahParks/keyfunc/v3 v3.8.2 h1:eydEwk/pBAVrDIpmFfB/gkCcrp++xQ7YYXirrI2zlWE=
 github.com/MicahParks/keyfunc/v3 v3.8.2/go.mod h1:T4snFPe26GwMg45bBAdM5P6qWQyLxZHLwBhxR/9PnCs=
-github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
-github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
+github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
 github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgyLJgsQ=
 github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
 github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
@@ -96,8 +98,8 @@ github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8v
 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
 github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
 github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
-github.com/alecthomas/go-check-sumtype v0.4.0 h1:gd4mE4QsA62sXlnFIs8GUv8/0xH3DOUqTkYQR1l2v28=
-github.com/alecthomas/go-check-sumtype v0.4.0/go.mod h1:aYYllI8IXNjo7ZYUzI2+wyLRECMg3zo+8CZj9JTBAWU=
+github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 h1:dXPJ+uOJLWb8rjlAuLX7JPiRJtZKw/53Ol+WxdDJ1Eo=
+github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606/go.mod h1:ZH31IJyNpxpStxP2ayCcPQz+3T2G/OnxIQqe4LWvSDw=
 github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
 github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
 github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
@@ -130,16 +132,16 @@ github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+j
 github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
 github.com/bombsimon/wsl/v5 v5.9.0 h1:WCrgZ7RQnZO5oEwbVTlYgBdU3wL294kR1BSWV8vTfsU=
 github.com/bombsimon/wsl/v5 v5.9.0/go.mod h1:kjo4HiAV5FDkHC8/uzJq9mBffEEd6WT/nvN7DoMovDM=
-github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE=
-github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE=
-github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg=
-github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s=
+github.com/breml/bidichk v0.3.4 h1:70G6w80G6GDZTUURHTC5gBKVjMLMw2736CzXquSf5Ek=
+github.com/breml/bidichk v0.3.4/go.mod h1:GOHprnh4i0V7HrvWjtbvbpicTUcqoYt9zYESKcJkk4M=
+github.com/breml/errchkjson v0.4.2 h1:4o23TrT/nju9uIxfV/WRja96u8X/1fKHDZXGB1acj2U=
+github.com/breml/errchkjson v0.4.2/go.mod h1:Wq62jvsaH+Eb1F2LJQlBenxwDhYgXe9wLMDuCe7HOVo=
 github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
 github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
-github.com/bufbuild/buf v1.72.0 h1:VMmGFtCLrxyS2wkpghExmhhiqJDdmc8DcwAvsGJGJ94=
-github.com/bufbuild/buf v1.72.0/go.mod h1:bhtIlPDo3q/PDw4yaTdx2+jxkc33Aq+ygFIi6Diz2yU=
-github.com/bufbuild/protocompile v0.14.2-0.20260716165721-bb5762d29672 h1:6xykiXQPoF/hhOjAhwiHQ0kgcAvrDZw0Tjl1VQkpu5c=
-github.com/bufbuild/protocompile v0.14.2-0.20260716165721-bb5762d29672/go.mod h1:jPUiZUFWc8E3Kc2Y4SRlGAdjde4amGkHY0BUACNS43E=
+github.com/bufbuild/buf v1.75.0 h1:FvUEktoR5wduFOq1j+HmOswL0PM4pjDivLkJ1iwRztg=
+github.com/bufbuild/buf v1.75.0/go.mod h1:PMlP1QzE1ddPbaL3vW5cac0WKAvvPPipBTu/1QHvxOI=
+github.com/bufbuild/protocompile v0.14.2-0.20261020145055-56248e843b50 h1:JjQ4WXOPRj7b404ouvTJNdYHi2Ib4Bfl5VoSBluKbGs=
+github.com/bufbuild/protocompile v0.14.2-0.20261020145055-56248e843b50/go.mod h1:mcyF/iWO0TSVh4LvVBH5gJO1N9pQyooNy+XR4tNlbO0=
 github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b h1:b7wvo9ZhjLzCp7tGbOUMvgtYTnd33zGSAmMxcdxMnhQ=
 github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b/go.mod h1:c5D8gWRIZ2HLWO3gXYTtUfw/hbJyD8xikv2ooPxnklQ=
 github.com/butuzov/ireturn v0.4.1 h1:vWb3NO4t77iku/sjCQ/2pHTQeOmxEhjIriJqRLg1Y+I=
@@ -156,10 +158,10 @@ github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1Di
 github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4=
 github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
 github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
-github.com/charmbracelet/ultraviolet v0.0.0-20260906173415-0277a179edd9 h1:tYBWVoMfQHTwp88mWeWH7o0uJFZqRJeEGJmeHpIR8Ms=
-github.com/charmbracelet/ultraviolet v0.0.0-20260906173415-0277a179edd9/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
-github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
-github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
+github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw=
+github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
+github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
+github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
 github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
 github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
 github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
@@ -185,8 +187,8 @@ github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+f
 github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88=
 github.com/daixiang0/gci v0.14.0 h1:h6AcLqmjIOBgojhtzY2CvBnA6RawPTkBHgtMvYD5YZ8=
 github.com/daixiang0/gci v0.14.0/go.mod h1:w9E+SWQ4aPQ+xYPUdqitGDoXpT4mayqOfk7Szz2U6wQ=
-github.com/dave/dst v0.27.4 h1:d+EVnOZmphH+lUEXq9rit4GjsFSKJ3AhfRWf7eobTps=
-github.com/dave/dst v0.27.4/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc=
+github.com/dave/dst v0.28.0 h1:EIEuRC5XmVKuZF2OeyMahXX9ARVt6Xxr8vI0WtIY7K8=
+github.com/dave/dst v0.28.0/go.mod h1:C4mfimBeSHfsgskhR/mUYSE05s2ih+PW6g9FP3c49tE=
 github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo=
 github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -197,14 +199,14 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
 github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
 github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
 github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
-github.com/dlclark/regexp2/v2 v2.8.0 h1:CekDhPLGfm+GAJmPVFIG+5dqMIQPkyHJll7BbdneDfw=
-github.com/dlclark/regexp2/v2 v2.8.0/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
-github.com/docker/cli v29.8.0+incompatible h1:ih0c2jq/nN7QfES8zIfwSzIhRScjs4ehER+kZ60aeSk=
-github.com/docker/cli v29.8.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
-github.com/docker/docker-credential-helpers v0.9.9 h1:BkydjIgZ46JnDbqyM2p2fc63KMw6y+KHL3Em/2AGJ7w=
-github.com/docker/docker-credential-helpers v0.9.9/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
-github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M=
-github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
+github.com/dlclark/regexp2/v2 v2.9.1 h1:q6bZW/NN9VodqMjYXpuic2lZxdHPfFTzGS+MJxIJmKQ=
+github.com/dlclark/regexp2/v2 v2.9.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
+github.com/docker/cli v29.9.2+incompatible h1:trup/vUxx5uUo2t+87Cd9AmITR+DvKUe4H2tTtZN8FY=
+github.com/docker/cli v29.9.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/docker-credential-helpers v0.9.12 h1:hAgjJcMuC17DPf0Kl/HQSZQ7cxVo+wEA8Oo1SzkmqFg=
+github.com/docker/docker-credential-helpers v0.9.12/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
+github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc=
+github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
 github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
 github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q=
@@ -273,32 +275,30 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
 github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
 github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0=
 github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ=
-github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6 h1:fVLolA3dG6s0brGetsiDAtcnpMSwVa2LqXJEw/9RJG4=
-github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6/go.mod h1:1xo+NFW5S+bEf2DKXhaxuzvcDN5AR6o/KMwvqC5Mkpo=
+github.com/golangci/canonicalheader v0.0.0-20260928145534-e18e5c5a078f h1:pOnwRNeWLPkADfkgqKcCP7RPQAS9OIzffaBaktRsKnk=
+github.com/golangci/canonicalheader v0.0.0-20260928145534-e18e5c5a078f/go.mod h1:aXo+AVSX0pC3TRo2wAfR92YwBKfG6C2eal+omjKXwYw=
 github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 h1:CbTB8KpqnViI6lIXxp03Oclc4VFHi3K4BWC1TacsZ+A=
 github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E=
 github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarogrvjO9AfiW3B4U=
 github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
 github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 h1:WL8YKrt3UbOBqSRU7GpP5BTtQTMWtVtj+mfPijgZeIg=
 github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792/go.mod h1:te5hX0dW4C5r6YbXs+6ysNr8Q5UTmdIqGbb+mlFiYmA=
-github.com/golangci/golangci-lint/v2 v2.13.2 h1:bCyq3E4vo9qwzifjpzJqYndEt7Ncva80qkk8G2B4TXU=
-github.com/golangci/golangci-lint/v2 v2.13.2/go.mod h1:5xaMd1kAxV7GSBPEyngw8gnnjoRocqW9UVKdBR6627w=
-github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
-github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10=
+github.com/golangci/golangci-lint/v2 v2.15.0 h1:w94zf8HAYI7hm3164kwKsvZRuqXvY+YxeFoTbu1OLg8=
+github.com/golangci/golangci-lint/v2 v2.15.0/go.mod h1:/FRI304IxqDVhX2o3ZZ0qp8Vvz8egJdzCY5A4GjvmTU=
+github.com/golangci/golines v0.16.0 h1:xKdwzRvwzLd7V131B/dv90vUiEoTA0vQybdeqperPOg=
+github.com/golangci/golines v0.16.0/go.mod h1:mCMgRQHiCvuALJOeaNtylu09zbzvS9Ka01dfvLLJios=
 github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg=
 github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
 github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg=
 github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw=
 github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s=
 github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k=
-github.com/golangci/rowserrcheck v0.0.0-20260901154044-241134db7241 h1:0kPqfqytQizeQ9WBk487NcsGcRdS8YhHeUhXxkfEAhA=
-github.com/golangci/rowserrcheck v0.0.0-20260901154044-241134db7241/go.mod h1:lczq5oJ8PxpMESQG6dO3iFYTQPhpqoMOYw//n2FEnHA=
+github.com/golangci/rowserrcheck v0.0.0-20261005233414-e0740d52dee8 h1:oN042y1yEcpldrqY81puyNA4ZjQZJzt0U/t7HyYrMic=
+github.com/golangci/rowserrcheck v0.0.0-20261005233414-e0740d52dee8/go.mod h1:T1Bg58Sk3N352aBbPLJPpHykFWxEEwV3o4/Enn+PPU8=
 github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM=
 github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s=
 github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM=
 github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc=
-github.com/google/cel-go v0.31.0 h1:H0bhpFTqOvmHrBGrWKp7ZlhBm5Hh8PYUEXnwxT1LL7A=
-github.com/google/cel-go v0.31.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
 github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -337,28 +337,28 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/jamesread/golure v0.0.0-20260902093223-27b18778c9a5 h1:AN7u+AYEs94l0CFrAavOcCsriZAiF+sJCmb0gXfRulk=
-github.com/jamesread/golure v0.0.0-20260902093223-27b18778c9a5/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE=
+github.com/jamesread/golure v0.0.0-20260927082702-900b761e432f h1:9eoL7vPEKO2JXRJjfLCmONS5jGpMXMsQzHwjTIOpN98=
+github.com/jamesread/golure v0.0.0-20260927082702-900b761e432f/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE=
 github.com/jdx/go-netrc v1.0.0 h1:QbLMLyCZGj0NA8glAhxUpf1zDg6cxnWgMBbjq40W0gQ=
 github.com/jdx/go-netrc v1.0.0/go.mod h1:Gh9eFQJnoTNIRHXl2j5bJXA1u84hQWJWgGh569zF3v8=
 github.com/jgautheron/goconst v1.11.0 h1:KgN90z5qXt5f0Uzf3cWXev3hfMMFUyNeKdpkSBRvLDk=
 github.com/jgautheron/goconst v1.11.0/go.mod h1:0p+wv1lFOiUr0IlNNT1nrm6+8DB8u2sU6KHGzFRXHDc=
-github.com/jhump/protoreflect/v2 v2.0.0-beta.2 h1:qZU+rEZUOYTz1Bnhi3xbwn+VxdXkLVeEpAeZzVXLY88=
-github.com/jhump/protoreflect/v2 v2.0.0-beta.2/go.mod h1:4tnOYkB/mq7QTyS3YKtVtNrJv4Psqout8HA1U+hZtgM=
+github.com/jhump/protoreflect/v2 v2.0.0 h1:dBg0utH2witLqXfczkVVrHFrFQc8SN1rvaKkOR5ALDg=
+github.com/jhump/protoreflect/v2 v2.0.0/go.mod h1:Sz8TB0WtuTKOF4eYj2iZovXSSU3tqZ5Ii8Oeg9k9vKY=
 github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8=
 github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU=
 github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ=
 github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
 github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
 github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
-github.com/kisielk/errcheck v1.20.0 h1:9rwHBNKzd4wkDWcROy3DvFGNqEPlkxBg305rvk7HabI=
-github.com/kisielk/errcheck v1.20.0/go.mod h1:O+f80MKNwX8Oor2jwgpeQ9An7uJm+hRSgT+h22knRJU=
+github.com/kisielk/errcheck v1.30.0 h1:ZIxNDAfAd4larXHTceoZP/dYPAB90dHg1OYhA2sVh5k=
+github.com/kisielk/errcheck v1.30.0/go.mod h1:uiCAInmFuyOje4VDdbQSP9mgqf5gORYSXrDf19XilsI=
 github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
 github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
-github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
-github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
-github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
-github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
+github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
+github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
+github.com/klauspost/pgzip v1.3.0 h1:8t+KjjzD/0n/8vNlJGGZmTeK4njuwffOQvJm8tOSc8o=
+github.com/klauspost/pgzip v1.3.0/go.mod h1:Xx7tAv5DH8ObLHUDQKXoE/nd8i/FYdNDTVMvi3SLTKI=
 github.com/knadh/koanf/maps v0.1.3 h1:P1z7EvTqdFBrPYbzSvorvrpib+sjkUMxf0FVvA5NKK4=
 github.com/knadh/koanf/maps v0.1.3/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
 github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU=
@@ -369,28 +369,28 @@ github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP
 github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
 github.com/knadh/koanf/providers/rawbytes v1.0.0 h1:MrKDh/HksJlKJmaZjgs4r8aVBb/zsJyc/8qaSnzcdNI=
 github.com/knadh/koanf/providers/rawbytes v1.0.0/go.mod h1:KxwYJf1uezTKy6PBtfE+m725NGp4GPVA7XoNTJ/PtLo=
-github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo=
-github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
+github.com/knadh/koanf/v2 v2.4.1 h1:MK2JDkLy4UGzEUkW1CCCKQftd9+M+NX8hAZ2axbf/jo=
+github.com/knadh/koanf/v2 v2.4.1/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/kulti/thelper v0.7.1 h1:fI8QITAoFVLx+y+vSyuLBP+rcVIB8jKooNSCT2EiI98=
 github.com/kulti/thelper v0.7.1/go.mod h1:NsMjfQEy6sd+9Kfw8kCP61W1I0nerGSYSFnGaxQkcbs=
-github.com/kunwardeep/paralleltest v1.0.15 h1:ZMk4Qt306tHIgKISHWFJAO1IDQJLc6uDyJMLyncOb6w=
-github.com/kunwardeep/paralleltest v1.0.15/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk=
+github.com/kunwardeep/paralleltest v1.0.16 h1:I0gQkLNgfigjpxWAGhx7PYj3VpswzfGrSV/715t6g+o=
+github.com/kunwardeep/paralleltest v1.0.16/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk=
 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
 github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ=
 github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM=
-github.com/ldez/gomoddirectives v0.9.0 h1:2YV/EX7nVlWL4jySusYTzBKHuE3D2fgcRsQuMa3yIoo=
-github.com/ldez/gomoddirectives v0.9.0/go.mod h1:DdZzfm9MdXCjn2/UGYXCFfo+tzrp2Ib4iD2Q0kIJkwE=
+github.com/ldez/gomoddirectives v0.10.0 h1:Zb23rfMMkG21B6pZ9OfESzemByP5Qr7YvWcXQZE/S3Q=
+github.com/ldez/gomoddirectives v0.10.0/go.mod h1:W9uh0721Vc7aaVoiMXTpKIveLmtHC9uaOSiHM4OK5a8=
 github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o=
 github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas=
 github.com/ldez/structtags v0.6.1 h1:bUooFLbXx41tW8SvkfwfFkkjPYvFFs59AAMgVg6DUBk=
 github.com/ldez/structtags v0.6.1/go.mod h1:YDxVSgDy/MON6ariaxLF2X09bh19qL7MtGBN5MrvbdY=
-github.com/ldez/tagliatelle v0.7.2 h1:KuOlL70/fu9paxuxbeqlicJnCspCRjH0x8FW+NfgYUk=
-github.com/ldez/tagliatelle v0.7.2/go.mod h1:PtGgm163ZplJfZMZ2sf5nhUT170rSuPgBimoyYtdaSI=
+github.com/ldez/tagliatelle v0.8.0 h1:QczqVPa22JdT40p3m5JTTVmPMv5wDORqdomJn6/Pnn0=
+github.com/ldez/tagliatelle v0.8.0/go.mod h1:K9n83Wv0wyVq3JYF9WI28BX8xXhDBVP3thHJ0ENuiEs=
 github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc=
 github.com/ldez/usetesting v0.5.0/go.mod h1:Spnb4Qppf8JTuRgblLrEWb7IE6rDmUpGvxY3iRrzvDQ=
 github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY=
@@ -411,14 +411,14 @@ github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4=
 github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs=
 github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
 github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
-github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
-github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A=
+github.com/mattn/go-colorable v0.1.16/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
 github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
 github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
-github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
-github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
-github.com/mgechev/revive v1.16.0 h1:9OZVO8xOPUwIYynplHXv7liMllPQ/BTNZLp07sM/Im0=
-github.com/mgechev/revive v1.16.0/go.mod h1:nT854jMmvQsS/qqxEaMPM4pJSUpxhcPoHM788ZxDvdQ=
+github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo=
+github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
+github.com/mgechev/revive v1.17.1 h1:z8hV0L3uJ6yrwOMnJbkc5qBJpxXa2AxYKFKERGJyA4s=
+github.com/mgechev/revive v1.17.1/go.mod h1:FRd64pJwWSEE6j8ESoaX0PixPzjh2rpNSfckbEu3rIE=
 github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
@@ -427,10 +427,10 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx
 github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
 github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
 github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
-github.com/moby/moby/api v1.56.0 h1:GQzua3NA599ASSIICx0iFgiJeO9YkdDARvQsm23ZZuQ=
-github.com/moby/moby/api v1.56.0/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk=
-github.com/moby/moby/client v0.6.0 h1:AJjEB21QPbXSXjDsZorFBoDZPhMrfbpaPLgSMAW9Bgs=
-github.com/moby/moby/client v0.6.0/go.mod h1:OCo00wNRyA3m4lmJ228W3JbyCN4ZNNYjpOXiJydBdcQ=
+github.com/moby/moby/api v1.56.1 h1:PpWkvVPB7Fr/No8w+TfyJ/I6rWZ2YPZhT3JorIg+06c=
+github.com/moby/moby/api v1.56.1/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk=
+github.com/moby/moby/client v0.6.2 h1:ekSPlMccrZGcw6Scb8T9D39qE0mB+XupU/yS0zNw9N4=
+github.com/moby/moby/client v0.6.2/go.mod h1:DGcnO7qmPJrqobx/ggQgQ/AUk+2wgVdQv7uxB0/mEsc=
 github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI=
 github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U=
 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -439,10 +439,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
 github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=
 github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
-github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg=
-github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs=
-github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
-github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c=
+github.com/nishanths/exhaustive v0.16.1 h1:5la0VKPN6CY8WYJq+JfyP970R3IBbJHZiTUY+Fl0yuA=
+github.com/nishanths/exhaustive v0.16.1/go.mod h1:/nBwAMRn2QmCjWQ3PiJ939yRSwvWLKSa21L2C6Tp84E=
+github.com/nishanths/predeclared v0.3.1 h1:qb/HpkPb0bF0drRBFkTyKY1ZRWcilc6gtxaBZGgn8H8=
+github.com/nishanths/predeclared v0.3.1/go.mod h1:Tsd/DNUrOSU4nGhDSYOXexypmCMpi2nXII8oiDVEzmU=
 github.com/nunnatsa/ginkgolinter v0.24.0 h1:Mp0EagluLFP98JatP6nqp/gGEoljNG97uf9AcxcBVy8=
 github.com/nunnatsa/ginkgolinter v0.24.0/go.mod h1:2ZMRuzX6+3XXyY6UZOwb6n+MCocVGbkIsDBC4vuWz5c=
 github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw=
@@ -462,17 +462,17 @@ github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT9
 github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
 github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
 github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
-github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 h1:lcWAnrqr2nNfDiArwFNHCE4787Mw2tCdVSOXCru0/0E=
-github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
+github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b h1:OzNsuVdSWGwXvWKTtChx9ve89k4cFJ6NxYM4Aw4/f+E=
+github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
-github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
+github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0=
+github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk=
 github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
 github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
-github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8=
-github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk=
+github.com/prometheus/common v0.72.1 h1:ZAiQed/nk8IIcsdzb2TlJu23NEUDyDLQ9TXTN2WRK6Y=
+github.com/prometheus/common v0.72.1/go.mod h1:1IoBY4IRaJnhs2ESjNzjilP1cJ/P8pimGoomq7zRXrA=
 github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
 github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
 github.com/protocolbuffers/protoscope v0.0.0-20221109213918-8e7a6aafa2c9 h1:arwj11zP0yJIxIRiDn22E0H8PxfF7TsTrc2wIPFIsf4=
@@ -491,8 +491,8 @@ github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4
 github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
 github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
 github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
-github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8=
-github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
+github.com/quic-go/quic-go v0.64.1 h1:BCNTP+6x6++OPKrGST2FR3f1tycx8O3QOA3QQv09g70=
+github.com/quic-go/quic-go v0.64.1/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
 github.com/raeperd/recvcheck v0.3.1 h1:p7nZMxiCWkdUtY2i3o+pv1h4fJCR5gpvqAXr9326hEY=
 github.com/raeperd/recvcheck v0.3.1/go.mod h1:CbE7iRocQ4uc71JwpW/CPSh4BShnkre4A9Gb508RypU=
 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -528,8 +528,8 @@ github.com/securego/gosec/v2 v2.29.0/go.mod h1:47C7ej+L2bC1NOxteU2aCGnCi4cZaoKzR
 github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
 github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
 github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
-github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
-github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
+github.com/shopspring/decimal v1.6.0 h1:DcTwjCj4tJPLuGZYsR9W0hyX9pQwMIkdo26Ig1T9xX4=
+github.com/shopspring/decimal v1.6.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
 github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
 github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
 github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE=
@@ -555,8 +555,8 @@ github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRk
 github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g=
 github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
-github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
+github.com/stretchr/objx v0.8.0 h1:rm9oMkeprG/wHifkZP4EuDww9S0mvEaQ/p9cXTNT4cw=
+github.com/stretchr/objx v0.8.0/go.mod h1:xSzHnPHkdEv7vtgdQej8atlCuU1mdX+ELge8bn0hCsE=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
@@ -571,28 +571,28 @@ github.com/tetafro/godot v1.5.6 h1:IEkrFCwXaYHlOn4mGzGS3F3dkP6m9t0jpwqBFPIkKiA=
 github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU=
 github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
 github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
-github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
-github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
-github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c h1:d/FUP8GD4QiY/rdFzZmBu3KqL9eBNVzMu7W/5pGkaMM=
-github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M=
-github.com/timonwong/loggercheck v0.12.0 h1:7Cb9XGLJZSWYNjgL459DIhpSiEHupD+MLQ6vaN2Pl3g=
-github.com/timonwong/loggercheck v0.12.0/go.mod h1:Wa6Y+SJHQR01mViZlLLY5GAZVOWOLNMbkXddMjFWQCs=
-github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is=
-github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo=
+github.com/tidwall/btree v1.8.2 h1:mTY5iM4UTh2e80TG7RHDQQmnAY3xGNQA/DbU0olkhkA=
+github.com/tidwall/btree v1.8.2/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
+github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 h1:HECVafB2AKntWWRfsiQ4gdunLx5WOYQzFdCsxqOrzp8=
+github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2/go.mod h1:JLoPVtROtNgJ99yXTIxuGNttvmJa85tkJAO6lr05qBA=
+github.com/timonwong/loggercheck v0.13.1 h1:JlOsAuKcfWLM/8wplNNzQ7l9h6HL4ZeAgMhFaSXL2dc=
+github.com/timonwong/loggercheck v0.13.1/go.mod h1:7HxbAwCq9vULYfq9Upvmq8jX00w2/Z7BEfiVE78Jji8=
+github.com/tomarrell/wrapcheck/v2 v2.13.0 h1:jYbLNs8PDkzGCY6d5LUzJdY7Bq//CTlb2HxZGV5z0JY=
+github.com/tomarrell/wrapcheck/v2 v2.13.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo=
 github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw=
 github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw=
 github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI=
 github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA=
 github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g=
 github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8=
-github.com/uudashr/gocognit v1.2.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4=
-github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q=
-github.com/uudashr/iface v1.5.1 h1:BS3yrgaT55s3dAtJoxuTnsZNbsejq/mVuzp49YDyfBA=
-github.com/uudashr/iface v1.5.1/go.mod h1:5UWoT6SvTdTww/KToRj6clO+n4Kg3/NrAQvgOB+5Ggw=
+github.com/uudashr/gocognit v1.3.0 h1:Xdw5JwAoYmX3WAB+wsXY3nxEwdw7+MWAnM0wCAAv7A4=
+github.com/uudashr/gocognit v1.3.0/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q=
+github.com/uudashr/iface v1.5.3 h1:N61bzr11L7BHP6Rct+C6zQePHjSTMeyVBq+s03uB/Ds=
+github.com/uudashr/iface v1.5.3/go.mod h1:5UWoT6SvTdTww/KToRj6clO+n4Kg3/NrAQvgOB+5Ggw=
 github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM=
 github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4=
-github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
-github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4=
+github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
 github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM=
 github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk=
 github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs=
@@ -628,18 +628,20 @@ go.lsp.dev/uri v1.0.1 h1:ywxJN1UXoQ+v0I9lkoQNMmsm7lub5G9ZG7KXT8YTx30=
 go.lsp.dev/uri v1.0.1/go.mod h1:06a0ghafs4PuqkLsJGUmlgPHw6+kH79edRXjOT/rA8s=
 go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
 go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU=
-go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
-go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
-go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
-go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
-go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
-go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
-go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
-go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
-go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
-go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 h1:LxwW/9ctSCv+QkE/cLR7M91ZIkXNMqJtEMi1vCw9U8s=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0/go.mod h1:tOsftB4SslBwwErVEPaenU2RpThXWPIU8DoJHEC4dyw=
+go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM=
+go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU=
+go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI=
+go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw=
+go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY=
+go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU=
+go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I=
+go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0=
+go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw=
+go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw=
+go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM=
+go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0=
 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
 go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
 go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@@ -658,22 +660,22 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
-golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
-golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
-golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU=
-golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E=
+golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
+golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
+golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A=
+golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw=
 golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
 golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
-golang.org/x/exp/typeparams v0.0.0-20260908205506-85c1c2202aba h1:ZoZmFkcuQ289xJilBm9Ck3W+/okrhXc4LUpbtKFQE4k=
-golang.org/x/exp/typeparams v0.0.0-20260908205506-85c1c2202aba/go.mod h1:qkMnvgP8C7+xkci04txL2SJFc/0PGqnkf6gs5JUC43E=
+golang.org/x/exp/typeparams v0.0.0-20261014143641-f0251eb4aa3e h1:SBQ1wB3Zgy/s4PeK7ScBk1sly3f7wfGDOgscRCf7EaE=
+golang.org/x/exp/typeparams v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:qkMnvgP8C7+xkci04txL2SJFc/0PGqnkf6gs5JUC43E=
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
-golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
+golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
+golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -684,8 +686,8 @@ golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qx
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
-golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
+golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
+golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
 golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
 golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -695,8 +697,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
-golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
+golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
+golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -712,15 +714,15 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
-golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
+golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
 golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
-golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
-golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
+golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
+golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -729,8 +731,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
-golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
-golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
+golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
+golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
 golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
 golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -742,8 +744,8 @@ golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0t
 golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
-golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
+golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
+golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
 golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
 golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
 golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -752,12 +754,12 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/genproto/googleapis/api v0.0.0-20260908043556-f8649ddbbfe6 h1:O4Tjo2vlGeGM3+tNgwJrGj5fQHO2V71gDkFqbm328C8=
-google.golang.org/genproto/googleapis/api v0.0.0-20260908043556-f8649ddbbfe6/go.mod h1:r4KD2hOq82JBWpTWkJ9NZLf6EwmRvAPIFmk7hPNtd+0=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6 h1:ieEbjQ6lzbvntOXUB9nMx9uH+yIU/HbgkNDjnk/mJuk=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
-google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
-google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b h1:nB3spRIaSPtxJpxb0k41yljROvm2Jrwfs2pSNdKS5y8=
+google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b/go.mod h1:9N4vtjEwvgQKEdlFVQO0M44ErhecQ9DCSQfGoZ51M6w=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b h1:6WNWHZk91dp4gZ25642OALkuG1UVNZbwENJD19DnB8k=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
+google.golang.org/protobuf v1.36.13-0.20261016172727-e9ee2a76ab8e h1:TU4XCsANCrpOpeS6NA+LhCA6H12DfxF2gIcYBrWcdwc=
+google.golang.org/protobuf v1.36.13-0.20261016172727-e9ee2a76ab8e/go.mod h1:X7eSFOSeyjM8zqq7vd+xpgLluojDTuH3nwq1zr6OCKg=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -766,15 +768,15 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
 gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
-honnef.co/go/tools v0.8.1 h1:+JKf3xJ1ni4CwrhVg4/pqsfPGP6vNAXcKbMXJodYx3w=
-honnef.co/go/tools v0.8.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks=
+honnef.co/go/tools v0.8.2 h1:+jPbCzuYxYrcoOvCecYUFDj+XJQRmzIhJmlbpps21Zk=
+honnef.co/go/tools v0.8.2/go.mod h1:xG1Q4n+mg3bKZrO6RWFA9jFlkqplflmDRHGeuexiLDs=
 mvdan.cc/gofumpt v0.12.0 h1:1Lbudkz2kpM9Cjz2pL4M19u7q+GaEhCTNf7N9mfpcho=
 mvdan.cc/gofumpt v0.12.0/go.mod h1:SmBHHrljiZu/uoypeKup3rFzP6eoC9UwCp2iH5E3jZA=
-mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 h1:Re1NRyLpiAt9kB+ImaaoapwWiQXrKwER4tY8fLOkDew=
-mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8/go.mod h1:MrS/+zJ1M2xvGXhKktiHbNQeQPyg3Qel+KkzT+f7/i4=
+mvdan.cc/unparam v0.0.0-20261011093844-7a2c2c0b7aa6 h1:BPYbCqDxFpG6SoLzbiU5hVn8kHW89plTWGddjT3OD7I=
+mvdan.cc/unparam v0.0.0-20261011093844-7a2c2c0b7aa6/go.mod h1:BEImwwhtsPZ0eyZhBtvrirFOvbQ7HmEnmfdFl3tz9M0=
 mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
 mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
 pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
 pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
-pluginrpc.com/pluginrpc v0.5.0 h1:tOQj2D35hOmvHyPu8e7ohW2/QvAnEtKscy2IJYWQ2yo=
-pluginrpc.com/pluginrpc v0.5.0/go.mod h1:UNWZ941hcVAoOZUn8YZsMmOZBzbUjQa3XMns8RQLp9o=
+pluginrpc.com/pluginrpc v0.6.0 h1:Dx5r3y9Nbbt02NSm+bKHvs+WgXM34lalD5grkjd8sno=
+pluginrpc.com/pluginrpc v0.6.0/go.mod h1:9FPMdIy8nmkALE5eSe2ipE42G83WpZnA2+I7M7ADijY=

+ 3 - 2
service/internal/api/api.go

@@ -8,6 +8,7 @@ import (
 	"net/http"
 	"os"
 	"path"
+	"slices"
 	"sort"
 	"strings"
 	"sync"
@@ -806,8 +807,8 @@ func (api *oliveTinAPI) buildActionLogsResponse(filtered []*executor.InternalLog
 	startIdx, endIdx := calculateReversedIndices(page, len(filtered))
 	ret := &apiv1.GetActionLogsResponse{}
 	chunk := filtered[int(startIdx):int(endIdx)]
-	for i := len(chunk) - 1; i >= 0; i-- {
-		ret.Logs = append(ret.Logs, api.internalLogEntryToPb(chunk[i], user))
+	for _, c := range slices.Backward(chunk) {
+		ret.Logs = append(ret.Logs, api.internalLogEntryToPb(c, user))
 	}
 	ret.CountRemaining = page.start
 	ret.PageSize = page.size

+ 1 - 4
service/internal/api/api_entities_list.go

@@ -156,10 +156,7 @@ func paginateEntityInstances(instances []*apiv1.Entity, page, pageSize int32) []
 		return []*apiv1.Entity{}
 	}
 
-	end := start + int64(pageSize)
-	if end > count {
-		end = count
-	}
+	end := min(start+int64(pageSize), count)
 
 	return instances[int(start):int(end)]
 }

+ 12 - 8
service/internal/api/api_entity_related_actions_test.go

@@ -92,9 +92,11 @@ func buildRelatedActionsTestConfig(t *testing.T) (*config.Config, *authpublic.Au
 	return cfg, lowUser, adminUser
 }
 
-func getEntityRelatedActionTitles(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType, entityKey string) []string {
+func getEntityRelatedActionTitles(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityKey string) []string {
 	t.Helper()
 
+	entityType := "host"
+
 	entity, ok := entities.GetEntityInstances(entityType)[entityKey]
 	require.True(t, ok, "entity %s/%s must exist", entityType, entityKey)
 
@@ -108,9 +110,11 @@ func getEntityRelatedActionTitles(t *testing.T, api *oliveTinAPI, user *authpubl
 	return titles
 }
 
-func getEntityRelatedBindingIDs(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType, entityKey string) []string {
+func getEntityRelatedBindingIDs(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityKey string) []string {
 	t.Helper()
 
+	entityType := "host"
+
 	entity, ok := entities.GetEntityInstances(entityType)[entityKey]
 	require.True(t, ok, "entity %s/%s must exist", entityType, entityKey)
 
@@ -132,10 +136,10 @@ func TestGetEntityRelatedActionsDeniesRestrictedView(t *testing.T) {
 	ex.RebuildActionMap()
 	api := newServer(ex)
 
-	ids := getEntityRelatedBindingIDs(t, api, lowUser, "host", "0")
+	ids := getEntityRelatedBindingIDs(t, api, lowUser, "0")
 	assert.Empty(t, ids)
 
-	titles := getEntityRelatedActionTitles(t, api, lowUser, "host", "0")
+	titles := getEntityRelatedActionTitles(t, api, lowUser, "0")
 	assert.Empty(t, titles)
 }
 
@@ -147,7 +151,7 @@ func TestGetEntityRelatedActionsAllowsAdminView(t *testing.T) {
 	ex.RebuildActionMap()
 	api := newServer(ex)
 
-	titles := getEntityRelatedActionTitles(t, api, adminUser, "host", "0")
+	titles := getEntityRelatedActionTitles(t, api, adminUser, "0")
 	assert.Contains(t, titles, "Run Automation Playbook")
 	assert.Contains(t, titles, "Public Host Action")
 	assert.Contains(t, titles, "Secret Entity Action")
@@ -161,7 +165,7 @@ func TestGetEntityRelatedActionsExcludesHiddenActions(t *testing.T) {
 	ex.RebuildActionMap()
 	api := newServer(ex)
 
-	titles := getEntityRelatedActionTitles(t, api, adminUser, "host", "0")
+	titles := getEntityRelatedActionTitles(t, api, adminUser, "0")
 	assert.NotContains(t, titles, "Hidden Host Action")
 }
 
@@ -179,11 +183,11 @@ func TestGetEntityRelatedActionsEntityBoundMatchesInstanceOnly(t *testing.T) {
 	api := newServer(ex)
 	user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
 
-	titlesHost0 := getEntityRelatedActionTitles(t, api, user, "host", "0")
+	titlesHost0 := getEntityRelatedActionTitles(t, api, user, "0")
 	assert.Len(t, titlesHost0, 1)
 	assert.Equal(t, "stuffbox Wake", titlesHost0[0])
 
-	titlesHost1 := getEntityRelatedActionTitles(t, api, user, "host", "1")
+	titlesHost1 := getEntityRelatedActionTitles(t, api, user, "1")
 	assert.Len(t, titlesHost1, 1)
 	assert.Equal(t, "lurker Wake", titlesHost1[0])
 }

+ 3 - 3
service/internal/api/api_init_search_hints_test.go

@@ -190,7 +190,7 @@ func TestBuildSearchHintsCapsActionsAndEntitiesPerType(t *testing.T) {
 		entities.ClearEntitiesOfType("cap_container")
 	})
 
-	for entityIndex := 0; entityIndex < maxSearchHintEntitiesPerType+5; entityIndex++ {
+	for entityIndex := range maxSearchHintEntitiesPerType + 5 {
 		entities.AddEntity("cap_host", fmt.Sprintf("%03d", entityIndex), map[string]any{"name": fmt.Sprintf("host-%03d", entityIndex)})
 		entities.AddEntity("cap_container", fmt.Sprintf("%03d", entityIndex), map[string]any{"name": fmt.Sprintf("ctr-%03d", entityIndex)})
 	}
@@ -201,7 +201,7 @@ func TestBuildSearchHintsCapsActionsAndEntitiesPerType(t *testing.T) {
 		{Name: "cap_container", File: "cap_container.yaml"},
 	}
 	cfg.Actions = make([]*config.Action, 0, maxSearchHintActions+5)
-	for actionIndex := 0; actionIndex < maxSearchHintActions+5; actionIndex++ {
+	for actionIndex := range maxSearchHintActions + 5 {
 		cfg.Actions = append(cfg.Actions, &config.Action{
 			ID:    fmt.Sprintf("action-%03d", actionIndex),
 			Title: fmt.Sprintf("Action %03d", actionIndex),
@@ -240,7 +240,7 @@ func TestBuildSearchHintsPrefersNonEntityActions(t *testing.T) {
 		entities.ClearEntitiesOfType("host")
 	})
 
-	for i := 0; i < 10; i++ {
+	for i := range 10 {
 		entities.AddEntity("host", fmt.Sprintf("%d", i), map[string]any{"name": fmt.Sprintf("host-%d", i)})
 	}
 

+ 6 - 6
service/internal/api/api_log_arguments_test.go

@@ -14,10 +14,10 @@ import (
 	"github.com/OliveTin/OliveTin/internal/executor"
 )
 
-func argumentAction(title, shell string, args []config.ActionArgument) *config.Action {
+func argumentAction(title string, args []config.ActionArgument) *config.Action {
 	return &config.Action{
 		Title:         title,
-		Shell:         shell,
+		Shell:         "echo {{ host }}",
 		MaxConcurrent: 1,
 		Arguments:     args,
 	}
@@ -75,7 +75,7 @@ func waitForLogJustification(t *testing.T, ex *executor.Executor, trackingID, ex
 func TestExecutionStatusIncludesStoredArguments(t *testing.T) {
 	cfg := config.DefaultConfig()
 	cfg.Actions = []*config.Action{
-		argumentAction("Ping host with stored args", "echo {{ host }}", []config.ActionArgument{
+		argumentAction("Ping host with stored args", []config.ActionArgument{
 			{Name: "host", Type: "ascii_identifier"},
 		}),
 	}
@@ -159,7 +159,7 @@ func TestExecutionStatusOmitsPasswordArguments(t *testing.T) {
 func TestRestartActionReusesStoredArguments(t *testing.T) {
 	cfg := config.DefaultConfig()
 	cfg.Actions = []*config.Action{
-		argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{
+		argumentAction("Ping host", []config.ActionArgument{
 			{Name: "host", Type: "ascii_identifier"},
 		}),
 	}
@@ -236,7 +236,7 @@ func TestRestartActionRejectsIncompleteStoredArguments(t *testing.T) {
 func TestRestartActionRejectsMissingRequiredStoredArguments(t *testing.T) {
 	cfg := config.DefaultConfig()
 	cfg.Actions = []*config.Action{
-		argumentAction("Ping host - reject missing required stored arg", "echo {{ host }}", []config.ActionArgument{
+		argumentAction("Ping host - reject missing required stored arg", []config.ActionArgument{
 			{Name: "host", Type: "ascii_identifier"},
 		}),
 	}
@@ -368,7 +368,7 @@ func TestRestartActionReusesStoredJustificationViaStartActionPath(t *testing.T)
 func TestGetLogsIncludesStoredArguments(t *testing.T) {
 	cfg := config.DefaultConfig()
 	cfg.Actions = []*config.Action{
-		argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{
+		argumentAction("Ping host", []config.ActionArgument{
 			{Name: "host", Type: "ascii_identifier"},
 		}),
 	}

+ 2 - 2
service/internal/api/api_test.go

@@ -864,7 +864,7 @@ func TestRegisterStreamingClientEnforcesLimit(t *testing.T) {
 	user := &authpublic.AuthenticatedUser{Username: "limit-test"}
 
 	clients := make([]*streamingClient, 0, maxEventStreamClients)
-	for i := 0; i < maxEventStreamClients; i++ {
+	for range maxEventStreamClients {
 		client := &streamingClient{
 			channel:           make(chan *apiv1.EventStreamResponse, 1),
 			AuthenticatedUser: user,
@@ -912,7 +912,7 @@ func TestEventStreamReleasesSlotWhenClientDisconnects(t *testing.T) {
 	server := ensureExecutorListener(ex)
 
 	cancels := make([]context.CancelFunc, 0, maxEventStreamClients)
-	for i := 0; i < maxEventStreamClients; i++ {
+	for range maxEventStreamClients {
 		cancels = append(cancels, openEventStreamAndReadHeartbeat(t, client))
 	}
 	require.Equal(t, maxEventStreamClients, countStreamingClients(server))

+ 1 - 1
service/internal/api/dashboard_acl_test.go

@@ -100,7 +100,7 @@ func TestRootDashboardEntriesIncludeCategory(t *testing.T) {
 	entries := api.buildRootDashboardEntries(user, cfg.Dashboards)
 	require.Len(t, entries, 4)
 	assert.Equal(t, []string{"Misc Tools", "My Servers", "Status Board", "My Containers"}, rootDashboardTitles(entries))
-	assert.Equal(t, "", entries[0].Category)
+	assert.Empty(t, entries[0].Category)
 	assert.Equal(t, "Infrastructure", entries[1].Category)
 	assert.Equal(t, "Monitoring", entries[2].Category)
 	assert.Equal(t, "Infrastructure", entries[3].Category)

+ 4 - 0
service/internal/api/dashboards.go

@@ -148,6 +148,8 @@ func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
 		Contents: make([]*apiv1.DashboardComponent, 0),
 	}
 
+	rr.ex.MapActionBindingsLock.RLock()
+
 	for _, binding := range rr.ex.MapActionBindings {
 		if !defaultBindingEligibleForDashboard(rr, binding) {
 			continue
@@ -158,6 +160,8 @@ func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
 		}
 	}
 
+	rr.ex.MapActionBindingsLock.RUnlock()
+
 	if len(fieldset.Contents) > 0 {
 		fieldset.Contents = sortDashboardComponents(fieldset.Contents)
 		db.Contents = append(db.Contents, fieldset)

+ 44 - 0
service/internal/api/dashboards_race_test.go

@@ -0,0 +1,44 @@
+package api
+
+import (
+	"sync"
+	"testing"
+
+	"github.com/OliveTin/OliveTin/internal/executor"
+)
+
+// TestBuildDefaultDashboardConcurrentWithRebuild guards against a crash when the
+// default dashboard is built (e.g. by Init) while a config or entity reload
+// rebuilds the action map. Without the read lock in buildDefaultDashboard, the
+// Go runtime aborts with "concurrent map iteration and map write"; run with
+// -race to see the data race directly.
+func TestBuildDefaultDashboardConcurrentWithRebuild(t *testing.T) {
+	cfg, _, adminUser := buildViewPermissionTestConfig(t)
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+
+	rr := &DashboardRenderRequest{
+		AuthenticatedUser: adminUser,
+		cfg:               cfg,
+		ex:                ex,
+	}
+
+	var wg sync.WaitGroup
+	wg.Add(2)
+
+	go func() {
+		defer wg.Done()
+		for range 200 {
+			ex.RebuildActionMap()
+		}
+	}()
+
+	go func() {
+		defer wg.Done()
+		for range 200 {
+			buildDefaultDashboard(rr)
+		}
+	}()
+
+	wg.Wait()
+}

+ 1 - 1
service/internal/auth/authpublic/authenticateduser.go

@@ -26,7 +26,7 @@ func (u *AuthenticatedUser) parseUsergroupLine(sep string) []string {
 	ret := []string{}
 
 	if sep != "" {
-		for _, v := range strings.Split(u.UsergroupLine, sep) {
+		for v := range strings.SplitSeq(u.UsergroupLine, sep) {
 			trimmed := strings.TrimSpace(v)
 
 			if trimmed != "" {

+ 6 - 6
service/internal/auth/otoauth2/restapi_auth_oauth2_test.go

@@ -43,13 +43,13 @@ func TestGetGroupFieldString(t *testing.T) {
 func TestGetGroupFieldMissing(t *testing.T) {
 	data := map[string]any{}
 
-	assert.Equal(t, "", getGroupField(data, "olivetin_group", ""))
+	assert.Empty(t, getGroupField(data, "olivetin_group", ""))
 }
 
 func TestGetGroupFieldEmptyFieldName(t *testing.T) {
 	data := map[string]any{"olivetin_group": "admins"}
 
-	assert.Equal(t, "", getGroupField(data, "", ""))
+	assert.Empty(t, getGroupField(data, "", ""))
 }
 
 func TestGetGroupFieldArrayDefaultSeparator(t *testing.T) {
@@ -73,13 +73,13 @@ func TestGetGroupFieldArraySkipsNonStringElements(t *testing.T) {
 func TestGetGroupFieldArrayAllNonStringElements(t *testing.T) {
 	data := map[string]any{"groups": []any{float64(1), true}}
 
-	assert.Equal(t, "", getGroupField(data, "groups", ""))
+	assert.Empty(t, getGroupField(data, "groups", ""))
 }
 
 func TestGetGroupFieldNotStringOrArray(t *testing.T) {
 	data := map[string]any{"groups": map[string]any{"nested": "value"}}
 
-	assert.Equal(t, "", getGroupField(data, "groups", ""))
+	assert.Empty(t, getGroupField(data, "groups", ""))
 }
 
 func TestGetUserInfoWithArrayGroupsClaim(t *testing.T) {
@@ -141,7 +141,7 @@ func TestHandleOAuthLoginRejectsWhenStateMapFull(t *testing.T) {
 
 	h := NewOAuth2Handler(cfg)
 	h.registeredStates = make(map[string]*oauth2State, oauthStateMaxEntries)
-	for i := 0; i < oauthStateMaxEntries; i++ {
+	for i := range oauthStateMaxEntries {
 		h.registeredStates[strconv.Itoa(i)] = &oauth2State{
 			providerConfig: &oauth2.Config{},
 			providerName:   "test",
@@ -155,5 +155,5 @@ func TestHandleOAuthLoginRejectsWhenStateMapFull(t *testing.T) {
 	h.HandleOAuthLogin(rec, req)
 
 	assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
-	assert.Equal(t, oauthStateMaxEntries, len(h.registeredStates))
+	assert.Len(t, h.registeredStates, oauthStateMaxEntries)
 }

+ 25 - 2
service/internal/config/config_reloader.go

@@ -45,6 +45,8 @@ func AppendSource(cfg *Config, k *koanf.Koanf, configPath string) {
 	}).Info("Appending cfg source")
 
 	configissues.BeginConfigLoad()
+	BeginLoadedSources()
+	RecordLoadedSource(configPath)
 	stampLoadedConfigSources(k, configPath)
 	loadIncludedConfigsFromDir(k, configPath)
 
@@ -71,12 +73,25 @@ func unmarshalRoot(k *koanf.Koanf, cfg *Config) bool {
 	})
 
 	if err != nil {
-		log.Errorf("Error unmarshalling config: %v", err)
+		reportDecodeFailure(err)
 		return false
 	}
 	return true
 }
 
+// A decode failure stops AppendSource before the issue list is rebuilt, so the
+// issue is published to the live list immediately as well as kept for later
+// rebuilds.
+func reportDecodeFailure(err error) {
+	issue := configissues.Issue{
+		Severity: configissues.SeverityError,
+		Code:     configissues.CodeConfigDecode,
+		Message:  fmt.Sprintf("Configuration could not be decoded: %v", err),
+	}
+	configissues.ReportSticky(issue)
+	configissues.Report(issue)
+}
+
 func afterLoadFinalize(cfg *Config, configPath string) {
 	metricConfigReloadedCount.Inc()
 	metricConfigActionCount.Set(float64(len(cfg.Actions)))
@@ -240,10 +255,18 @@ func loadAndMergeIncludedFile(k *koanf.Koanf, includePath, filename string) {
 	filePath := filepath.Join(includePath, filename)
 
 	if err := k.Load(file.Provider(filePath), yaml.Parser(), koanf.WithMergeFunc(mergeFuncForSource(filePath))); err != nil {
+		configissues.ReportSticky(configissues.Issue{
+			Severity:   configissues.SeverityError,
+			Code:       configissues.CodeIncludeParse,
+			Message:    fmt.Sprintf("Included config file could not be loaded: %v", err),
+			ConfigFile: filePath,
+		})
 		log.Errorf("Error loading included config file %s: %v", filePath, err)
 		return
 	}
 
+	RecordLoadedSource(filePath)
+
 	log.WithFields(log.Fields{
 		"filePath": filePath,
 	}).Info("Successfully loaded included config file")
@@ -350,7 +373,7 @@ func justificationDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (
 }
 
 func envDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error) {
-	log.Debugf("envDecodeHookFunc called: from=%v, to=%v, data=%v", from, to, data)
+	log.Tracef("envDecodeHookFunc called: from=%v, to=%v, data=%v", from, to, data)
 	if from.Kind() != reflect.String {
 		return data, nil
 	}

+ 8 - 8
service/internal/config/config_reloader_user_test.go

@@ -42,7 +42,7 @@ actions:
 	// Load config using koanf
 	k := koanf.New(".")
 	err = k.Load(file.Provider(tmpFile.Name()), yaml.Parser())
-	assert.NoError(t, err, "Should load config file")
+	require.NoError(t, err, "Should load config file")
 
 	// Create config struct and load it
 	cfg := &Config{}
@@ -50,7 +50,7 @@ actions:
 
 	// Test that authLocalUsers was loaded correctly
 	assert.True(t, cfg.AuthLocalUsers.Enabled, "AuthLocalUsers should be enabled")
-	assert.Equal(t, 2, len(cfg.AuthLocalUsers.Users), "Should load 2 users")
+	assert.Len(t, cfg.AuthLocalUsers.Users, 2, "Should load 2 users")
 
 	// Test individual users
 	user1 := cfg.FindUserByUsername("testuser1")
@@ -91,13 +91,13 @@ actions:
 
 	k := koanf.New(".")
 	err = k.Load(file.Provider(tmpFile.Name()), yaml.Parser())
-	assert.NoError(t, err, "Should load config file")
+	require.NoError(t, err, "Should load config file")
 
 	cfg := &Config{}
 	AppendSource(cfg, k, tmpFile.Name())
 
 	assert.True(t, cfg.AuthLocalUsers.Enabled, "AuthLocalUsers should be enabled")
-	assert.Equal(t, 0, len(cfg.AuthLocalUsers.Users), "Should have 0 users")
+	assert.Empty(t, cfg.AuthLocalUsers.Users, "Should have 0 users")
 	assert.Nil(t, cfg.FindUserByUsername("anyuser"), "Should return nil for any user")
 }
 
@@ -126,13 +126,13 @@ actions:
 
 	k := koanf.New(".")
 	err = k.Load(file.Provider(tmpFile.Name()), yaml.Parser())
-	assert.NoError(t, err, "Should load config file")
+	require.NoError(t, err, "Should load config file")
 
 	cfg := &Config{}
 	AppendSource(cfg, k, tmpFile.Name())
 
 	assert.False(t, cfg.AuthLocalUsers.Enabled, "AuthLocalUsers should be disabled")
-	assert.Equal(t, 1, len(cfg.AuthLocalUsers.Users), "Should still load users even when disabled")
+	assert.Len(t, cfg.AuthLocalUsers.Users, 1, "Should still load users even when disabled")
 
 	// User should still be findable even when auth is disabled
 	user := cfg.FindUserByUsername("testuser")
@@ -157,13 +157,13 @@ actions:
 
 	k := koanf.New(".")
 	err = k.Load(file.Provider(tmpFile.Name()), yaml.Parser())
-	assert.NoError(t, err, "Should load config file")
+	require.NoError(t, err, "Should load config file")
 
 	cfg := &Config{}
 	AppendSource(cfg, k, tmpFile.Name())
 
 	// Should have default values
 	assert.False(t, cfg.AuthLocalUsers.Enabled, "AuthLocalUsers should be disabled by default")
-	assert.Equal(t, 0, len(cfg.AuthLocalUsers.Users), "Should have 0 users by default")
+	assert.Empty(t, cfg.AuthLocalUsers.Users, "Should have 0 users by default")
 	assert.Nil(t, cfg.FindUserByUsername("anyuser"), "Should return nil for any user")
 }

+ 4 - 3
service/internal/config/entity_argument_choices_test.go

@@ -4,10 +4,11 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestValidateEntityArgumentChoicesForArgument(t *testing.T) {
-	assert.NoError(t, validateEntityArgumentChoicesForArgument("Reboot", ActionArgument{
+	require.NoError(t, validateEntityArgumentChoicesForArgument("Reboot", ActionArgument{
 		Name:   "target",
 		Entity: "servers",
 		Choices: []ActionArgumentChoice{
@@ -15,7 +16,7 @@ func TestValidateEntityArgumentChoicesForArgument(t *testing.T) {
 		},
 	}))
 
-	assert.Error(t, validateEntityArgumentChoicesForArgument("Reboot", ActionArgument{
+	require.Error(t, validateEntityArgumentChoicesForArgument("Reboot", ActionArgument{
 		Name:   "target",
 		Entity: "servers",
 		Choices: []ActionArgumentChoice{
@@ -24,7 +25,7 @@ func TestValidateEntityArgumentChoicesForArgument(t *testing.T) {
 		},
 	}))
 
-	assert.Error(t, validateEntityArgumentChoicesForArgument("Reboot", ActionArgument{
+	require.Error(t, validateEntityArgumentChoicesForArgument("Reboot", ActionArgument{
 		Name:    "target",
 		Entity:  "servers",
 		Choices: nil,

+ 83 - 0
service/internal/config/load_failure_issues_test.go

@@ -0,0 +1,83 @@
+package config_test
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/configissues"
+	"github.com/knadh/koanf/parsers/yaml"
+	"github.com/knadh/koanf/providers/file"
+	"github.com/knadh/koanf/v2"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func appendSourceFromFile(t *testing.T, basePath string) {
+	t.Helper()
+	k := koanf.New(".")
+	require.NoError(t, k.Load(file.Provider(basePath), yaml.Parser()))
+	config.AppendSource(config.DefaultConfig(), k, basePath)
+}
+
+func findStickyIssue(code string) *configissues.Issue {
+	for _, issue := range configissues.CopySticky() {
+		if issue.Code == code {
+			return &issue
+		}
+	}
+	return nil
+}
+
+func TestAppendSourceReportsUnparseableIncludeFile(t *testing.T) {
+	dir := t.TempDir()
+	includeDir := filepath.Join(dir, "config.d")
+	require.NoError(t, os.Mkdir(includeDir, 0o755))
+
+	basePath := filepath.Join(dir, "config.yaml")
+	require.NoError(t, os.WriteFile(basePath, []byte("include: config.d\n"), 0o644))
+
+	includePath := filepath.Join(includeDir, "10-broken.yaml")
+	require.NoError(t, os.WriteFile(includePath, []byte("actions: [\n  - title: broken\n"), 0o644))
+
+	appendSourceFromFile(t, basePath)
+
+	issue := findStickyIssue(configissues.CodeIncludeParse)
+	require.NotNil(t, issue)
+	assert.Equal(t, configissues.SeverityError, issue.Severity)
+	assert.Equal(t, includePath, issue.ConfigFile)
+}
+
+func TestAppendSourceReportsDecodeFailure(t *testing.T) {
+	basePath := filepath.Join(t.TempDir(), "config.yaml")
+	require.NoError(t, os.WriteFile(basePath, []byte("actions: not-a-list-of-actions\n"), 0o644))
+
+	appendSourceFromFile(t, basePath)
+
+	issue := findStickyIssue(configissues.CodeConfigDecode)
+	require.NotNil(t, issue)
+	assert.Equal(t, configissues.SeverityError, issue.Severity)
+	assert.True(t, hasLiveIssue(configissues.CodeConfigDecode), "decode failure should be visible without a rebuild")
+}
+
+func hasLiveIssue(code string) bool {
+	for _, issue := range configissues.List() {
+		if issue.Code == code {
+			return true
+		}
+	}
+	return false
+}
+
+func TestAppendSourceClearsLoadFailuresOnSuccessfulLoad(t *testing.T) {
+	basePath := filepath.Join(t.TempDir(), "config.yaml")
+	require.NoError(t, os.WriteFile(basePath, []byte("actions: not-a-list-of-actions\n"), 0o644))
+	appendSourceFromFile(t, basePath)
+	require.NotNil(t, findStickyIssue(configissues.CodeConfigDecode))
+
+	require.NoError(t, os.WriteFile(basePath, []byte("pageTitle: Fixed\n"), 0o644))
+	appendSourceFromFile(t, basePath)
+
+	assert.Nil(t, findStickyIssue(configissues.CodeConfigDecode))
+}

+ 43 - 0
service/internal/config/loaded_sources.go

@@ -0,0 +1,43 @@
+package config
+
+import "slices"
+
+import "sync"
+
+var (
+	loadedSourcesMu sync.Mutex
+	loadedSources   []string
+)
+
+// BeginLoadedSources clears the list of config files recorded for the current load.
+func BeginLoadedSources() {
+	loadedSourcesMu.Lock()
+	defer loadedSourcesMu.Unlock()
+	loadedSources = nil
+}
+
+// RecordLoadedSource records a config file that was successfully read during parsing.
+func RecordLoadedSource(path string) {
+	if path == "" {
+		return
+	}
+
+	loadedSourcesMu.Lock()
+	defer loadedSourcesMu.Unlock()
+
+	if slices.Contains(loadedSources, path) {
+		return
+	}
+
+	loadedSources = append(loadedSources, path)
+}
+
+// LoadedSources returns a copy of config files read during the latest config load.
+func LoadedSources() []string {
+	loadedSourcesMu.Lock()
+	defer loadedSourcesMu.Unlock()
+
+	out := make([]string, len(loadedSources))
+	copy(out, loadedSources)
+	return out
+}

+ 22 - 0
service/internal/config/loaded_sources_test.go

@@ -0,0 +1,22 @@
+package config
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestLoadedSourcesRecordsUniquePaths(t *testing.T) {
+	BeginLoadedSources()
+	assert.Empty(t, LoadedSources())
+
+	RecordLoadedSource("/tmp/config.yaml")
+	RecordLoadedSource("/tmp/config.d/a.yaml")
+	RecordLoadedSource("/tmp/config.yaml")
+	RecordLoadedSource("")
+
+	assert.Equal(t, []string{"/tmp/config.yaml", "/tmp/config.d/a.yaml"}, LoadedSources())
+
+	BeginLoadedSources()
+	assert.Empty(t, LoadedSources())
+}

+ 2 - 0
service/internal/configissues/issue.go

@@ -11,6 +11,8 @@ const (
 	CodeEntityArgumentChoices  = "entity_argument_choices"
 	CodeEnvUnset               = "env_unset"
 	CodeIncludeMissing         = "include_missing"
+	CodeIncludeParse           = "include_parse"
+	CodeConfigDecode           = "config_decode"
 	CodeTemplateParse          = "template_parse"
 	CodeArgDefaultInvalid      = "arg_default_invalid"
 	CodeEntityFile             = "entity_file"

+ 2 - 2
service/internal/entities/entities_test.go

@@ -58,8 +58,8 @@ func TestDisplayNameFieldKey(t *testing.T) {
 	assert.Equal(t, "title", DisplayNameFieldKey(map[string]any{"title": "Car"}))
 	assert.Equal(t, "name", DisplayNameFieldKey(map[string]any{"name": "Car"}))
 	assert.Equal(t, "Name", DisplayNameFieldKey(map[string]any{"Name": "Car"}))
-	assert.Equal(t, "", DisplayNameFieldKey(map[string]any{"status": "running"}))
-	assert.Equal(t, "", DisplayNameFieldKey("not a map"))
+	assert.Empty(t, DisplayNameFieldKey(map[string]any{"status": "running"}))
+	assert.Empty(t, DisplayNameFieldKey("not a map"))
 }
 
 func TestDisplayNameFieldKey_caseCollisionIsDeterministic(t *testing.T) {

+ 3 - 3
service/internal/executor/executor.go

@@ -1313,8 +1313,8 @@ func substituteShellAfterCompletedEnvRefs(command string) string {
 
 func replaceShellAfterEnvRef(command string, pattern *regexp.Regexp, envRef string) string {
 	matches := pattern.FindAllStringIndex(command, -1)
-	for i := len(matches) - 1; i >= 0; i-- {
-		start, end := matches[i][0], matches[i][1]
+	for _, match := range slices.Backward(matches) {
+		start, end := match[0], match[1]
 		replacement := `"` + envRef + `"`
 		if shellPosInsideSingleQuotes(command, start) {
 			// Break out of single quotes so the env ref can expand at runtime.
@@ -1528,7 +1528,7 @@ func sanitizeLogFilename(title string) string {
 	}
 
 	// NUL and other C0 controls plus DEL are invalid or problematic in filenames.
-	for i := 0; i < 32; i++ {
+	for i := range 32 {
 		oldnew = append(oldnew, string(rune(i)), "_")
 	}
 	oldnew = append(oldnew, "\x7f", "_")

+ 3 - 0
service/internal/executor/executor_actions.go

@@ -101,6 +101,9 @@ func (e *Executor) RebuildActionMap() {
 	}
 
 	for configOrder, action := range e.Cfg.Actions {
+		if action == nil {
+			continue
+		}
 		if action.Entity != "" {
 			registerActionsFromEntities(e, configOrder, action.Entity, action, req)
 		} else {

+ 42 - 34
service/internal/executor/executor_test.go

@@ -158,7 +158,7 @@ func TestArgumentNameCamelCase(t *testing.T) {
 	out, err := parseActionArguments(req)
 
 	assert.Equal(t, "echo 'Tickling Fred'", out)
-	assert.Nil(t, err)
+	assert.NoError(t, err)
 }
 
 func TestArgumentNameSnakeCase(t *testing.T) {
@@ -181,7 +181,7 @@ func TestArgumentNameSnakeCase(t *testing.T) {
 	out, err := parseActionArguments(req)
 
 	assert.Equal(t, "echo 'Tickling Fred'", out)
-	assert.Nil(t, err)
+	assert.NoError(t, err)
 }
 
 func TestGetLogsEmpty(t *testing.T) {
@@ -192,7 +192,7 @@ func TestGetLogsEmpty(t *testing.T) {
 	logs, paging := e.GetLogTrackingIds(0, 10)
 
 	assert.NotNil(t, logs, "Logs should not be nil")
-	assert.Equal(t, 0, len(logs), "No logs yet")
+	assert.Empty(t, logs, "No logs yet")
 	assert.Equal(t, int64(0), paging.CountRemaining, "There should be no remaining logs")
 }
 
@@ -212,35 +212,35 @@ func TestGetLogsLessThanPageSize(t *testing.T) {
 
 	logEntries, paging := e.GetLogTrackingIds(0, 10)
 
-	assert.Equal(t, 0, len(logEntries), "There should be 0 logs")
+	assert.Empty(t, logEntries, "There should be 0 logs")
 	assert.Zero(t, paging.CountRemaining, "There should be no remaining logs")
 
-	execNewReqAndWait(e, "blat", cfg)
-	execNewReqAndWait(e, "blat", cfg)
-	execNewReqAndWait(e, "blat", cfg)
-	execNewReqAndWait(e, "blat", cfg)
-	execNewReqAndWait(e, "blat", cfg)
-	execNewReqAndWait(e, "blat", cfg)
-	execNewReqAndWait(e, "blat", cfg)
+	execNewReqAndWait(e, cfg)
+	execNewReqAndWait(e, cfg)
+	execNewReqAndWait(e, cfg)
+	execNewReqAndWait(e, cfg)
+	execNewReqAndWait(e, cfg)
+	execNewReqAndWait(e, cfg)
+	execNewReqAndWait(e, cfg)
 
 	logEntries, paging = e.GetLogTrackingIds(0, 10)
 
-	assert.Equal(t, 7, len(logEntries), "There should be 7 logs")
+	assert.Len(t, logEntries, 7, "There should be 7 logs")
 	assert.Zero(t, paging.CountRemaining, "There should be no remaining logs")
 
-	execNewReqAndWait(e, "blat", cfg)
-	execNewReqAndWait(e, "blat", cfg)
-	execNewReqAndWait(e, "blat", cfg)
-	execNewReqAndWait(e, "blat", cfg)
-	execNewReqAndWait(e, "blat", cfg)
+	execNewReqAndWait(e, cfg)
+	execNewReqAndWait(e, cfg)
+	execNewReqAndWait(e, cfg)
+	execNewReqAndWait(e, cfg)
+	execNewReqAndWait(e, cfg)
 
 	logEntries, paging = e.GetLogTrackingIds(0, 10)
 
-	assert.Equal(t, 10, len(logEntries), "There should be 10 logs")
+	assert.Len(t, logEntries, 10, "There should be 10 logs")
 	assert.Equal(t, int64(2), paging.CountRemaining, "There should be 1 remaining logs")
 }
 
-func execNewReqAndWait(e *Executor, title string, cfg *config.Config) {
+func execNewReqAndWait(e *Executor, cfg *config.Config) {
 	req := &ExecutionRequest{
 		//		ActionTitle: title,
 		Cfg: cfg,
@@ -250,7 +250,7 @@ func execNewReqAndWait(e *Executor, title string, cfg *config.Config) {
 	e.RebuildActionMap()
 	var action *config.Action
 	for _, a := range cfg.Actions {
-		if a.Title == title {
+		if a.Title == "blat" {
 			action = a
 			break
 		}
@@ -288,8 +288,8 @@ func TestUnsetRequiredArgument(t *testing.T) {
 
 	out, err := parseActionArguments(req)
 
-	assert.Equal(t, "", out)
-	assert.NotNil(t, err)
+	assert.Empty(t, out)
+	assert.Error(t, err)
 }
 
 func TestUnusedArgumentStillPassesTypeSafetyCheck(t *testing.T) {
@@ -316,8 +316,8 @@ func TestUnusedArgumentStillPassesTypeSafetyCheck(t *testing.T) {
 
 	out, err := parseActionArguments(req)
 
-	assert.Equal(t, "", out)
-	assert.NotNil(t, err)
+	assert.Empty(t, out)
+	assert.Error(t, err)
 }
 
 // https://github.com/OliveTin/OliveTin/issues/564
@@ -357,7 +357,7 @@ func TestMangleInvalidArgumentValues(t *testing.T) {
 	wg.Wait()
 
 	assert.NotNil(t, req.logEntry, "Log entry should not be nil")
-	assert.Equal(t, req.logEntry.Output, "The date is: 1990-01-10T12:00:00\n", "Date should be mangled to a valid format")
+	assert.Equal(t, "The date is: 1990-01-10T12:00:00\n", req.logEntry.Output, "Date should be mangled to a valid format")
 
 }
 
@@ -726,7 +726,7 @@ func TestValidatedSystemArgsMatchesSystemArgumentDefinitions(t *testing.T) {
 
 	args, err := validatedSystemArgs(req)
 
-	assert.Nil(t, err)
+	require.NoError(t, err)
 	assert.Len(t, args, len(systemArgumentDefinitions))
 	for _, arg := range systemArgumentDefinitions {
 		assert.Contains(t, args, arg.Name)
@@ -745,7 +745,7 @@ func TestBuildShellAfterArgsOnlyAddsExpectedNonSystemArgs(t *testing.T) {
 
 	args, err := buildShellAfterArgs(req)
 
-	assert.Nil(t, err)
+	require.NoError(t, err)
 	assert.Len(t, args, len(systemArgumentDefinitions)+2)
 	assert.Contains(t, args, "output")
 	assert.Contains(t, args, "exitCode")
@@ -816,7 +816,7 @@ func TestBuildShellAfterArgsUsesValidatedSystemArgs(t *testing.T) {
 
 	args, err := buildShellAfterArgs(req)
 
-	assert.Nil(t, err)
+	require.NoError(t, err)
 	assert.Equal(t, "alice@example.com", args["ot_username"])
 	assert.Equal(t, "server-track-456", args["ot_executionTrackingId"])
 	assert.Equal(t, "hello", args["output"])
@@ -836,7 +836,7 @@ func TestBuildShellAfterArgsFailsWhenSystemArgIsInvalid(t *testing.T) {
 	args, err := buildShellAfterArgs(req)
 
 	assert.Nil(t, args)
-	assert.NotNil(t, err)
+	require.Error(t, err)
 	assert.Contains(t, err.Error(), `system argument "ot_username" failed validation`)
 }
 
@@ -879,7 +879,7 @@ func TestTriggerExecutesTriggeredAction(t *testing.T) {
 	wg.Wait()
 
 	var got []string
-	for i := 0; i < 2; i++ {
+	for i := range 2 {
 		select {
 		case title := <-finishedTitles:
 			got = append(got, title)
@@ -995,15 +995,15 @@ func TestStepSaveLogSanitizesSlashInTitle(t *testing.T) {
 	assert.FileExists(t, outputPath)
 
 	resultsEntries, err := os.ReadDir(resultsDir)
-	assert.NoError(t, err)
+	require.NoError(t, err)
 	assert.Len(t, resultsEntries, 1, "results file must be flat under resultsDirectory, not a subdirectory")
 
 	data, err := os.ReadFile(resultsPath)
-	assert.NoError(t, err)
+	require.NoError(t, err)
 	assert.Contains(t, string(data), title, "YAML content keeps the original action title")
 
 	output, err := os.ReadFile(outputPath)
-	assert.NoError(t, err)
+	require.NoError(t, err)
 	assert.Equal(t, "report ok", string(output))
 }
 
@@ -1071,7 +1071,7 @@ func TestStepSaveLogSanitizesNULInTitle(t *testing.T) {
 	assert.NotContains(t, outputPath, "\x00")
 
 	output, err := os.ReadFile(outputPath)
-	assert.NoError(t, err)
+	require.NoError(t, err)
 	assert.Equal(t, "nul ok", string(output))
 }
 
@@ -1232,3 +1232,11 @@ func TestLogEntryOutputAvailableWhileRunning(t *testing.T) {
 	assert.True(t, snapshot.ExecutionFinished)
 	assert.Contains(t, snapshot.Output, "hello-mid-run")
 }
+
+func TestRebuildActionMapSkipsNilActions(t *testing.T) {
+	e, cfg := testingExecutor()
+	cfg.Actions = append(cfg.Actions, nil)
+
+	assert.NotPanics(t, e.RebuildActionMap)
+	assert.Len(t, e.MapActionBindings, 1)
+}

+ 1 - 1
service/internal/executor/group_concurrency_test.go

@@ -359,7 +359,7 @@ func TestGroupQueuesThirdAndBlocksWhenQueueFull(t *testing.T) {
 	trackings := []string{tracking1, tracking2}
 	waitGroups := []*sync.WaitGroup{wg1, wg2}
 
-	for idx := 0; idx < 3; idx++ {
+	for range 3 {
 		wg, tracking := e.ExecRequest(&ExecutionRequest{
 			Binding:           binding,
 			Cfg:               cfg,

+ 6 - 6
service/internal/executor/loadlogs.go

@@ -18,12 +18,12 @@ func (e *Executor) LoadLogsFromDisk() {
 		return
 	}
 
-	entries, skippedCount := e.readLogDirectory(resultsDir)
+	entries := e.readLogDirectory(resultsDir)
 	if entries == nil {
 		return
 	}
 
-	loadedLogs, skippedCount := e.parseLogFiles(resultsDir, entries, skippedCount)
+	loadedLogs, skippedCount := e.parseLogFiles(resultsDir, entries, 0)
 
 	sort.Slice(loadedLogs, func(i, j int) bool {
 		return loadedLogs[i].DatetimeStarted.Before(loadedLogs[j].DatetimeStarted)
@@ -37,12 +37,12 @@ func (e *Executor) LoadLogsFromDisk() {
 	}).Info("Finished loading persisted logs from disk")
 }
 
-func (e *Executor) readLogDirectory(resultsDir string) ([]os.DirEntry, int) {
+func (e *Executor) readLogDirectory(resultsDir string) []os.DirEntry {
 	if _, err := os.Stat(resultsDir); os.IsNotExist(err) {
 		log.WithFields(log.Fields{
 			"directory": resultsDir,
 		}).Debug("Logs directory does not exist, skipping log loading")
-		return nil, 0
+		return nil
 	}
 
 	log.WithFields(log.Fields{
@@ -55,10 +55,10 @@ func (e *Executor) readLogDirectory(resultsDir string) ([]os.DirEntry, int) {
 			"directory": resultsDir,
 			"error":     err,
 		}).Warnf("Failed to read logs directory")
-		return nil, 0
+		return nil
 	}
 
-	return entries, 0
+	return entries
 }
 
 func (e *Executor) parseLogFiles(resultsDir string, entries []os.DirEntry, skippedCount int) ([]*InternalLogEntry, int) {

+ 5 - 10
service/internal/logfilter/filter.go

@@ -38,12 +38,7 @@ func Compile(expression string) (*vm.Program, error) {
 		return nil, fmt.Errorf("filter expression exceeds maximum length of %d characters", maxFilterLength)
 	}
 
-	normalized, err := normalizeExpression(trimmed)
-	if err != nil {
-		return nil, err
-	}
-
-	return compileNormalized(normalized)
+	return compileNormalized(normalizeExpression(trimmed))
 }
 
 func compileNormalized(normalized string) (*vm.Program, error) {
@@ -114,19 +109,19 @@ func Matches(program *vm.Program, record Record) (bool, error) {
 	return matched, nil
 }
 
-func normalizeExpression(expression string) (string, error) {
+func normalizeExpression(expression string) string {
 	if isNegatedSearchTerm(expression) {
 		term := quoteLiteral(strings.TrimPrefix(expression, "!"))
-		return negatedSearchExpression(term), nil
+		return negatedSearchExpression(term)
 	}
 
 	if isPositiveSearchTerm(expression) {
-		return positiveSearchExpression(quoteLiteral(expression)), nil
+		return positiveSearchExpression(quoteLiteral(expression))
 	}
 
 	normalized := replaceContainsOperators(expression)
 	normalized = replaceComparisons(normalized)
-	return replaceBooleanWords(normalized), nil
+	return replaceBooleanWords(normalized)
 }
 
 func isNegatedSearchTerm(expression string) bool {

+ 2 - 2
service/internal/tpl/templates.go

@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"regexp"
+	"slices"
 	"strings"
 	"text/template"
 
@@ -112,8 +113,7 @@ func migrateLegacyEntityProperties(rawShellCommand string) string {
 func migrateLegacyArgumentNames(rawShellCommand string) string {
 	matches := legacyArgumentRegex.FindAllStringSubmatchIndex(rawShellCommand, -1)
 
-	for i := len(matches) - 1; i >= 0; i-- {
-		match := matches[i]
+	for _, match := range slices.Backward(matches) {
 		fullMatchStart := match[0]
 		fullMatchEnd := match[1]
 		argNameStart := match[2]

+ 3 - 2
service/internal/tpl/templates_test.go

@@ -7,6 +7,7 @@ import (
 
 	"github.com/OliveTin/OliveTin/internal/entities"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestParseTemplateWithActionContext_Json(t *testing.T) {
@@ -60,7 +61,7 @@ func TestParseTemplateWithActionContext_Json(t *testing.T) {
 				assert.Error(t, err)
 				return
 			}
-			assert.NoError(t, err)
+			require.NoError(t, err)
 			if tt.checkJsonOnly {
 				assertJsonOutput(t, output, tt.expectedOutput, tt.args)
 			} else {
@@ -78,7 +79,7 @@ func assertJsonOutput(t *testing.T, output, expectedPrefix string, args map[stri
 	jsonPart = strings.TrimSpace(jsonPart)
 	var decoded map[string]string
 	err := json.Unmarshal([]byte(jsonPart), &decoded)
-	assert.NoError(t, err)
+	require.NoError(t, err)
 	for k, v := range args {
 		assert.Equal(t, v, decoded[k], "decoded JSON should contain %s=%s", k, v)
 	}

+ 7 - 7
service/internal/webhooks/github.go

@@ -53,16 +53,16 @@ func setExtractIfMissing(cfg *config.WebhookConfig, key, value string) {
 	}
 }
 
-func setMatchHeaderIfMissing(cfg *config.WebhookConfig, key, value string) {
-	if _, exists := cfg.MatchHeaders[key]; !exists {
-		cfg.MatchHeaders[key] = value
+func setGitHubEventHeaderIfMissing(cfg *config.WebhookConfig, event string) {
+	if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists {
+		cfg.MatchHeaders["X-GitHub-Event"] = event
 	}
 }
 
 func applyGitHubPushTemplate(cfg *config.WebhookConfig) {
 	setDefaultAuth(cfg)
 	ensureMatchHeaders(cfg)
-	setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "push")
+	setGitHubEventHeaderIfMissing(cfg, "push")
 	ensureExtract(cfg)
 	setExtractIfMissing(cfg, "git_repository", "$.repository.full_name")
 	setExtractIfMissing(cfg, "git_ref", "$.ref")
@@ -75,7 +75,7 @@ func applyGitHubPushTemplate(cfg *config.WebhookConfig) {
 func applyGitHubPRTemplate(cfg *config.WebhookConfig) {
 	setDefaultAuth(cfg)
 	ensureMatchHeaders(cfg)
-	setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "pull_request")
+	setGitHubEventHeaderIfMissing(cfg, "pull_request")
 	ensureExtract(cfg)
 	setExtractIfMissing(cfg, "pr_number", "$.number")
 	setExtractIfMissing(cfg, "pr_title", "$.pull_request.title")
@@ -89,7 +89,7 @@ func applyGitHubPRTemplate(cfg *config.WebhookConfig) {
 func applyGitHubReleaseTemplate(cfg *config.WebhookConfig) {
 	setDefaultAuth(cfg)
 	ensureMatchHeaders(cfg)
-	setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "release")
+	setGitHubEventHeaderIfMissing(cfg, "release")
 	ensureExtract(cfg)
 	setExtractIfMissing(cfg, "release_action", "$.action")
 	setExtractIfMissing(cfg, "release_tag", "$.release.tag_name")
@@ -101,7 +101,7 @@ func applyGitHubReleaseTemplate(cfg *config.WebhookConfig) {
 func applyGitHubWorkflowTemplate(cfg *config.WebhookConfig) {
 	setDefaultAuth(cfg)
 	ensureMatchHeaders(cfg)
-	setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "workflow_run")
+	setGitHubEventHeaderIfMissing(cfg, "workflow_run")
 	ensureExtract(cfg)
 	setExtractIfMissing(cfg, "workflow_name", "$.workflow_run.name")
 	setExtractIfMissing(cfg, "workflow_status", "$.workflow_run.status")

+ 106 - 27
service/main.go

@@ -2,6 +2,7 @@ package main
 
 import (
 	"flag"
+	"fmt"
 	"path/filepath"
 	"strings"
 
@@ -33,15 +34,30 @@ import (
 
 var (
 	cfg     *config.Config
+	cli     cliOptions
 	version = "dev"
 	commit  = "nocommit"
 	date    = "nodate"
+
+	syntaxCheckSearchResults []string
+	syntaxCheckLoadError     error
 )
 
+type cliOptions struct {
+	configDir    string
+	syntaxCheck  bool
+	startupTrace bool
+	printVersion bool
+}
+
 func init() {
+	// Parse flags before initLog so -startuptrace can enable TRACE before config loads.
+	cli = parseCliFlags()
 	initLog()
+	handleEarlyCliFlags()
 
-	initConfig(initCliFlags())
+	initConfig(cli.configDir, !cli.syntaxCheck)
+	quietSyntaxCheckLogs()
 
 	initCheckEnvironment()
 
@@ -62,32 +78,64 @@ func initLog() {
 		})
 	}
 
-	// Use debug this early on to catch details about startup errors. The
-	// default config will raise the log level later, if not set.
-	log.SetLevel(log.DebugLevel) // Default to debug, to catch cfg issue
-}
+	if cli.startupTrace {
+		log.SetLevel(log.TraceLevel)
+		return
+	}
 
-func initCliFlags() string {
-	var configDir string
-	flag.StringVar(&configDir, "configdir", ".", "Config directory path")
+	log.SetLevel(log.InfoLevel)
+}
 
-	var printVersion bool
-	flag.BoolVar(&printVersion, "version", false, "Prints the version number and exits")
+func parseCliFlags() cliOptions {
+	options := cliOptions{}
+	flag.StringVar(&options.configDir, "configdir", ".", "Config directory path")
+	flag.BoolVar(&options.printVersion, "version", false, "Prints the version number and exits")
+	flag.BoolVar(&options.syntaxCheck, "syntaxcheck", false, "Check configuration for issues and exit")
+	flag.BoolVar(&options.startupTrace, "startuptrace", false, "Enable TRACE logging before configuration is loaded")
 	flag.Parse()
+	return options
+}
 
+func handleEarlyCliFlags() {
 	// This log message should be the first log message OliveTin prints.
-	if printVersion {
+	if cli.printVersion {
 		logStartupMessage("OliveTin is just printing the startup message")
 		os.Exit(1)
-	} else {
-		logStartupMessage("OliveTin initializing")
 	}
 
+	if cli.syntaxCheck {
+		// Suppress routine logs before any further startup output. Re-applied
+		// after config load because sanitize may raise the level again.
+		log.SetLevel(log.FatalLevel)
+		return
+	}
+
+	logStartupMessage("OliveTin initializing")
+
 	log.WithFields(log.Fields{
-		"value": configDir,
+		"value": cli.configDir,
 	}).Debugf("Value of -configdir flag")
+}
+
+// quietSyntaxCheckLogs keeps -syntaxcheck output limited to the report on
+// stdout. Load failures are returned to the syntax-check report instead of
+// exiting immediately via Fatalf.
+func quietSyntaxCheckLogs() {
+	if cli.syntaxCheck {
+		log.SetLevel(log.FatalLevel)
+	}
+}
+
+func failConfigLoad(format string, args ...any) {
+	err := fmt.Errorf(format, args...)
+	if cli.syntaxCheck {
+		if syntaxCheckLoadError == nil {
+			syntaxCheckLoadError = err
+		}
+		return
+	}
 
-	return configDir
+	log.Fatal(err)
 }
 
 func getBasePort() int {
@@ -178,7 +226,7 @@ func watchConfigFile(k *koanf.Koanf, f *file.File, configPath string) {
 	}
 }
 
-func loadAndWatchConfig(k *koanf.Koanf, configPath string) {
+func loadConfigFromPath(k *koanf.Koanf, configPath string, watch bool) bool {
 	log.WithFields(log.Fields{
 		"configPath": configPath,
 	}).Info("Loading config from path")
@@ -186,40 +234,66 @@ func loadAndWatchConfig(k *koanf.Koanf, configPath string) {
 	f := file.Provider(configPath)
 
 	if err := k.Load(f, yaml.Parser()); err != nil {
-		log.Fatalf("error loading config from %s: %v", configPath, err)
+		failConfigLoad("error loading config from %s: %v", configPath, err)
+		return false
+	}
+
+	if watch {
+		watchConfigFile(k, f, configPath)
 	}
 
-	watchConfigFile(k, f, configPath)
+	return true
 }
 
-func findAndLoadBaseConfig(k *koanf.Koanf, directories []string) string {
+func findAndLoadBaseConfig(k *koanf.Koanf, directories []string, watch bool) string {
 	for _, directory := range directories {
 		configPath := getConfigPath(directory)
-		if !configPathExists(configPath) {
+		found := configPathExists(configPath)
+		printConfigSearchResult(configPath, found)
+
+		if !found {
 			continue
 		}
 
-		loadAndWatchConfig(k, configPath)
+		if !loadConfigFromPath(k, configPath, watch) {
+			return ""
+		}
+
 		return configPath
 	}
 
 	return ""
 }
 
-func initConfig(configDir string) {
+func printConfigSearchResult(configPath string, found bool) {
+	if !cli.syntaxCheck {
+		return
+	}
+
+	if found {
+		syntaxCheckSearchResults = append(syntaxCheckSearchResults, "Found config file: "+configPath)
+		return
+	}
+
+	syntaxCheckSearchResults = append(syntaxCheckSearchResults, "Config file not found: "+configPath)
+}
+
+func initConfig(configDir string, watch bool) {
 	k := koanf.New(".")
 	err := k.Load(env.Provider(".", ".", nil), nil)
 	if err != nil {
-		log.WithFields(log.Fields{
-			"error": err,
-		}).Fatalf("Error loading environment variables")
+		failConfigLoad("Error loading environment variables: %v", err)
+		return
 	}
 
-	baseConfigPath := findAndLoadBaseConfig(k, configSearchDirectories(configDir))
+	baseConfigPath := findAndLoadBaseConfig(k, configSearchDirectories(configDir), watch)
 	cfg = config.DefaultConfigWithBasePort(getBasePort())
 
 	if baseConfigPath == "" {
-		log.Fatalf("No base config file found")
+		if syntaxCheckLoadError == nil {
+			failConfigLoad("No base config file found")
+		}
+		return
 	}
 
 	config.AppendSource(cfg, k, baseConfigPath)
@@ -251,6 +325,11 @@ func warnIfPuidGuid() {
 }
 
 func main() {
+	if cli.syntaxCheck {
+		runSyntaxCheck()
+		return
+	}
+
 	servicehost.Start(cfg.ServiceHostMode, cfg.ServiceLogs.Directory)
 
 	log.WithFields(log.Fields{

+ 82 - 0
service/syntaxcheck.go

@@ -0,0 +1,82 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/configissues"
+	"github.com/OliveTin/OliveTin/internal/entities"
+	"github.com/OliveTin/OliveTin/internal/executor"
+)
+
+func runSyntaxCheck() {
+	printSyntaxCheckSearchResults()
+	printLoadedConfigFiles()
+
+	if syntaxCheckLoadError != nil {
+		fmt.Printf("Configuration load failed: %v\n", syntaxCheckLoadError)
+		fmt.Println("0 configuration issues found")
+		os.Exit(1)
+	}
+
+	entities.SyncEntityFileWatchers(cfg)
+	executor.DefaultExecutor(cfg).RebuildActionMap()
+	printConfigIssues(configissues.List())
+
+	issueCount := configissues.Count()
+	fmt.Printf("%d configuration issues found\n", issueCount)
+
+	if issueCount > 0 {
+		os.Exit(1)
+	}
+
+	os.Exit(0)
+}
+
+func printSyntaxCheckSearchResults() {
+	for _, line := range syntaxCheckSearchResults {
+		fmt.Println(line)
+	}
+}
+
+func printLoadedConfigFiles() {
+	files := config.LoadedSources()
+	if len(files) == 0 {
+		fmt.Println("No config files were read")
+		return
+	}
+
+	fmt.Println("Config files read:")
+	for _, path := range files {
+		fmt.Printf("  %s\n", path)
+	}
+}
+
+func printConfigIssues(issues []configissues.Issue) {
+	if len(issues) == 0 {
+		return
+	}
+
+	fmt.Println("Configuration issues:")
+	for _, issue := range issues {
+		fmt.Printf("  %s\n", formatConfigIssue(issue))
+	}
+}
+
+func formatConfigIssue(issue configissues.Issue) string {
+	parts := []string{issue.Severity, issue.Code}
+
+	if issue.ConfigFile != "" {
+		parts = append(parts, "file="+issue.ConfigFile)
+	}
+	if issue.ActionTitle != "" {
+		parts = append(parts, "action="+issue.ActionTitle)
+	}
+	if issue.ArgumentName != "" {
+		parts = append(parts, "argument="+issue.ArgumentName)
+	}
+
+	return strings.Join(parts, " ") + ": " + issue.Message
+}

+ 11 - 1
specs/config-issues.md

@@ -8,7 +8,7 @@ Operators should see configuration problems in Diagnostics instead of only in se
 
 ## When issues are rebuilt
 
-The issue list is cleared and rebuilt when configuration is loaded or reloaded, and when entity data changes. Some findings that can only be detected while configuration is first being loaded (for example references to unset environment variables that are expanded away during that load) are kept across later rebuilds until the next configuration load begins.
+The issue list is cleared and rebuilt when configuration is loaded or reloaded, and when entity data changes. Some findings that can only be detected while configuration is first being loaded (for example references to unset environment variables that are expanded away during that load, or included files that failed to parse) are kept across later rebuilds until the next configuration load begins.
 
 ## What is collected
 
@@ -19,6 +19,8 @@ Issues include:
 - Arguments whose type was left unset (defaulted to a generic text type)
 - Unset environment variables referenced from configuration
 - Missing or invalid include directories
+- Included configuration files that cannot be read or parsed (the file is skipped and the rest of the configuration still loads)
+- Configuration that parses as YAML but cannot be decoded into OliveTin's settings (for example a value of the wrong type)
 - Argument default or choice templates that fail to parse
 - Literal argument defaults that fail type validation (templated defaults are not type-checked as raw text)
 - Entity files that cannot be read or parsed, or are empty
@@ -48,3 +50,11 @@ When Diagnostics is visible and at least one configuration issue exists that the
 ## Startup count
 
 When the web UI starts, users who may view Diagnostics receive the same filtered configuration issue count used for the Diagnostics list and navigation badge. For other users the count is zero.
+
+## Command-line syntax check
+
+Operators can validate configuration without starting the web server by running OliveTin with the `-syntaxcheck` flag (optionally with `-configdir`). OliveTin searches for and reads configuration files as usual, prints each candidate path that was found or not found, prints the list of configuration files that were successfully read (including include-directory files), prints each configuration issue, and always prints a final count of issues found (including zero). Routine server logs are suppressed so the report stays readable.
+
+If configuration cannot be loaded (for example no base config file is found, or a found file cannot be parsed), OliveTin still prints the search results and loaded-file list, prints a configuration load failure message, prints `0 configuration issues found`, and exits with a non-zero status.
+
+The process exits with status `0` when configuration loaded successfully and no configuration issues were found, and status `1` when one or more issues were found or configuration failed to load.

+ 2 - 0
var/marketing/screenshots/.gitignore

@@ -0,0 +1,2 @@
+tmp/
+__pycache__/

+ 48 - 0
var/marketing/screenshots/Makefile

@@ -0,0 +1,48 @@
+# Regenerates marketing screenshots against a throwaway OliveTin that runs
+# the fictional config in seed/. Run `make` here (or `make screenshots` from
+# the repo root); OliveTin is always stopped afterwards.
+#
+# OliveTin keeps all of its state in config files and memory, so there are
+# no database or broker containers to start.
+
+ROOT := $(abspath ../../..)
+HERE := $(abspath .)
+TMP := $(HERE)/tmp
+
+BASE_URL := http://127.0.0.1:18337
+API := $(BASE_URL)/api/olivetin.api.v1.OliveTinApiService
+
+.PHONY: all build up seed shoot manifest down clean
+
+all:
+	@$(MAKE) build up seed shoot manifest; rc=$$?; $(MAKE) down; exit $$rc
+
+build:
+	$(MAKE) -C $(ROOT) service webui-dist
+
+up: down
+	@if curl -fsS --max-time 5 $(BASE_URL)/ >/dev/null 2>&1; then \
+		echo "Port 18337 is already in use"; exit 1; fi
+	rm -rf $(TMP)
+	mkdir -p $(TMP)
+	cp -r seed $(TMP)/config
+	# Started from service/ so that OliveTin finds ../webui.
+	cd $(ROOT)/service && { ./OliveTin -configdir $(TMP)/config > $(TMP)/service.log 2>&1 & echo $$! > $(TMP)/service.pid; }
+	@for i in $$(seq 60); do curl -fsS --max-time 5 -X POST -H 'Content-Type: application/json' -d '{}' \
+		$(API)/Init >/dev/null 2>&1 && exit 0; sleep 0.5; done; \
+		echo "OliveTin did not start; see $(TMP)/service.log"; exit 1
+
+seed:
+	./seed.sh $(BASE_URL)
+
+shoot:
+	repo-helper screenshot --config screenshots.ini
+
+manifest:
+	./generate-manifest.py
+
+down:
+	-@[ -f $(TMP)/service.pid ] && kill $$(cat $(TMP)/service.pid) 2>/dev/null; rm -f $(TMP)/service.pid
+
+clean: down
+	rm -rf $(TMP)

+ 41 - 0
var/marketing/screenshots/README.md

@@ -0,0 +1,41 @@
+# Marketing screenshots
+
+The screenshots used in the project README, captured from a throwaway
+OliveTin running the fictional config in [`seed/`](seed/).
+
+```bash
+make screenshots                      # from the repo root
+make -C var/marketing/screenshots     # or from anywhere
+```
+
+This builds OliveTin and the webui, starts OliveTin on `127.0.0.1:18337`
+with a copy of `seed/` in `tmp/`, runs a few actions through the API
+(`seed.sh`) so the logs have history, captures every section of
+`screenshots.ini` with `repo-helper screenshot`, regenerates
+`screenshot-manifest.yaml`, and then stops OliveTin, even if a step fails.
+
+## Requirements
+
+- Go and Node.js, to build OliveTin and the webui
+- [`repo-helper`](https://github.com/jamesread/repo-common), with Chrome and a
+  matching `chromedriver`
+- `curl` and `jq`
+
+No containers are needed: OliveTin keeps its state in config files and memory.
+
+## Files
+
+| File | Purpose |
+|------|---------|
+| `seed/config.yaml`, `seed/entities/` | Fictional config. Commands print static, made-up output, so images never show details of the machine running `make`. |
+| `seed.sh` | Runs actions with fixed execution tracking ids, so the logs and execution URLs are the same on every run. |
+| `screenshots.ini` | One section per image, plus the `title`/`description`/`theme` used for the manifest. |
+| `scripts/` | Selenium setup scripts. `light.py`/`dark.py` pin `prefers-color-scheme`, because repo-helper shares one browser session across sections. |
+| `generate-manifest.py` | Writes `screenshot-manifest.yaml`. |
+| `screenshot-manifest.yaml` | Generated index of the screenshots for indexers. Do not edit by hand. |
+
+To add or change a screenshot, edit `screenshots.ini` and run `make`. If only
+a title or description changed, `make manifest` is enough.
+
+To browse the demo instance yourself, run `make build up seed`, open
+<http://127.0.0.1:18337/>, and run `make down` when finished.

BIN
var/marketing/screenshots/argument-form.png


BIN
var/marketing/screenshots/dashboard-dark.png


BIN
var/marketing/screenshots/dashboard-mobile.png


BIN
var/marketing/screenshots/dashboard.png


BIN
var/marketing/screenshots/entities.png


BIN
var/marketing/screenshots/execution.png


+ 74 - 0
var/marketing/screenshots/generate-manifest.py

@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+"""Writes screenshot-manifest.yaml from screenshots.ini for indexers.
+
+Each INI section with url= is one screenshot; it must have title= and
+description=, and its PNG must exist. Run via `make manifest`.
+"""
+import configparser
+import json
+import struct
+import sys
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+INI = HERE / "screenshots.ini"
+OUT = HERE / "screenshot-manifest.yaml"
+
+
+def png_size(path):
+    with open(path, "rb") as f:
+        header = f.read(24)
+    if header[:8] != b"\x89PNG\r\n\x1a\n":
+        raise ValueError("%s is not a PNG" % path)
+    return struct.unpack(">II", header[16:24])
+
+
+def q(value):
+    # JSON strings are valid YAML scalars and handle all quoting/escaping.
+    return json.dumps(value, ensure_ascii=False)
+
+
+def main():
+    cp = configparser.ConfigParser(interpolation=None)
+    cp.read(INI)
+    defaults = cp.defaults()
+    base_url = defaults["manifest_base_url"].rstrip("/") + "/"
+    errors = []
+    lines = [
+        "# Generated by `make manifest` from screenshots.ini. Do not edit by hand.",
+        "version: 1",
+        "project: %s" % q(defaults["manifest_project"]),
+        "screenshots:",
+    ]
+    for section in cp.sections():
+        if not cp.get(section, "url", fallback="").strip():
+            continue
+        name = cp.get(section, "name", fallback=section).strip() or section
+        file = "%s.png" % name
+        title = cp.get(section, "title", fallback="").strip()
+        description = cp.get(section, "description", fallback="").strip()
+        if not title or not description:
+            errors.append("[%s] needs title= and description=" % section)
+            continue
+        if not (HERE / file).is_file():
+            errors.append("[%s] %s is missing; run `make` first" % (section, file))
+            continue
+        width, height = png_size(HERE / file)
+        lines += [
+            "  - name: %s" % q(name),
+            "    title: %s" % q(title),
+            "    description: %s" % q(description),
+            "    url: %s" % q(base_url + file),
+            "    file: %s" % q(file),
+            "    width: %d" % width,
+            "    height: %d" % height,
+            "    theme: %s" % q(cp.get(section, "theme", fallback="light").strip()),
+        ]
+    if errors:
+        sys.exit("\n".join(errors))
+    OUT.write_text("\n".join(lines) + "\n")
+    print("Wrote %s" % OUT.name)
+
+
+if __name__ == "__main__":
+    main()

BIN
var/marketing/screenshots/logs.png


+ 60 - 0
var/marketing/screenshots/screenshot-manifest.yaml

@@ -0,0 +1,60 @@
+# Generated by `make manifest` from screenshots.ini. Do not edit by hand.
+version: 1
+project: "OliveTin"
+screenshots:
+  - name: "dashboard"
+    title: "Action buttons"
+    description: "Your predefined shell commands appear as simple, touch-friendly buttons, so anyone can run them safely without a terminal."
+    url: "https://raw.githubusercontent.com/OliveTin/OliveTin/main/var/marketing/screenshots/dashboard.png"
+    file: "dashboard.png"
+    width: 1280
+    height: 800
+    theme: "light"
+  - name: "argument-form"
+    title: "Arguments"
+    description: "Actions can ask for input before they run, with typed and validated fields, defaults and dropdowns, so commands only ever run with sensible values."
+    url: "https://raw.githubusercontent.com/OliveTin/OliveTin/main/var/marketing/screenshots/argument-form.png"
+    file: "argument-form.png"
+    width: 1280
+    height: 800
+    theme: "light"
+  - name: "execution"
+    title: "Command output"
+    description: "Watch a command's output live as it runs, along with whether it succeeded and how long it took."
+    url: "https://raw.githubusercontent.com/OliveTin/OliveTin/main/var/marketing/screenshots/execution.png"
+    file: "execution.png"
+    width: 1280
+    height: 800
+    theme: "light"
+  - name: "entities"
+    title: "Dashboards and entities"
+    description: "Build dashboards that group actions by the things you manage, such as servers or containers, with one set of buttons generated for each of them."
+    url: "https://raw.githubusercontent.com/OliveTin/OliveTin/main/var/marketing/screenshots/entities.png"
+    file: "entities.png"
+    width: 1280
+    height: 800
+    theme: "light"
+  - name: "logs"
+    title: "Execution logs"
+    description: "Every action that runs is logged, so you can see who ran what, when, and whether it succeeded."
+    url: "https://raw.githubusercontent.com/OliveTin/OliveTin/main/var/marketing/screenshots/logs.png"
+    file: "logs.png"
+    width: 1280
+    height: 800
+    theme: "light"
+  - name: "dashboard-dark"
+    title: "Dark mode"
+    description: "OliveTin follows your browser's dark mode preference automatically."
+    url: "https://raw.githubusercontent.com/OliveTin/OliveTin/main/var/marketing/screenshots/dashboard-dark.png"
+    file: "dashboard-dark.png"
+    width: 1280
+    height: 800
+    theme: "dark"
+  - name: "dashboard-mobile"
+    title: "Mobile layout"
+    description: "The responsive layout works well on phones and on wall-mounted tablets, so commands are a tap away wherever you are."
+    url: "https://raw.githubusercontent.com/OliveTin/OliveTin/main/var/marketing/screenshots/dashboard-mobile.png"
+    file: "dashboard-mobile.png"
+    width: 412
+    height: 915
+    theme: "light"

+ 58 - 0
var/marketing/screenshots/screenshots.ini

@@ -0,0 +1,58 @@
+# Marketing screenshots, captured by `make` in this directory against the
+# throwaway OliveTin it starts with the fictional config in seed/.
+#
+# title/description/theme are not used by repo-helper; `make manifest` turns
+# them into screenshot-manifest.yaml for indexers. Every section needs them.
+
+[DEFAULT]
+base_url = http://127.0.0.1:18337/
+dir = .
+width = 1280
+height = 800
+script = scripts/light.py, scripts/prepare.py
+post_script_sleep = 1.5
+theme = light
+manifest_project = OliveTin
+manifest_base_url = https://raw.githubusercontent.com/OliveTin/OliveTin/main/var/marketing/screenshots/
+
+[dashboard]
+url = .
+title = Action buttons
+description = Your predefined shell commands appear as simple, touch-friendly buttons, so anyone can run them safely without a terminal.
+
+[argument-form]
+url = .
+script = scripts/light.py, scripts/prepare.py, scripts/argument_form.py
+title = Arguments
+description = Actions can ask for input before they run, with typed and validated fields, defaults and dropdowns, so commands only ever run with sensible values.
+
+[execution]
+url = logs/5eed0000-0000-4000-8000-000000000007
+title = Command output
+description = Watch a command's output live as it runs, along with whether it succeeded and how long it took.
+
+[entities]
+url = dashboards/Servers
+title = Dashboards and entities
+description = Build dashboards that group actions by the things you manage, such as servers or containers, with one set of buttons generated for each of them.
+
+[logs]
+url = logs
+title = Execution logs
+description = Every action that runs is logged, so you can see who ran what, when, and whether it succeeded.
+
+# Dark and mobile variants last; the browser session is shared, so each
+# section pins its own color scheme.
+[dashboard-dark]
+url = .
+script = scripts/dark.py, scripts/prepare.py
+theme = dark
+title = Dark mode
+description = OliveTin follows your browser's dark mode preference automatically.
+
+[dashboard-mobile]
+url = .
+width = 412
+height = 915
+title = Mobile layout
+description = The responsive layout works well on phones and on wall-mounted tablets, so commands are a tap away wherever you are.

+ 10 - 0
var/marketing/screenshots/scripts/argument_form.py

@@ -0,0 +1,10 @@
+"""Opens the argument form for the "Ping host" action."""
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+
+def run(driver):
+    driver.find_element(By.CSS_SELECTOR, '[title="Ping host"]').click()
+    WebDriverWait(driver, 15).until(
+        lambda d: d.find_element(By.TAG_NAME, "body").get_attribute("loaded-argument-form")
+    )

+ 10 - 0
var/marketing/screenshots/scripts/dark.py

@@ -0,0 +1,10 @@
+"""Forces the dark color scheme before capture."""
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(__file__))
+from theme import set_color_scheme  # noqa: E402
+
+
+def run(driver):
+    set_color_scheme(driver, "dark")

+ 10 - 0
var/marketing/screenshots/scripts/light.py

@@ -0,0 +1,10 @@
+"""Forces the light color scheme before capture."""
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(__file__))
+from theme import set_color_scheme  # noqa: E402
+
+
+def run(driver):
+    set_color_scheme(driver, "light")

+ 19 - 0
var/marketing/screenshots/scripts/prepare.py

@@ -0,0 +1,19 @@
+"""Waits for the SPA to render before capture."""
+import time
+
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+
+def _rendered(driver):
+    if driver.find_element(By.TAG_NAME, "body").get_attribute("loaded-dashboard"):
+        return True
+    if driver.find_elements(By.CSS_SELECTOR, "main table tbody tr"):
+        return True
+    return any(rows.text.strip() for rows in driver.find_elements(By.CSS_SELECTOR, ".xterm-rows"))
+
+
+def run(driver):
+    WebDriverWait(driver, 30).until(_rendered)
+    time.sleep(1)
+    driver.execute_script("window.scrollTo(0, 0);")

+ 12 - 0
var/marketing/screenshots/scripts/theme.py

@@ -0,0 +1,12 @@
+"""Pins prefers-color-scheme; OliveTin's dark mode follows the browser.
+
+repo-helper reuses one browser session for every section, so each section
+must set the scheme explicitly or it inherits the previous one.
+"""
+
+
+def set_color_scheme(driver, scheme):
+    driver.execute_cdp_cmd(
+        "Emulation.setEmulatedMedia",
+        {"features": [{"name": "prefers-color-scheme", "value": scheme}]},
+    )

+ 36 - 0
var/marketing/screenshots/seed.sh

@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+# Runs a few fictional actions through OliveTin's public API so that the
+# logs and execution pages have history to show. Fixed tracking ids keep
+# the logs table and execution URLs the same on every run.
+# Usage: seed.sh <base-url>
+set -euo pipefail
+
+base="${1:?base url}"
+api="$base/api/olivetin.api.v1.OliveTinApiService"
+
+rpc() {
+	curl -fsS --max-time 10 -X POST -H 'Content-Type: application/json' -d "$2" "$api/$1"
+}
+
+run() {
+	local tracking_id="$1" binding_id="$2" arguments="${3:-[]}"
+
+	rpc StartAction "{\"bindingId\": \"$binding_id\", \"uniqueTrackingId\": \"$tracking_id\", \"arguments\": $arguments}" >/dev/null
+
+	for _ in $(seq 50); do
+		rpc ExecutionStatus "{\"executionTrackingId\": \"$tracking_id\"}" |
+			jq -e '.logEntry.executionFinished' >/dev/null && return 0
+		sleep 0.1
+	done
+
+	echo "Action $binding_id did not finish" >&2
+	return 1
+}
+
+run 5eed0000-0000-4000-8000-000000000001 run_playbook
+run 5eed0000-0000-4000-8000-000000000002 nightly_backup
+run 5eed0000-0000-4000-8000-000000000003 restart_container '[{"name": "container", "value": "demo-web"}]'
+run 5eed0000-0000-4000-8000-000000000004 restart_media
+run 5eed0000-0000-4000-8000-000000000005 porch_light
+run 5eed0000-0000-4000-8000-000000000006 ping_host '[{"name": "host", "value": "nas.example.com"}, {"name": "count", "value": "4"}]'
+run 5eed0000-0000-4000-8000-000000000007 check_disk

+ 204 - 0
var/marketing/screenshots/seed/config.yaml

@@ -0,0 +1,204 @@
+# Fictional OliveTin config used only for marketing screenshots.
+#
+# Every command prints static, made-up output so that captures are
+# repeatable and never show details of the machine running `make`.
+# Hostnames use example.com and the 192.0.2.0/24 documentation range.
+
+listenAddressSingleHTTPFrontend: 127.0.0.1:18337
+logLevel: "WARN"
+checkForUpdates: false
+pageTitle: OliveTin
+showFooter: false
+
+actions:
+  - title: Restart media server
+    id: restart_media
+    icon: restart
+    shell: |
+      echo "Stopping demo-media..."
+      sleep 1
+      echo "Starting demo-media..."
+      echo "demo-media is running."
+
+  - title: Check disk space
+    id: check_disk
+    icon: disk
+    onclick: execution-dialog
+    shell: |
+      printf '%s\n' \
+        'Filesystem      Size  Used Avail Use% Mounted on' \
+        '/dev/sda2       457G  182G  252G  42% /' \
+        '/dev/sda1       974M  211M  696M  24% /boot' \
+        '/dev/sdb1       3.6T  2.1T  1.4T  61% /srv/media' \
+        '/dev/sdc1       1.8T  640G  1.1T  37% /srv/backups'
+
+  - title: Ping host
+    id: ping_host
+    icon: ping
+    shell: |
+      echo "PING {{ host }} (192.0.2.10) 56(84) bytes of data."
+      for i in $(seq {{ count }}); do
+        echo "64 bytes from {{ host }} (192.0.2.10): icmp_seq=$i ttl=64 time=0.42 ms"
+      done
+      echo ""
+      echo "--- {{ host }} ping statistics ---"
+      echo "{{ count }} packets transmitted, {{ count }} received, 0% packet loss"
+    arguments:
+      - name: host
+        title: Host
+        type: ascii_identifier
+        default: nas.example.com
+        description: The host that you want to ping.
+      - name: count
+        title: Count
+        type: int
+        default: 3
+        description: How many pings to send.
+
+  - title: Restart container
+    id: restart_container
+    icon: box
+    shell: 'echo "Restarting container {{ container }}..." && echo "{{ container }} restarted."'
+    arguments:
+      - name: container
+        title: Container
+        description: Pick the container to restart.
+        choices:
+          - title: Web server (demo-web)
+            value: demo-web
+          - title: Database (demo-db)
+            value: demo-db
+          - title: Cache (demo-cache)
+            value: demo-cache
+
+  - title: Run nightly backup
+    id: nightly_backup
+    icon: backup
+    groups: [ backup-jobs ]
+    shell: |
+      echo "Backing up /srv/media to backup.example.com..."
+      echo "Transferred 1,284 files (3.2 GB)."
+      echo "Backup complete."
+
+  - title: Backup selected folders
+    id: backup_folders
+    icon: backup
+    groups: [ backup-jobs ]
+    shell: 'echo "Backing up: {{ folders }}"'
+    arguments:
+      - name: folders
+        title: Folders to back up
+        type: checklist
+        description: Select one or more folders to include in the backup.
+        choices:
+          - title: Documents
+            value: documents
+          - title: Photos
+            value: photos
+          - title: Music
+            value: music
+          - title: Videos
+            value: videos
+        default: '["documents","photos"]'
+
+  - title: Toggle porch light
+    id: porch_light
+    icon: light
+    shell: echo "Porch light toggled."
+
+  - title: Run automation playbook
+    id: run_playbook
+    icon: robot
+    shell: |
+      echo "PLAY [homelab] *****************************************************"
+      echo "TASK [Update packages] *********************************************"
+      echo "ok: [web.example.com]"
+      echo "ok: [nas.example.com]"
+      echo "PLAY RECAP *********************************************************"
+      echo "web.example.com : ok=4 changed=1 unreachable=0 failed=0"
+      echo "nas.example.com : ok=4 changed=0 unreachable=0 failed=0"
+
+  - title: Enable SSH for 20 minutes
+    id: enable_ssh
+    icon: ssh
+    shell: echo "SSH enabled until the timer expires."
+
+  - title: Search the logs
+    id: search_logs
+    icon: logs
+    shell: 'echo "No matches for {{ pattern }}."'
+    arguments:
+      - name: pattern
+        title: Pattern
+        type: ascii_sentence
+        default: error
+
+  - title: Delete old backups
+    id: delete_old_backups
+    icon: ashtonished
+    shell: echo "Deleted 4 backups older than 90 days."
+    arguments:
+      - type: html
+        title: Description
+        default: This removes backups older than 90 days from <strong>backup.example.com</strong>.
+      - type: confirmation
+        title: Are you sure?
+
+  - title: Change theme
+    id: change_theme
+    icon: theme
+    shell: echo "Theme changed."
+
+  - title: "Wake {{ server.name }}"
+    icon: reboot
+    entity: server
+    shell: 'echo "Sending Wake on LAN to {{ server.hostname }}"'
+
+  - title: "Update {{ server.name }}"
+    icon: robot
+    entity: server
+    shell: 'echo "Updating {{ server.hostname }}"'
+
+  - title: "Back up {{ server.name }}"
+    icon: backup
+    entity: server
+    shell: 'echo "Backing up {{ server.hostname }}"'
+
+entities:
+  - file: entities/servers.yaml
+    name: server
+    icon: ssh
+    properties:
+      - name: hostname
+        title: Hostname
+      - name: ip
+        title: IP address
+
+actionGroups:
+  backup-jobs:
+    maxConcurrent: 1
+    icon: backup
+
+dashboards:
+  - title: Servers
+    contents:
+      - type: fieldset
+        entity: server
+        title: '{{ server.name }}'
+        contents:
+          - type: display
+            title: |
+              Hostname: <strong>{{ server.hostname }}</strong><br />
+              IP address: <strong>{{ server.ip }}</strong>
+          - title: 'Wake {{ server.name }}'
+          - title: 'Update {{ server.name }}'
+          - title: 'Back up {{ server.name }}'
+
+defaultPolicy:
+  showDiagnostics: true
+  showLogList: true
+
+defaultPermissions:
+  view: true
+  exec: true
+  logs: true

+ 10 - 0
var/marketing/screenshots/seed/entities/servers.yaml

@@ -0,0 +1,10 @@
+# Fictional servers for marketing screenshots.
+- name: web
+  hostname: web.example.com
+  ip: 192.0.2.10
+- name: nas
+  hostname: nas.example.com
+  ip: 192.0.2.20
+- name: media
+  hostname: media.example.com
+  ip: 192.0.2.30

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor