James Read 1 неделя назад
Родитель
Сommit
dc64d53e26
51 измененных файлов с 3244 добавлено и 497 удалено
  1. 11 0
      SECURITY.md
  2. 1 0
      docs/modules/ROOT/nav.adoc
  3. 17 0
      docs/modules/ROOT/pages/action_customization/hidden.adoc
  4. 2 0
      docs/modules/ROOT/pages/action_customization/intro.adoc
  5. 2 1
      docs/modules/ROOT/pages/action_execution/triggers.adoc
  6. 17 0
      docs/modules/ROOT/pages/advanced_configuration/webui.adoc
  7. 2 0
      docs/modules/ROOT/pages/args/input_checklist.adoc
  8. 2 0
      docs/modules/ROOT/pages/args/input_dropdown.adoc
  9. 1 0
      docs/modules/ROOT/pages/config.adoc
  10. 4 0
      docs/modules/ROOT/pages/entities/intro.adoc
  11. 54 0
      docs/modules/ROOT/pages/security/acl.adoc
  12. 1 0
      docs/modules/ROOT/pages/security/design_choices.adoc
  13. 207 292
      frontend/package-lock.json
  14. 4 4
      frontend/package.json
  15. 105 0
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts
  16. 0 0
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js
  17. 38 3
      frontend/resources/vue/App.vue
  18. 228 0
      frontend/resources/vue/stores/searchIndex.js
  19. 221 0
      frontend/resources/vue/stores/searchIndex.test.mjs
  20. 0 1
      frontend/resources/vue/views/EntitiesView.vue
  21. 0 1
      frontend/resources/vue/views/LogsCalendarView.vue
  22. 0 1
      frontend/resources/vue/views/LogsListView.vue
  23. 2 2
      integration-tests/lib/elements.js
  24. 30 0
      proto/olivetin/api/v1/olivetin.proto
  25. 299 87
      service/gen/olivetin/api/v1/olivetin.pb.go
  26. 12 5
      service/internal/acl/acl.go
  27. 80 0
      service/internal/acl/acl_entity_test.go
  28. 46 0
      service/internal/acl/acl_hidden_test.go
  29. 115 16
      service/internal/api/api.go
  30. 46 14
      service/internal/api/apiActions.go
  31. 52 33
      service/internal/api/api_entities_list.go
  32. 214 0
      service/internal/api/api_entity_acl_test.go
  33. 175 0
      service/internal/api/api_entity_argument_acl.go
  34. 205 0
      service/internal/api/api_entity_argument_acl_test.go
  35. 5 1
      service/internal/api/api_entity_related_actions.go
  36. 273 0
      service/internal/api/api_init_search_hints_test.go
  37. 170 0
      service/internal/api/api_search_hints.go
  38. 46 1
      service/internal/api/api_test.go
  39. 5 0
      service/internal/api/dashboard_entities.go
  40. 46 24
      service/internal/api/dashboards.go
  41. 7 0
      service/internal/config/config.go
  42. 40 0
      service/internal/config/entity_argument_choices_test.go
  43. 40 0
      service/internal/config/sanitize.go
  44. 96 9
      service/internal/configcheck/rebuild.go
  45. 90 0
      service/internal/configcheck/rebuild_test.go
  46. 3 1
      service/internal/configissues/issue.go
  47. 29 0
      service/internal/entities/search_hints.go
  48. 42 0
      service/internal/entities/search_hints_test.go
  49. 5 1
      service/internal/executor/arguments.go
  50. 88 0
      specs/entity-acls.md
  51. 66 0
      specs/feature-flags.md

+ 11 - 0
SECURITY.md

@@ -37,6 +37,17 @@ The following notes might be helpful when reporting a vulnerability:
 * GitHub usernames are how we you will be credited for discoveries reported via GitHub, if using emails we'll ask for your preferred name/handle to credit you with.
 * CVEs will be requested via GitHub Security Advisories when appropriate, but we do not guarantee that all vulnerabilities will receive CVEs, as this is determined on a case-by-case basis.
 
+## Feature flags (alpha / experimental)
+
+OliveTin uses global `features.*` flags in `config.yaml` to ship unfinished or experimental functionality.
+
+* **All feature flags default to off.** Enabling a flag is an explicit operator choice.
+* Functionality behind a feature flag is **alpha / experimental** until the flag is removed or the feature is graduated to a stable, default-on product surface.
+* Private security reports **are accepted** for vulnerabilities that affect feature-flagged (alpha) functionality when that flag is enabled. Use Option A or B above; do not file a public issue that discloses exploit details.
+* Reports that affect **stable, non-flagged** code paths remain in scope under this policy, even if a feature flag exists elsewhere in the project.
+
+Operators who enable experimental features should treat them as preview software and avoid relying on them in high-assurance production deployments.
+
 ## Disclosure of how vulnerabilities were found
 
 It is incredibly useful to not just patch security vulnerabilities, but also to understand how they were found. If you are able to share this information, it can help us and the community to better understand potential attack vectors and improve the overall security of the project.

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

@@ -57,6 +57,7 @@
 ** xref:action_customization/ratelimiting.adoc[Rate Limiting]
 ** xref:action_customization/enabledExpression.adoc[Enabled Expression]
 ** xref:action_customization/ids.adoc[IDs]
+** xref:action_customization/hidden.adoc[Hidden]
 * xref:action_examples/intro.adoc[Action Examples]
 ** xref:action_examples/containers.adoc[Containers - start/stop]
 *** xref:action_examples/docker-proxy.adoc[Docker control, via proxy]

+ 17 - 0
docs/modules/ROOT/pages/action_customization/hidden.adoc

@@ -0,0 +1,17 @@
+[#action-hidden]
+= Hidden actions
+
+Set `hidden: true` on an action to keep it off the default **Actions** dashboard and out of automatic UI listings (for example entity-related action pickers). This is useful for background helpers that run on cron, startup, webhooks, or as xref:action_execution/triggers.adoc[triggers].
+
+[source,yaml]
+----
+actions:
+  - title: Update container entity file
+    shell: docker ps -a --format=json > /etc/OliveTin/entities/containers.json
+    hidden: true
+    execOnStartup: true
+----
+
+IMPORTANT: `hidden` is **not** a security control. It only affects where the action appears in the UI. Opening Action Details and calling APIs such as `GetActionBinding` require **view** permission; viewing execution logs requires **logs** permission. Those are separate from whether the action is listed on a dashboard. To restrict who can see or run an action, use xref:security/acl.adoc[Access Control Lists].
+
+You can still place a hidden action on a custom dashboard by title if you want a button for it in a specific place.

+ 2 - 0
docs/modules/ROOT/pages/action_customization/intro.adoc

@@ -12,6 +12,7 @@ You can customize actions by:
 * Setting rate limits to prevent actions from being executed too frequently
 * Using enabled expressions to dynamically enable/disable actions based on entity state
 * Assigning unique IDs for API access
+* Hiding background helpers from the default dashboard
 * Configuring log saving for audit trails
 
 See the links in this section for detailed information on each customization option.
@@ -28,3 +29,4 @@ Explore specific customization options:
 * xref:action_customization/enabledExpression.adoc[Enabled expressions] - Dynamically enable/disable actions based on entity state
 * xref:logs/saving.adoc[Save action logs] - Configure log retention for actions
 * xref:action_customization/ids.adoc[Set action IDs] - Assign IDs for API access
+* xref:action_customization/hidden.adoc[Hidden actions] - Keep background helpers off the default dashboard (not a security control)

+ 2 - 1
docs/modules/ROOT/pages/action_execution/triggers.adoc

@@ -1,7 +1,7 @@
 [#triggers]
 = Triggers
 
-Sometimes you want to trigger another action after the first one completes. This is mostly useful for updating hidden actions that update entity files, without having to run those updates on a cron job every 10 seconds!
+Sometimes you want to trigger another action after the first one completes. This is mostly useful for updating xref:action_customization/hidden.adoc[hidden] actions that update entity files, without having to run those updates on a cron job every 10 seconds!
 
 NOTE: OliveTin used to support a single action trigger, but now supports multiple triggers. The field `trigger` was renamed to `triggers` and is now an array of triggers.
 
@@ -23,3 +23,4 @@ actions:
     hidden: true
 ----
 
+IMPORTANT: `hidden` only removes the action from automatic dashboard listings. It is not a security control — use xref:security/acl.adoc[ACLs] to restrict who can view or execute an action. See xref:action_customization/hidden.adoc[Hidden actions].

+ 17 - 0
docs/modules/ROOT/pages/advanced_configuration/webui.adoc

@@ -143,6 +143,23 @@ You can add custom JavaScript to OliveTin, which will be executed on every page
 
 If the browser blocks your script or network calls with Content Security Policy errors, see xref:security/content_security_policy.adoc[Content Security Policy headers] for how to adjust or disable the CSP sent by OliveTin.
 
+[#header-search]
+== Header search
+
+Header QuickSearch is an **alpha / experimental** opt-in feature. All `features.*` flags default to **off**. Enabling a flag is an explicit operator choice; see the project https://github.com/OliveTin/OliveTin/blob/main/SECURITY.md[security policy] (feature flags are out of scope for CVEs and security advisories until graduated).
+
+Enable it in `config.yaml`:
+
+[source,yaml]
+----
+features:
+  headerSearch: true
+----
+
+When enabled, the header shows a search control that can jump to actions, dashboards, entities, and system navigation pages the user is already allowed to see. Refresh the browser after changing the flag so Init picks up the new value.
+
+Other `features.*` flags follow the same pattern: typed booleans under `features`, default false, exposed on Init for the web UI, and treated as alpha until graduated.
+
 == Custom CSS (with a custom theme)
 
 You can customize OliveTin with themes, but it's also possible to write your on very simple theme that contains just a few CSS rules to change the look and feel of OliveTin. This is very useful if you just want to change the colours of OliveTin, or hide a few elements.

+ 2 - 0
docs/modules/ROOT/pages/args/input_checklist.adoc

@@ -81,3 +81,5 @@ entities:
 ----
 
 OliveTin expands the template once per entity instance and renders each result as a checkbox. Selected values are still passed as a JSON array string.
+
+IMPORTANT: Like dropdowns, checklist arguments with `entity` must define **exactly one** choice template. Combining `entity` with multiple static choices is rejected on startup.

+ 2 - 0
docs/modules/ROOT/pages/args/input_dropdown.adoc

@@ -62,6 +62,8 @@ This is what it looks like in the web interface;
 
 image::args/dropdown/dropdown-entities.png[]
 
+IMPORTANT: Arguments with `entity` must define **exactly one** choice template. Multiple static choices combined with `entity` is invalid configuration and is rejected on startup.
+
 include::partial$args/reject-null.adoc[]
 
 == Default values

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

@@ -60,6 +60,7 @@ All configuration options are covered in the solution sections
 | `defaultIconForBack` | The default icon to use for back (from directories). | `«` | Requires Restart | -
 | `enableCustomJs` | Enable custom JavaScript. | `false` | Live Reloadable, but refreshing the web browser is required. | xref:advanced_configuration/webui.adoc[Custom JS].
 | `themeName` | The theme to use. | `` | Restart recommended | xref:reference/reference_themes_for_users.adoc[Themes].
+| `features.headerSearch` | Enable header QuickSearch (actions, dashboards, entities). Alpha / experimental; all `features.*` default off. | `false` | Live reloadable; refresh the browser after changing. | xref:advanced_configuration/webui.adoc#header-search[Header search].
 |===
 
 == Security Configuration

+ 4 - 0
docs/modules/ROOT/pages/entities/intro.adoc

@@ -15,6 +15,8 @@ Entity field values are **not** sanitized for shell safety. If you substitute th
 
 To control which fields appear in the Entities page table and entity details view, configure `properties` on the entity definition in `config.yaml`. See xref:entities/properties.adoc[Entity properties] for details.
 
+To restrict which users may see an entity type (list, details, search, and related UI), list `acls` on the entity definition. See xref:security/acl.adoc#acls[Access Control Lists] (Entities section). Entity types with no `acls` stay unrestricted.
+
 [source,yaml]
 ----
 entities:
@@ -24,6 +26,8 @@ entities:
   - file: /etc/OliveTin/servers.yaml
     name: server
     icon: ssh
+    acls:
+      - ops
     properties:
       - name: hostname
         title: Hostname

+ 54 - 0
docs/modules/ROOT/pages/security/acl.adoc

@@ -158,6 +158,60 @@ dashboards:
 
 In the example above, guests can open **Public tools**, but **Services** is hidden from the side menu and cannot be loaded by deep link.
 
+NOTE: Action `hidden: true` is not part of the ACL model. It only controls dashboard listing. Restrict who can see or run actions with the permissions above; see xref:action_customization/hidden.adoc[Hidden actions].
+
+== ACLs and Entities
+
+Entity types (entries under `entities` in configuration) can also list `acls`. This controls whether users may see that type in the Entities page, entity details, search hints, dashboard entity fieldsets, and entity-driven argument choices.
+
+* If an entity type has **no** `acls` (or an empty list), it is unrestricted — same as root dashboards without ACLs.
+* If an entity type lists one or more `acls`, access uses the same allow-list rules as actions and dashboards: a matching ACL that grants `view`, otherwise `defaultPermissions.view`.
+* Only **view** applies to entity types. `exec`, `logs`, and `kill` remain action permissions.
+* `addToEveryAction` does **not** apply to entities. List the ACL on the entity definition explicitly when you want to restrict it.
+* Access is per **entity type**, not per instance. All instances of a restricted type are hidden together.
+* Entity types loaded at runtime without a matching `entities:` entry stay unrestricted for ACL purposes, but Diagnostics reports a configuration warning.
+* Arguments that expand choices from an entity type must define **exactly one** choice template. Combining `entity` with multiple static choices is invalid and rejected on startup.
+
+Entity-related actions stay dual-gated:
+
+* Viewing the entity type requires entity `view`.
+* Seeing or running a related action still requires that action’s own ACLs (`view` / `exec`).
+* Search and dashboards do not show entity-bound actions for types the user cannot view, even if the action ACL alone would allow it.
+
+[source,yaml]
+.`config.yaml`
+----
+defaultPermissions:
+  view: false
+  exec: false
+
+accessControlLists:
+  - name: ops
+    matchUsergroups:
+      - ops
+    permissions:
+      view: true
+      exec: true
+
+entities:
+  - name: printers
+    file: entities/printers.yaml
+
+  - name: servers
+    file: entities/servers.yaml
+    acls:
+      - ops
+
+actions:
+  - title: Restart {{ name }}
+    entity: servers
+    shell: echo restart
+    acls:
+      - ops
+----
+
+In the example above, guests can see **printers** (unrestricted) but not **servers**. Users in the `ops` group can see **servers** and the Restart actions bound to them.
+
 == ACL Matching - usernames and usergroups.
 
 You can match users based on their usergroup which is the most common, but it is also possible to match based on the user's username.

+ 1 - 0
docs/modules/ROOT/pages/security/design_choices.adoc

@@ -12,6 +12,7 @@ OliveTin has a few design choices that should help it's general security posture
 * OliveTin listens on just 1 open public port by default (1337). The rest of the ports only listen on `localhost` so you don't have to worry about them in your firewall.
 * Standard Linux controls can be used to run OliveTin as non-root, with `sudo` permissions if needed. See the action customization section of these docs for more details.
 * Robust code-scanning, code review, and dependency analysis at build-time. OliveTin uses many linters and code checkers, especially on new pull requests. Out dated dependencies are addressed quickly.
+* Unfinished or experimental surfaces ship behind global `features.*` flags that **default to off**. Those surfaces are alpha until graduated; they are out of scope for security advisories and CVEs (see https://github.com/OliveTin/OliveTin/blob/main/SECURITY.md[SECURITY.md]). Do not enable experimental flags in high-assurance deployments unless you accept that risk.
 
 == Hardening Recommendations
 

+ 207 - 292
frontend/package-lock.json

@@ -18,11 +18,11 @@
 				"@xterm/addon-web-links": "^0.12.0",
 				"@xterm/xterm": "^6.0.0",
 				"iconify-icon": "^3.0.2",
-				"picocrank": "^1.21.2",
+				"picocrank": "^1.22.1",
 				"standard": "^17.1.2",
 				"unplugin-vue-components": "^32.1.0",
-				"vite": "^8.1.5",
-				"vue": "^3.5.40",
+				"vite": "^8.2.1",
+				"vue": "^3.5.41",
 				"vue-i18n": "^11.4.8",
 				"vue-router": "^5.2.0"
 			},
@@ -133,12 +133,12 @@
 			}
 		},
 		"node_modules/@babel/parser": {
-			"version": "7.29.7",
-			"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
-			"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+			"version": "7.29.8",
+			"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+			"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
 			"license": "MIT",
 			"dependencies": {
-				"@babel/types": "^7.29.7"
+				"@babel/types": "^7.29.8"
 			},
 			"bin": {
 				"parser": "bin/babel-parser.js"
@@ -148,9 +148,9 @@
 			}
 		},
 		"node_modules/@babel/types": {
-			"version": "7.29.7",
-			"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
-			"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+			"version": "7.29.8",
+			"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+			"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
 			"license": "MIT",
 			"dependencies": {
 				"@babel/helper-string-parser": "^7.29.7",
@@ -409,37 +409,6 @@
 				"postcss-selector-parser": "^7.1.1"
 			}
 		},
-		"node_modules/@emnapi/core": {
-			"version": "1.11.1",
-			"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
-			"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
-			"license": "MIT",
-			"optional": true,
-			"dependencies": {
-				"@emnapi/wasi-threads": "1.2.2",
-				"tslib": "^2.4.0"
-			}
-		},
-		"node_modules/@emnapi/runtime": {
-			"version": "1.11.1",
-			"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
-			"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
-			"license": "MIT",
-			"optional": true,
-			"dependencies": {
-				"tslib": "^2.4.0"
-			}
-		},
-		"node_modules/@emnapi/wasi-threads": {
-			"version": "1.2.2",
-			"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
-			"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
-			"license": "MIT",
-			"optional": true,
-			"dependencies": {
-				"tslib": "^2.4.0"
-			}
-		},
 		"node_modules/@esbuild/aix-ppc64": {
 			"version": "0.28.1",
 			"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
@@ -1110,24 +1079,6 @@
 			"dev": true,
 			"license": "MIT"
 		},
-		"node_modules/@napi-rs/wasm-runtime": {
-			"version": "1.1.6",
-			"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
-			"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
-			"license": "MIT",
-			"optional": true,
-			"dependencies": {
-				"@tybys/wasm-util": "^0.10.3"
-			},
-			"funding": {
-				"type": "github",
-				"url": "https://github.com/sponsors/Brooooooklyn"
-			},
-			"peerDependencies": {
-				"@emnapi/core": "^1.7.1",
-				"@emnapi/runtime": "^1.7.1"
-			}
-		},
 		"node_modules/@nodelib/fs.scandir": {
 			"version": "2.1.5",
 			"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1161,18 +1112,18 @@
 			}
 		},
 		"node_modules/@oxc-project/types": {
-			"version": "0.139.0",
-			"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
-			"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
+			"version": "0.143.0",
+			"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
+			"integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==",
 			"license": "MIT",
 			"funding": {
 				"url": "https://github.com/sponsors/Boshen"
 			}
 		},
 		"node_modules/@rolldown/binding-android-arm64": {
-			"version": "1.1.5",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
-			"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
+			"version": "1.2.3",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
+			"integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==",
 			"cpu": [
 				"arm64"
 			],
@@ -1186,9 +1137,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-darwin-arm64": {
-			"version": "1.1.5",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
-			"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
+			"version": "1.2.3",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz",
+			"integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==",
 			"cpu": [
 				"arm64"
 			],
@@ -1202,9 +1153,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-darwin-x64": {
-			"version": "1.1.5",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
-			"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
+			"version": "1.2.3",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz",
+			"integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==",
 			"cpu": [
 				"x64"
 			],
@@ -1218,9 +1169,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-freebsd-x64": {
-			"version": "1.1.5",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
-			"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
+			"version": "1.2.3",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz",
+			"integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==",
 			"cpu": [
 				"x64"
 			],
@@ -1234,9 +1185,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
-			"version": "1.1.5",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
-			"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
+			"version": "1.2.3",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz",
+			"integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==",
 			"cpu": [
 				"arm"
 			],
@@ -1250,9 +1201,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-linux-arm64-gnu": {
-			"version": "1.1.5",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
-			"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
+			"version": "1.2.3",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz",
+			"integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==",
 			"cpu": [
 				"arm64"
 			],
@@ -1266,9 +1217,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-linux-arm64-musl": {
-			"version": "1.1.5",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
-			"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
+			"version": "1.2.3",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz",
+			"integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==",
 			"cpu": [
 				"arm64"
 			],
@@ -1282,9 +1233,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-linux-ppc64-gnu": {
-			"version": "1.1.5",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
-			"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
+			"version": "1.2.3",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz",
+			"integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==",
 			"cpu": [
 				"ppc64"
 			],
@@ -1298,9 +1249,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-linux-s390x-gnu": {
-			"version": "1.1.5",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
-			"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
+			"version": "1.2.3",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz",
+			"integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==",
 			"cpu": [
 				"s390x"
 			],
@@ -1314,9 +1265,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-linux-x64-gnu": {
-			"version": "1.1.5",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
-			"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
+			"version": "1.2.3",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz",
+			"integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==",
 			"cpu": [
 				"x64"
 			],
@@ -1330,9 +1281,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-linux-x64-musl": {
-			"version": "1.1.5",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
-			"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
+			"version": "1.2.3",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz",
+			"integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==",
 			"cpu": [
 				"x64"
 			],
@@ -1346,9 +1297,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-openharmony-arm64": {
-			"version": "1.1.5",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
-			"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
+			"version": "1.2.3",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz",
+			"integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==",
 			"cpu": [
 				"arm64"
 			],
@@ -1361,28 +1312,10 @@
 				"node": "^20.19.0 || >=22.12.0"
 			}
 		},
-		"node_modules/@rolldown/binding-wasm32-wasi": {
-			"version": "1.1.5",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
-			"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
-			"cpu": [
-				"wasm32"
-			],
-			"license": "MIT",
-			"optional": true,
-			"dependencies": {
-				"@emnapi/core": "1.11.1",
-				"@emnapi/runtime": "1.11.1",
-				"@napi-rs/wasm-runtime": "^1.1.6"
-			},
-			"engines": {
-				"node": "^20.19.0 || >=22.12.0"
-			}
-		},
 		"node_modules/@rolldown/binding-win32-arm64-msvc": {
-			"version": "1.1.5",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
-			"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
+			"version": "1.2.3",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz",
+			"integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==",
 			"cpu": [
 				"arm64"
 			],
@@ -1396,9 +1329,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-win32-x64-msvc": {
-			"version": "1.1.5",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
-			"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
+			"version": "1.2.3",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz",
+			"integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==",
 			"cpu": [
 				"x64"
 			],
@@ -1436,16 +1369,6 @@
 				"url": "https://github.com/sponsors/sindresorhus"
 			}
 		},
-		"node_modules/@tybys/wasm-util": {
-			"version": "0.10.3",
-			"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
-			"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
-			"license": "MIT",
-			"optional": true,
-			"dependencies": {
-				"tslib": "^2.4.0"
-			}
-		},
 		"node_modules/@types/esrecurse": {
 			"version": "4.3.1",
 			"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -1524,39 +1447,39 @@
 			}
 		},
 		"node_modules/@vue/compiler-core": {
-			"version": "3.5.40",
-			"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz",
-			"integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==",
+			"version": "3.5.41",
+			"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz",
+			"integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==",
 			"license": "MIT",
 			"dependencies": {
-				"@babel/parser": "^7.29.7",
-				"@vue/shared": "3.5.40",
+				"@babel/parser": "^7.29.8",
+				"@vue/shared": "3.5.41",
 				"entities": "^7.0.1",
 				"estree-walker": "^2.0.2",
 				"source-map-js": "^1.2.1"
 			}
 		},
 		"node_modules/@vue/compiler-dom": {
-			"version": "3.5.40",
-			"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz",
-			"integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==",
+			"version": "3.5.41",
+			"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz",
+			"integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/compiler-core": "3.5.40",
-				"@vue/shared": "3.5.40"
+				"@vue/compiler-core": "3.5.41",
+				"@vue/shared": "3.5.41"
 			}
 		},
 		"node_modules/@vue/compiler-sfc": {
-			"version": "3.5.40",
-			"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz",
-			"integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==",
+			"version": "3.5.41",
+			"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz",
+			"integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==",
 			"license": "MIT",
 			"dependencies": {
-				"@babel/parser": "^7.29.7",
-				"@vue/compiler-core": "3.5.40",
-				"@vue/compiler-dom": "3.5.40",
-				"@vue/compiler-ssr": "3.5.40",
-				"@vue/shared": "3.5.40",
+				"@babel/parser": "^7.29.8",
+				"@vue/compiler-core": "3.5.41",
+				"@vue/compiler-dom": "3.5.41",
+				"@vue/compiler-ssr": "3.5.41",
+				"@vue/shared": "3.5.41",
 				"estree-walker": "^2.0.2",
 				"magic-string": "^0.30.21",
 				"postcss": "^8.5.19",
@@ -1564,13 +1487,13 @@
 			}
 		},
 		"node_modules/@vue/compiler-ssr": {
-			"version": "3.5.40",
-			"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz",
-			"integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==",
+			"version": "3.5.41",
+			"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz",
+			"integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/compiler-dom": "3.5.40",
-				"@vue/shared": "3.5.40"
+				"@vue/compiler-dom": "3.5.41",
+				"@vue/shared": "3.5.41"
 			}
 		},
 		"node_modules/@vue/devtools-api": {
@@ -1598,51 +1521,51 @@
 			"license": "MIT"
 		},
 		"node_modules/@vue/reactivity": {
-			"version": "3.5.40",
-			"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz",
-			"integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==",
+			"version": "3.5.41",
+			"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz",
+			"integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/shared": "3.5.40"
+				"@vue/shared": "3.5.41"
 			}
 		},
 		"node_modules/@vue/runtime-core": {
-			"version": "3.5.40",
-			"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz",
-			"integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==",
+			"version": "3.5.41",
+			"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz",
+			"integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/reactivity": "3.5.40",
-				"@vue/shared": "3.5.40"
+				"@vue/reactivity": "3.5.41",
+				"@vue/shared": "3.5.41"
 			}
 		},
 		"node_modules/@vue/runtime-dom": {
-			"version": "3.5.40",
-			"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz",
-			"integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==",
+			"version": "3.5.41",
+			"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz",
+			"integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/reactivity": "3.5.40",
-				"@vue/runtime-core": "3.5.40",
-				"@vue/shared": "3.5.40",
+				"@vue/reactivity": "3.5.41",
+				"@vue/runtime-core": "3.5.41",
+				"@vue/shared": "3.5.41",
 				"csstype": "^3.2.3"
 			}
 		},
 		"node_modules/@vue/server-renderer": {
-			"version": "3.5.40",
-			"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz",
-			"integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==",
+			"version": "3.5.41",
+			"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz",
+			"integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/compiler-ssr": "3.5.40",
-				"@vue/runtime-dom": "3.5.40",
-				"@vue/shared": "3.5.40"
+				"@vue/compiler-ssr": "3.5.41",
+				"@vue/runtime-dom": "3.5.41",
+				"@vue/shared": "3.5.41"
 			}
 		},
 		"node_modules/@vue/shared": {
-			"version": "3.5.40",
-			"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz",
-			"integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==",
+			"version": "3.5.41",
+			"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz",
+			"integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
 			"license": "MIT"
 		},
 		"node_modules/@xterm/addon-fit": {
@@ -1972,9 +1895,9 @@
 			"license": "ISC"
 		},
 		"node_modules/brace-expansion": {
-			"version": "1.1.16",
-			"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
-			"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
+			"version": "1.1.18",
+			"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+			"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
 			"license": "MIT",
 			"dependencies": {
 				"balanced-match": "^1.0.0",
@@ -3230,9 +3153,9 @@
 			"license": "MIT"
 		},
 		"node_modules/fast-uri": {
-			"version": "3.1.4",
-			"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
-			"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
+			"version": "3.1.5",
+			"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
+			"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
 			"dev": true,
 			"funding": [
 				{
@@ -3264,9 +3187,9 @@
 			}
 		},
 		"node_modules/femtocrank": {
-			"version": "2.5.0",
-			"resolved": "https://registry.npmjs.org/femtocrank/-/femtocrank-2.5.0.tgz",
-			"integrity": "sha512-plV1HNS/fUzohWJ349kuCBZ3TCfXz7V4F/sY2lVbVWtGXUV+aHxLG6IddAMEf64k2LJ8j0KVrj+nIIKepFaKvg==",
+			"version": "2.5.1",
+			"resolved": "https://registry.npmjs.org/femtocrank/-/femtocrank-2.5.1.tgz",
+			"integrity": "sha512-KdNcLMLBS/qwpXryPMp28OlB5OJjsLFL9d8qFSNkeldi/JQqn4Axuv345R6ZqGV0C5Aem8gkecjxdWrlOAS4pQ==",
 			"license": "AGPL-3.0"
 		},
 		"node_modules/file-entry-cache": {
@@ -4432,9 +4355,9 @@
 			}
 		},
 		"node_modules/lightningcss": {
-			"version": "1.32.0",
-			"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
-			"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+			"version": "1.33.0",
+			"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+			"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
 			"license": "MPL-2.0",
 			"dependencies": {
 				"detect-libc": "^2.0.3"
@@ -4447,23 +4370,23 @@
 				"url": "https://opencollective.com/parcel"
 			},
 			"optionalDependencies": {
-				"lightningcss-android-arm64": "1.32.0",
-				"lightningcss-darwin-arm64": "1.32.0",
-				"lightningcss-darwin-x64": "1.32.0",
-				"lightningcss-freebsd-x64": "1.32.0",
-				"lightningcss-linux-arm-gnueabihf": "1.32.0",
-				"lightningcss-linux-arm64-gnu": "1.32.0",
-				"lightningcss-linux-arm64-musl": "1.32.0",
-				"lightningcss-linux-x64-gnu": "1.32.0",
-				"lightningcss-linux-x64-musl": "1.32.0",
-				"lightningcss-win32-arm64-msvc": "1.32.0",
-				"lightningcss-win32-x64-msvc": "1.32.0"
+				"lightningcss-android-arm64": "1.33.0",
+				"lightningcss-darwin-arm64": "1.33.0",
+				"lightningcss-darwin-x64": "1.33.0",
+				"lightningcss-freebsd-x64": "1.33.0",
+				"lightningcss-linux-arm-gnueabihf": "1.33.0",
+				"lightningcss-linux-arm64-gnu": "1.33.0",
+				"lightningcss-linux-arm64-musl": "1.33.0",
+				"lightningcss-linux-x64-gnu": "1.33.0",
+				"lightningcss-linux-x64-musl": "1.33.0",
+				"lightningcss-win32-arm64-msvc": "1.33.0",
+				"lightningcss-win32-x64-msvc": "1.33.0"
 			}
 		},
 		"node_modules/lightningcss-android-arm64": {
-			"version": "1.32.0",
-			"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
-			"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+			"version": "1.33.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+			"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
 			"cpu": [
 				"arm64"
 			],
@@ -4481,9 +4404,9 @@
 			}
 		},
 		"node_modules/lightningcss-darwin-arm64": {
-			"version": "1.32.0",
-			"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
-			"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+			"version": "1.33.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+			"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
 			"cpu": [
 				"arm64"
 			],
@@ -4501,9 +4424,9 @@
 			}
 		},
 		"node_modules/lightningcss-darwin-x64": {
-			"version": "1.32.0",
-			"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
-			"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+			"version": "1.33.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+			"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
 			"cpu": [
 				"x64"
 			],
@@ -4521,9 +4444,9 @@
 			}
 		},
 		"node_modules/lightningcss-freebsd-x64": {
-			"version": "1.32.0",
-			"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
-			"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+			"version": "1.33.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+			"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
 			"cpu": [
 				"x64"
 			],
@@ -4541,9 +4464,9 @@
 			}
 		},
 		"node_modules/lightningcss-linux-arm-gnueabihf": {
-			"version": "1.32.0",
-			"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
-			"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+			"version": "1.33.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+			"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
 			"cpu": [
 				"arm"
 			],
@@ -4561,9 +4484,9 @@
 			}
 		},
 		"node_modules/lightningcss-linux-arm64-gnu": {
-			"version": "1.32.0",
-			"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
-			"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+			"version": "1.33.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+			"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
 			"cpu": [
 				"arm64"
 			],
@@ -4581,9 +4504,9 @@
 			}
 		},
 		"node_modules/lightningcss-linux-arm64-musl": {
-			"version": "1.32.0",
-			"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
-			"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+			"version": "1.33.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+			"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
 			"cpu": [
 				"arm64"
 			],
@@ -4601,9 +4524,9 @@
 			}
 		},
 		"node_modules/lightningcss-linux-x64-gnu": {
-			"version": "1.32.0",
-			"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
-			"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+			"version": "1.33.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+			"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
 			"cpu": [
 				"x64"
 			],
@@ -4621,9 +4544,9 @@
 			}
 		},
 		"node_modules/lightningcss-linux-x64-musl": {
-			"version": "1.32.0",
-			"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
-			"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+			"version": "1.33.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+			"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
 			"cpu": [
 				"x64"
 			],
@@ -4641,9 +4564,9 @@
 			}
 		},
 		"node_modules/lightningcss-win32-arm64-msvc": {
-			"version": "1.32.0",
-			"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
-			"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+			"version": "1.33.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+			"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
 			"cpu": [
 				"arm64"
 			],
@@ -4661,9 +4584,9 @@
 			}
 		},
 		"node_modules/lightningcss-win32-x64-msvc": {
-			"version": "1.32.0",
-			"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
-			"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+			"version": "1.33.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+			"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
 			"cpu": [
 				"x64"
 			],
@@ -4932,9 +4855,9 @@
 			"license": "MIT"
 		},
 		"node_modules/nanoid": {
-			"version": "3.3.16",
-			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
-			"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+			"version": "3.3.17",
+			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
+			"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
 			"funding": [
 				{
 					"type": "github",
@@ -5272,19 +5195,19 @@
 			"license": "ISC"
 		},
 		"node_modules/picocrank": {
-			"version": "1.21.2",
-			"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.21.2.tgz",
-			"integrity": "sha512-bUw6789jyYCTa9waINvYRHmnfgtoMquUidkJx2yhrDCMGbu2O65Qilkl++57yl5JnFeoZOhiPuQTKMPxMQku4A==",
+			"version": "1.22.1",
+			"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.22.1.tgz",
+			"integrity": "sha512-sgIngxDkDIWI4OnXt6VVAsh/mgWChOvsj6/EWOMvaE5h3YUoCr89c9R9XKltEtNhTE5su+wH28qjKlsHpYR5Dg==",
 			"license": "ISC",
 			"dependencies": {
-				"@hugeicons/core-free-icons": "^4.2.2",
+				"@hugeicons/core-free-icons": "^4.2.3",
 				"@hugeicons/vue": "^1.0.7",
-				"@vitejs/plugin-vue": "^6.0.7",
-				"femtocrank": "^2.5.0",
+				"@vitejs/plugin-vue": "^6.0.8",
+				"femtocrank": "^2.5.1",
 				"unplugin-vue-components": "^32.1.0",
-				"vite": "^8.1.2",
-				"vue": "^3.5.39",
-				"vue-router": "^5.1.0"
+				"vite": "^8.2.0",
+				"vue": "^3.5.40",
+				"vue-router": "^5.2.0"
 			}
 		},
 		"node_modules/picomatch": {
@@ -5404,9 +5327,9 @@
 			}
 		},
 		"node_modules/postcss": {
-			"version": "8.5.22",
-			"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
-			"integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==",
+			"version": "8.5.26",
+			"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+			"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
 			"funding": [
 				{
 					"type": "opencollective",
@@ -5423,7 +5346,7 @@
 			],
 			"license": "MIT",
 			"dependencies": {
-				"nanoid": "^3.3.16",
+				"nanoid": "^3.3.17",
 				"picocolors": "^1.1.1",
 				"source-map-js": "^1.2.1"
 			},
@@ -5706,12 +5629,12 @@
 			}
 		},
 		"node_modules/rolldown": {
-			"version": "1.1.5",
-			"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
-			"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
+			"version": "1.2.3",
+			"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
+			"integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==",
 			"license": "MIT",
 			"dependencies": {
-				"@oxc-project/types": "=0.139.0",
+				"@oxc-project/types": "=0.143.0",
 				"@rolldown/pluginutils": "^1.0.0"
 			},
 			"bin": {
@@ -5721,21 +5644,20 @@
 				"node": "^20.19.0 || >=22.12.0"
 			},
 			"optionalDependencies": {
-				"@rolldown/binding-android-arm64": "1.1.5",
-				"@rolldown/binding-darwin-arm64": "1.1.5",
-				"@rolldown/binding-darwin-x64": "1.1.5",
-				"@rolldown/binding-freebsd-x64": "1.1.5",
-				"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
-				"@rolldown/binding-linux-arm64-gnu": "1.1.5",
-				"@rolldown/binding-linux-arm64-musl": "1.1.5",
-				"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
-				"@rolldown/binding-linux-s390x-gnu": "1.1.5",
-				"@rolldown/binding-linux-x64-gnu": "1.1.5",
-				"@rolldown/binding-linux-x64-musl": "1.1.5",
-				"@rolldown/binding-openharmony-arm64": "1.1.5",
-				"@rolldown/binding-wasm32-wasi": "1.1.5",
-				"@rolldown/binding-win32-arm64-msvc": "1.1.5",
-				"@rolldown/binding-win32-x64-msvc": "1.1.5"
+				"@rolldown/binding-android-arm64": "1.2.3",
+				"@rolldown/binding-darwin-arm64": "1.2.3",
+				"@rolldown/binding-darwin-x64": "1.2.3",
+				"@rolldown/binding-freebsd-x64": "1.2.3",
+				"@rolldown/binding-linux-arm-gnueabihf": "1.2.3",
+				"@rolldown/binding-linux-arm64-gnu": "1.2.3",
+				"@rolldown/binding-linux-arm64-musl": "1.2.3",
+				"@rolldown/binding-linux-ppc64-gnu": "1.2.3",
+				"@rolldown/binding-linux-s390x-gnu": "1.2.3",
+				"@rolldown/binding-linux-x64-gnu": "1.2.3",
+				"@rolldown/binding-linux-x64-musl": "1.2.3",
+				"@rolldown/binding-openharmony-arm64": "1.2.3",
+				"@rolldown/binding-win32-arm64-msvc": "1.2.3",
+				"@rolldown/binding-win32-x64-msvc": "1.2.3"
 			}
 		},
 		"node_modules/run-parallel": {
@@ -6611,13 +6533,6 @@
 				"strip-bom": "^3.0.0"
 			}
 		},
-		"node_modules/tslib": {
-			"version": "2.8.1",
-			"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
-			"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
-			"license": "0BSD",
-			"optional": true
-		},
 		"node_modules/type-check": {
 			"version": "0.4.0",
 			"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -6916,15 +6831,15 @@
 			}
 		},
 		"node_modules/vite": {
-			"version": "8.1.5",
-			"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
-			"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
+			"version": "8.2.1",
+			"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
+			"integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
 			"license": "MIT",
 			"dependencies": {
-				"lightningcss": "^1.32.0",
+				"lightningcss": "^1.33.0",
 				"picomatch": "^4.0.5",
-				"postcss": "^8.5.17",
-				"rolldown": "~1.1.5",
+				"postcss": "^8.5.25",
+				"rolldown": "~1.2.1",
 				"tinyglobby": "^0.2.17"
 			},
 			"bin": {
@@ -6941,7 +6856,7 @@
 			},
 			"peerDependencies": {
 				"@types/node": "^20.19.0 || >=22.12.0",
-				"@vitejs/devtools": "^0.3.0",
+				"@vitejs/devtools": "^0.4.0",
 				"esbuild": "^0.27.0 || ^0.28.0",
 				"jiti": ">=1.21.0",
 				"less": "^4.0.0",
@@ -7005,16 +6920,16 @@
 			}
 		},
 		"node_modules/vue": {
-			"version": "3.5.40",
-			"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz",
-			"integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==",
+			"version": "3.5.41",
+			"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz",
+			"integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/compiler-dom": "3.5.40",
-				"@vue/compiler-sfc": "3.5.40",
-				"@vue/runtime-dom": "3.5.40",
-				"@vue/server-renderer": "3.5.40",
-				"@vue/shared": "3.5.40"
+				"@vue/compiler-dom": "3.5.41",
+				"@vue/compiler-sfc": "3.5.41",
+				"@vue/runtime-dom": "3.5.41",
+				"@vue/server-renderer": "3.5.41",
+				"@vue/shared": "3.5.41"
 			},
 			"peerDependencies": {
 				"typescript": "*"

+ 4 - 4
frontend/package.json

@@ -11,7 +11,7 @@
 		"stylelint-config-standard": "^40.0.0"
 	},
 	"scripts": {
-		"test": "node --test resources/vue/components/*.test.mjs resources/vue/utils/*.test.mjs"
+		"test": "node --test resources/vue/components/*.test.mjs resources/vue/utils/*.test.mjs resources/vue/stores/*.test.mjs"
 	},
 	"author": "",
 	"parcelIgnore": [
@@ -32,11 +32,11 @@
 		"@xterm/addon-web-links": "^0.12.0",
 		"@xterm/xterm": "^6.0.0",
 		"iconify-icon": "^3.0.2",
-		"picocrank": "^1.21.2",
+		"picocrank": "^1.22.1",
 		"standard": "^17.1.2",
 		"unplugin-vue-components": "^32.1.0",
-		"vite": "^8.1.5",
-		"vue": "^3.5.40",
+		"vite": "^8.2.1",
+		"vue": "^3.5.41",
 		"vue-i18n": "^11.4.8",
 		"vue-router": "^5.2.0"
 	},

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

@@ -335,6 +335,77 @@ export declare type GetDashboardResponse = Message<"olivetin.api.v1.GetDashboard
  */
 export declare const GetDashboardResponseSchema: GenMessage<GetDashboardResponse>;
 
+/**
+ * @generated from message olivetin.api.v1.SearchHints
+ */
+export declare type SearchHints = Message<"olivetin.api.v1.SearchHints"> & {
+  /**
+   * Lightweight titles for client search only. Omits fields, icons, and payloads.
+   * Dashboards are not included; clients index Init.root_dashboard_entries instead.
+   *
+   * @generated from field: repeated olivetin.api.v1.EntitySearchHint entities = 1;
+   */
+  entities: EntitySearchHint[];
+
+  /**
+   * @generated from field: repeated olivetin.api.v1.ActionSearchHint actions = 2;
+   */
+  actions: ActionSearchHint[];
+};
+
+/**
+ * Describes the message olivetin.api.v1.SearchHints.
+ * Use `create(SearchHintsSchema)` to create a new message.
+ */
+export declare const SearchHintsSchema: GenMessage<SearchHints>;
+
+/**
+ * @generated from message olivetin.api.v1.EntitySearchHint
+ */
+export declare type EntitySearchHint = Message<"olivetin.api.v1.EntitySearchHint"> & {
+  /**
+   * @generated from field: string title = 1;
+   */
+  title: string;
+
+  /**
+   * @generated from field: string type = 2;
+   */
+  type: string;
+
+  /**
+   * @generated from field: string unique_key = 3;
+   */
+  uniqueKey: string;
+};
+
+/**
+ * Describes the message olivetin.api.v1.EntitySearchHint.
+ * Use `create(EntitySearchHintSchema)` to create a new message.
+ */
+export declare const EntitySearchHintSchema: GenMessage<EntitySearchHint>;
+
+/**
+ * @generated from message olivetin.api.v1.ActionSearchHint
+ */
+export declare type ActionSearchHint = Message<"olivetin.api.v1.ActionSearchHint"> & {
+  /**
+   * @generated from field: string title = 1;
+   */
+  title: string;
+
+  /**
+   * @generated from field: string binding_id = 2;
+   */
+  bindingId: string;
+};
+
+/**
+ * Describes the message olivetin.api.v1.ActionSearchHint.
+ * Use `create(ActionSearchHintSchema)` to create a new message.
+ */
+export declare const ActionSearchHintSchema: GenMessage<ActionSearchHint>;
+
 /**
  * @generated from message olivetin.api.v1.EffectivePolicy
  */
@@ -361,6 +432,25 @@ export declare type EffectivePolicy = Message<"olivetin.api.v1.EffectivePolicy">
  */
 export declare const EffectivePolicySchema: GenMessage<EffectivePolicy>;
 
+/**
+ * Features are global opt-in flags from config.yaml features.*.
+ * All flags default to false and gate alpha / experimental functionality.
+ *
+ * @generated from message olivetin.api.v1.Features
+ */
+export declare type Features = Message<"olivetin.api.v1.Features"> & {
+  /**
+   * @generated from field: bool header_search = 1;
+   */
+  headerSearch: boolean;
+};
+
+/**
+ * Describes the message olivetin.api.v1.Features.
+ * Use `create(FeaturesSchema)` to create a new message.
+ */
+export declare const FeaturesSchema: GenMessage<Features>;
+
 /**
  * @generated from message olivetin.api.v1.GetDashboardRequest
  */
@@ -1895,6 +1985,21 @@ export declare type InitResponse = Message<"olivetin.api.v1.InitResponse"> & {
    * @generated from field: repeated olivetin.api.v1.RootDashboard root_dashboard_entries = 27;
    */
   rootDashboardEntries: RootDashboard[];
+
+  /**
+   * Client-side search index hints. Omitted when login is required
+   * or features.header_search is false.
+   * Entities match GetEntities visibility; actions use view ACL.
+   * Dashboards are indexed client-side from root_dashboard_entries.
+   *
+   * @generated from field: olivetin.api.v1.SearchHints search_hints = 28;
+   */
+  searchHints?: SearchHints | undefined;
+
+  /**
+   * @generated from field: olivetin.api.v1.Features features = 29;
+   */
+  features?: Features | undefined;
 };
 
 /**

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 38 - 3
frontend/resources/vue/App.vue

@@ -8,6 +8,14 @@
     @toggle-sidebar="toggleSidebar"
   >
     <template #toolbar>
+      <QuickSearch
+        v-if="!loginRequired && headerSearchEnabled"
+        :items="searchIndexItems"
+        :auto-import-routes="false"
+        :search-fields="['title', 'description', 'category']"
+        :max-results="15"
+        placeholder="Search actions, dashboards, entities…"
+      />
       <div
         v-if="bannerMessage"
         id="banner"
@@ -193,6 +201,7 @@ import { useRouter } from 'vue-router'
 import Sidebar from 'picocrank/vue/components/Sidebar.vue'
 import Navigation from 'picocrank/vue/components/Navigation.vue'
 import Header from 'picocrank/vue/components/Header.vue'
+import QuickSearch from 'picocrank/vue/components/QuickSearch.vue'
 import ConnectionBanner from './components/ConnectionBanner.vue'
 import { connectEventStreamIfNeeded } from '../../js/websocket.js'
 import { HugeiconsIcon } from '@hugeicons/vue'
@@ -200,6 +209,7 @@ import { UserCircle02Icon, DashboardSquare01Icon } from '@hugeicons/core-free-ic
 import logoUrl from '../../OliveTinLogo.png'
 import { useI18n } from 'vue-i18n'
 import combinedTranslations from '../../../lang/combined_output.json'
+import { searchIndexItems, clearSearchIndex, indexSystemNavigation, indexSearchHints, indexRootDashboardEntries } from './stores/searchIndex.js'
 const { t } = useI18n()
 
 const router = useRouter()
@@ -220,6 +230,8 @@ const showDiagnostics = ref(true)
 const showVersionNumber = ref(true)
 const showLoginLink = ref(true)
 const sectionNavigationStyle = ref('sidebar')
+const loginRequired = ref(false)
+const headerSearchEnabled = ref(false)
 
 const languageDialog = ref(null)
 const browserLanguages = ref([])
@@ -305,8 +317,15 @@ function updateHeaderFromInit () {
     return
   }
 
-  username.value = window.initResponse.authenticatedUser
-  isLoggedIn.value = window.initResponse.authenticatedUser !== '' && window.initResponse.authenticatedUser !== 'guest'
+  // Rebuild the in-memory search index from this Init; never persist it.
+  clearSearchIndex()
+
+  const authenticatedUser = window.initResponse.authenticatedUser
+  loginRequired.value = !!window.initResponse.loginRequired
+  headerSearchEnabled.value = !!window.initResponse.features?.headerSearch
+
+  username.value = authenticatedUser
+  isLoggedIn.value = authenticatedUser !== '' && authenticatedUser !== 'guest'
   currentVersion.value = window.initResponse.currentVersion
   pageTitle.value = window.initResponse.pageTitle || 'OliveTin'
   bannerMessage.value = window.initResponse.bannerMessage || ''
@@ -329,12 +348,17 @@ function updateHeaderFromInit () {
   renderNavigation()
   applyTheme()
 
-  if (window.initResponse.loginRequired) {
+  if (loginRequired.value) {
     connectEventStreamIfNeeded()
     router.push('/login')
     return
   }
 
+  if (headerSearchEnabled.value) {
+    indexSearchHints(window.initResponse.searchHints)
+    indexRootDashboardEntries(getRootDashboardEntries())
+  }
+
   connectEventStreamIfNeeded()
 }
 
@@ -433,6 +457,17 @@ function addSystemNavLinks () {
     })
   }
 
+  if (!loginRequired.value && headerSearchEnabled.value) {
+    indexRootDashboardEntries(getRootDashboardEntries())
+    indexSystemNavigation({
+      showLogs: showLogs.value,
+      showDiagnostics: showDiagnostics.value,
+      entitiesTitle: t('nav.entities'),
+      logsTitle: t('nav.logs'),
+      diagnosticsTitle: t('nav.diagnostics')
+    })
+  }
+
   if (systemLinks.length === 0) {
     return
   }

+ 228 - 0
frontend/resources/vue/stores/searchIndex.js

@@ -0,0 +1,228 @@
+import { reactive, computed } from 'vue'
+import {
+  CellsIcon,
+  DashboardSquare01Icon,
+  LeftToRightListDashIcon,
+  PlayIcon,
+  Wrench01Icon
+} from '@hugeicons/core-free-icons'
+
+export const MAX_SEARCH_HINT_ACTIONS = 100
+export const MAX_SEARCH_HINT_DASHBOARDS = 100
+export const MAX_SEARCH_HINT_ENTITIES_PER_TYPE = 50
+
+const SOURCE_ENTITIES = 'entities'
+const SOURCE_ACTIONS = 'actions'
+const SOURCE_DASHBOARDS = 'dashboards'
+const SOURCE_NAVIGATION = 'navigation'
+
+// QuickSearch keeps insertion order; prefer entities over actions for equal matches.
+const CATEGORY_PRIORITY = {
+  Navigation: 0,
+  Dashboards: 1,
+  Entities: 2,
+  Actions: 3
+}
+
+const state = reactive({
+  itemsById: {},
+  sourceToIds: {}
+})
+
+export const searchIndexItems = computed(() => {
+  return Object.values(state.itemsById).sort((a, b) => {
+    return categoryPriority(a.category) - categoryPriority(b.category)
+  })
+})
+
+function categoryPriority (category) {
+  return CATEGORY_PRIORITY[category] ?? 50
+}
+
+export function clearSearchIndex () {
+  state.itemsById = {}
+  state.sourceToIds = {}
+}
+
+function replaceSource (source, items) {
+  const previousIds = state.sourceToIds[source] || []
+
+  for (const itemId of previousIds) {
+    delete state.itemsById[itemId]
+  }
+
+  const nextIds = []
+  for (const item of items) {
+    if (!item?.id) {
+      continue
+    }
+
+    state.itemsById[item.id] = item
+    nextIds.push(item.id)
+  }
+
+  state.sourceToIds[source] = nextIds
+}
+
+export function dashboardRoutePath (title, entityType, entityKey) {
+  if (!title) {
+    return '/'
+  }
+
+  if (title === 'Actions' && !entityType && !entityKey) {
+    return '/'
+  }
+
+  let path = `/dashboards/${encodeURIComponent(title)}`
+
+  if (entityType && entityKey) {
+    path += `/${encodeURIComponent(entityType)}/${encodeURIComponent(entityKey)}`
+  }
+
+  return path
+}
+
+/**
+ * Indexes search hints from Init (entities and actions).
+ * Dashboards are indexed separately from rootDashboardEntries.
+ */
+export function indexSearchHints (searchHints) {
+  replaceSource(SOURCE_ENTITIES, entityItemsFromHints(searchHints?.entities))
+  replaceSource(SOURCE_ACTIONS, actionItemsFromHints(searchHints?.actions))
+}
+
+/**
+ * Indexes dashboards from Init.rootDashboardEntries (already ACL-filtered).
+ */
+export function indexRootDashboardEntries (entries) {
+  replaceSource(SOURCE_DASHBOARDS, dashboardItemsFromRootEntries(entries))
+}
+
+function entityItemsFromHints (hints) {
+  if (!Array.isArray(hints)) {
+    return []
+  }
+
+  const countsByType = {}
+  const items = []
+
+  for (const hint of hints) {
+    if (!hint?.uniqueKey || !hint?.type) {
+      continue
+    }
+
+    const count = countsByType[hint.type] || 0
+    if (count >= MAX_SEARCH_HINT_ENTITIES_PER_TYPE) {
+      continue
+    }
+
+    countsByType[hint.type] = count + 1
+    items.push({
+      id: `entity:${hint.type}:${hint.uniqueKey}`,
+      title: hint.title || hint.uniqueKey,
+      description: hint.type,
+      category: 'Entities',
+      type: 'route',
+      path: `/entity-details/${encodeURIComponent(hint.type)}/${encodeURIComponent(hint.uniqueKey)}`,
+      icon: CellsIcon
+    })
+  }
+
+  return items
+}
+
+function actionItemsFromHints (hints) {
+  if (!Array.isArray(hints)) {
+    return []
+  }
+
+  const items = []
+  for (const hint of hints.slice(0, MAX_SEARCH_HINT_ACTIONS)) {
+    if (!hint?.bindingId) {
+      continue
+    }
+
+    items.push({
+      id: `action:${hint.bindingId}`,
+      title: hint.title || hint.bindingId,
+      category: 'Actions',
+      type: 'route',
+      path: `/action/${encodeURIComponent(hint.bindingId)}`,
+      icon: PlayIcon
+    })
+  }
+
+  return items
+}
+
+function dashboardItemsFromRootEntries (entries) {
+  if (!Array.isArray(entries)) {
+    return []
+  }
+
+  const items = []
+  for (const entry of entries.slice(0, MAX_SEARCH_HINT_DASHBOARDS)) {
+    if (!entry?.title) {
+      continue
+    }
+
+    const section = (entry.category || '').trim()
+
+    items.push({
+      id: `dashboard:${entry.title}`,
+      title: entry.title,
+      description: section,
+      category: 'Dashboards',
+      type: 'route',
+      path: dashboardRoutePath(entry.title),
+      icon: DashboardSquare01Icon
+    })
+  }
+
+  return items
+}
+
+/**
+ * Indexes system navigation destinations that the user can already reach from
+ * the sidebar, respecting Init visibility flags.
+ */
+export function indexSystemNavigation ({
+  showLogs = false,
+  showDiagnostics = false,
+  entitiesTitle = 'Entities',
+  logsTitle = 'Logs',
+  diagnosticsTitle = 'Diagnostics'
+} = {}) {
+  const items = [{
+    id: 'nav:entities',
+    title: entitiesTitle,
+    category: 'Navigation',
+    type: 'route',
+    path: '/entities',
+    icon: CellsIcon
+  }]
+
+  if (showLogs) {
+    items.push({
+      id: 'nav:logs',
+      title: logsTitle,
+      category: 'Navigation',
+      type: 'route',
+      path: '/logs',
+      icon: LeftToRightListDashIcon
+    })
+  }
+
+  if (showDiagnostics) {
+    items.push({
+      id: 'nav:diagnostics',
+      title: diagnosticsTitle,
+      category: 'Navigation',
+      type: 'route',
+      path: '/diagnostics',
+      icon: Wrench01Icon
+    })
+  }
+
+  replaceSource(SOURCE_NAVIGATION, items)
+}

+ 221 - 0
frontend/resources/vue/stores/searchIndex.test.mjs

@@ -0,0 +1,221 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import {
+  MAX_SEARCH_HINT_ACTIONS,
+  MAX_SEARCH_HINT_DASHBOARDS,
+  MAX_SEARCH_HINT_ENTITIES_PER_TYPE,
+  clearSearchIndex,
+  dashboardRoutePath,
+  indexRootDashboardEntries,
+  indexSearchHints,
+  indexSystemNavigation,
+  searchIndexItems
+} from '../stores/searchIndex.js'
+
+function resetIndex () {
+  clearSearchIndex()
+}
+
+test('dashboardRoutePath maps Actions to root', () => {
+  assert.equal(dashboardRoutePath('Actions'), '/')
+})
+
+test('dashboardRoutePath builds dashboard and entity paths', () => {
+  assert.equal(dashboardRoutePath('Servers'), '/dashboards/Servers')
+  assert.equal(
+    dashboardRoutePath('Servers', 'host', 'web01'),
+    '/dashboards/Servers/host/web01'
+  )
+  assert.equal(
+    dashboardRoutePath('Ops/Prod', 'host/type', 'key?1'),
+    '/dashboards/Ops%2FProd/host%2Ftype/key%3F1'
+  )
+  assert.equal(
+    dashboardRoutePath('Servers', 'host', 'key#frag'),
+    '/dashboards/Servers/host/key%23frag'
+  )
+})
+
+test('indexSearchHints indexes entities and actions', () => {
+  resetIndex()
+
+  indexSearchHints({
+    entities: [
+      { title: 'web01', type: 'host', uniqueKey: '0' },
+      { title: '', type: 'host', uniqueKey: '1' },
+      { title: 'skip', type: '', uniqueKey: 'x' },
+      { title: 'slash host', type: 'host/type', uniqueKey: 'key?1' },
+      { title: 'hash host', type: 'host', uniqueKey: 'key#frag' }
+    ],
+    actions: [
+      { title: 'Ping Host', bindingId: 'bind-ping' },
+      { title: '', bindingId: 'bind-empty-title' },
+      { title: 'Ignored', bindingId: '' },
+      { title: 'Slash Action', bindingId: 'bind/slash' },
+      { title: 'Query Action', bindingId: 'bind?query' },
+      { title: 'Hash Action', bindingId: 'bind#hash' }
+    ]
+  })
+
+  const byId = Object.fromEntries(searchIndexItems.value.map((item) => [item.id, item]))
+
+  assert.equal(byId['entity:host:0'].title, 'web01')
+  assert.equal(byId['entity:host:0'].path, '/entity-details/host/0')
+  assert.equal(byId['entity:host:0'].description, 'host')
+  assert.equal(byId['entity:host:1'].title, '1')
+  assert.equal(
+    byId['entity:host/type:key?1'].path,
+    '/entity-details/host%2Ftype/key%3F1'
+  )
+  assert.equal(
+    byId['entity:host:key#frag'].path,
+    '/entity-details/host/key%23frag'
+  )
+
+  assert.equal(byId['action:bind-ping'].title, 'Ping Host')
+  assert.equal(byId['action:bind-ping'].path, '/action/bind-ping')
+  assert.equal(byId['action:bind/slash'].path, '/action/bind%2Fslash')
+  assert.equal(byId['action:bind?query'].path, '/action/bind%3Fquery')
+  assert.equal(byId['action:bind#hash'].path, '/action/bind%23hash')
+  assert.equal(byId['action:bind-ping'].category, 'Actions')
+  assert.equal(byId['action:bind-empty-title'].title, 'bind-empty-title')
+
+  assert.equal(Object.keys(byId).includes('entity:host:x'), false)
+  assert.equal(Object.keys(byId).includes('action:'), false)
+})
+
+test('indexRootDashboardEntries indexes ACL-filtered dashboards', () => {
+  resetIndex()
+
+  indexRootDashboardEntries([
+    { title: 'Actions', category: '' },
+    { title: 'My Server', category: 'Infrastructure' },
+    { title: '', category: 'Ignored' }
+  ])
+
+  const byId = Object.fromEntries(searchIndexItems.value.map((item) => [item.id, item]))
+  assert.equal(byId['dashboard:Actions'].path, '/')
+  assert.equal(byId['dashboard:My Server'].path, '/dashboards/My%20Server')
+  assert.equal(byId['dashboard:My Server'].description, 'Infrastructure')
+  assert.equal(byId['dashboard:My Server'].category, 'Dashboards')
+})
+
+test('indexSearchHints replaces prior entity and action snapshots', () => {
+  resetIndex()
+
+  indexSearchHints({
+    entities: [{ title: 'old', type: 'host', uniqueKey: 'old' }],
+    actions: [{ title: 'Old Action', bindingId: 'old-action' }]
+  })
+  indexRootDashboardEntries([{ title: 'Old Board', category: '' }])
+
+  indexSearchHints({
+    entities: [{ title: 'new', type: 'host', uniqueKey: 'new' }],
+    actions: [{ title: 'New Action', bindingId: 'new-action' }]
+  })
+  indexRootDashboardEntries([{ title: 'New Board', category: 'Ops' }])
+
+  const byId = Object.fromEntries(searchIndexItems.value.map((item) => [item.id, item]))
+  assert.equal(byId['entity:host:old'], undefined)
+  assert.equal(byId['action:old-action'], undefined)
+  assert.equal(byId['dashboard:Old Board'], undefined)
+  assert.equal(byId['entity:host:new'].title, 'new')
+  assert.equal(byId['action:new-action'].title, 'New Action')
+  assert.equal(byId['dashboard:New Board'].description, 'Ops')
+})
+
+test('caps actions globally, entities per type, and dashboards from root entries', () => {
+  resetIndex()
+
+  const actions = []
+  for (let i = 0; i < MAX_SEARCH_HINT_ACTIONS + 20; i++) {
+    actions.push({ title: `Action ${i}`, bindingId: `a-${i}` })
+  }
+
+  const entities = []
+  for (let i = 0; i < MAX_SEARCH_HINT_ENTITIES_PER_TYPE + 20; i++) {
+    entities.push({ title: `Host ${i}`, type: 'host', uniqueKey: `${i}` })
+    entities.push({ title: `Container ${i}`, type: 'container', uniqueKey: `${i}` })
+  }
+
+  const dashboards = []
+  for (let i = 0; i < MAX_SEARCH_HINT_DASHBOARDS + 20; i++) {
+    dashboards.push({ title: `Board ${i}`, category: '' })
+  }
+
+  indexSearchHints({ actions, entities })
+  indexRootDashboardEntries(dashboards)
+
+  const items = searchIndexItems.value
+  assert.equal(items.filter((item) => item.category === 'Actions').length, MAX_SEARCH_HINT_ACTIONS)
+  assert.equal(items.filter((item) => item.category === 'Dashboards').length, MAX_SEARCH_HINT_DASHBOARDS)
+  assert.equal(
+    items.filter((item) => item.category === 'Entities' && item.description === 'host').length,
+    MAX_SEARCH_HINT_ENTITIES_PER_TYPE
+  )
+  assert.equal(
+    items.filter((item) => item.category === 'Entities' && item.description === 'container').length,
+    MAX_SEARCH_HINT_ENTITIES_PER_TYPE
+  )
+})
+
+test('indexSystemNavigation always includes entities and respects visibility flags', () => {
+  resetIndex()
+
+  indexSystemNavigation({
+    showLogs: true,
+    showDiagnostics: false,
+    entitiesTitle: 'Entities',
+    logsTitle: 'Logs',
+    diagnosticsTitle: 'Diagnostics'
+  })
+
+  let byId = Object.fromEntries(searchIndexItems.value.map((item) => [item.id, item]))
+  assert.equal(byId['nav:entities'].path, '/entities')
+  assert.equal(byId['nav:logs'].path, '/logs')
+  assert.equal(byId['nav:diagnostics'], undefined)
+
+  indexSystemNavigation({
+    showLogs: false,
+    showDiagnostics: true
+  })
+
+  byId = Object.fromEntries(searchIndexItems.value.map((item) => [item.id, item]))
+  assert.equal(byId['nav:entities'].path, '/entities')
+  assert.equal(byId['nav:logs'], undefined)
+  assert.equal(byId['nav:diagnostics'].path, '/diagnostics')
+})
+
+test('entities appear before actions in searchIndexItems', () => {
+  resetIndex()
+
+  indexSearchHints({
+    entities: [{ title: 'server1', type: 'host', uniqueKey: '0' }],
+    actions: [{ title: 'server1', bindingId: 'bind-server1' }]
+  })
+
+  const titlesByCategory = searchIndexItems.value.map((item) => [item.category, item.title])
+  const entityIndex = titlesByCategory.findIndex(([category]) => category === 'Entities')
+  const actionIndex = titlesByCategory.findIndex(([category]) => category === 'Actions')
+
+  assert.ok(entityIndex >= 0)
+  assert.ok(actionIndex >= 0)
+  assert.ok(entityIndex < actionIndex)
+})
+
+test('search index has no Logs category', () => {
+  resetIndex()
+
+  indexSearchHints({
+    entities: [{ title: 'web01', type: 'host', uniqueKey: '0' }],
+    actions: [{ title: 'Backup', bindingId: 'bind-backup' }]
+  })
+  indexRootDashboardEntries([{ title: 'Ops', category: '' }])
+  indexSystemNavigation({ showLogs: true, showDiagnostics: true })
+
+  assert.equal(
+    searchIndexItems.value.some((item) => item.category === 'Logs'),
+    false
+  )
+})

+ 0 - 1
frontend/resources/vue/views/EntitiesView.vue

@@ -24,7 +24,6 @@
 import { ref, computed, onMounted } from 'vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import EntityDefinitionSection from '../components/EntityDefinitionSection.vue'
-
 const definitionsLoaded = ref(false)
 const entityDefinitions = ref([])
 

+ 0 - 1
frontend/resources/vue/views/LogsCalendarView.vue

@@ -45,7 +45,6 @@ import { useI18n } from 'vue-i18n'
 import Calendar from 'picocrank/vue/components/Calendar.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import { loadStoredLogsFilter } from '../utils/logsFilterStorage.js'
-
 const router = useRouter()
 const { t } = useI18n()
 

+ 0 - 1
frontend/resources/vue/views/LogsListView.vue

@@ -222,7 +222,6 @@ import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 import LogActionTitle from '../components/LogActionTitle.vue'
 import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
 import { loadStoredLogsFilter, storeLogsFilter } from '../utils/logsFilterStorage.js'
-
 const route = useRoute()
 const router = useRouter()
 

+ 2 - 2
integration-tests/lib/elements.js

@@ -167,9 +167,9 @@ export async function openSidebar() {
 }
 
 export async function getNavigationLinks() {
-  // Exclude section headers (e.g. "System"); they are li.nav-section-header-item.
+  // Exclude section containers and legacy section header rows; count only link items.
   const navigationLinks = await webdriver.findElements(
-    By.css('.navigation-links li:not(.nav-section-header-item)')
+    By.css('.navigation-links li:not(.nav-section-header-item):not(.nav-section)')
   )
 
   return navigationLinks

+ 30 - 0
proto/olivetin/api/v1/olivetin.proto

@@ -79,12 +79,36 @@ message GetDashboardResponse {
 	Dashboard dashboard = 4;
 }
 
+message SearchHints {
+	// Lightweight titles for client search only. Omits fields, icons, and payloads.
+	// Dashboards are not included; clients index Init.root_dashboard_entries instead.
+	repeated EntitySearchHint entities = 1;
+	repeated ActionSearchHint actions = 2;
+}
+
+message EntitySearchHint {
+	string title = 1;
+	string type = 2;
+	string unique_key = 3;
+}
+
+message ActionSearchHint {
+	string title = 1;
+	string binding_id = 2;
+}
+
 message EffectivePolicy {
 	bool show_diagnostics = 1;
 	bool show_log_list = 2;
 	bool show_version_number = 3;
 }
 
+// Features are global opt-in flags from config.yaml features.*.
+// All flags default to false and gate alpha / experimental functionality.
+message Features {
+	bool header_search = 1;
+}
+
 message GetDashboardRequest {
 	string title = 1;
 	string entity_type = 2;
@@ -426,6 +450,12 @@ message InitResponse {
 	bool show_navigate_on_start_icons = 25;
 	int32 config_issue_count = 26;
 	repeated RootDashboard root_dashboard_entries = 27;
+	// Client-side search index hints. Omitted when login is required
+	// or features.header_search is false.
+	// Entities match GetEntities visibility; actions use view ACL.
+	// Dashboards are indexed client-side from root_dashboard_entries.
+	SearchHints search_hints = 28;
+	Features features = 29;
 }
 
 message RootDashboard {

Разница между файлами не показана из-за своего большого размера
+ 299 - 87
service/gen/olivetin/api/v1/olivetin.pb.go


+ 12 - 5
service/internal/acl/acl.go

@@ -119,12 +119,9 @@ func IsAllowedExec(cfg *config.Config, user *authpublic.AuthenticatedUser, actio
 	return aclCheck(Exec, cfg.DefaultPermissions.Exec, cfg, "isAllowedExec", user, action.Title, action.Acls, true)
 }
 
-// IsAllowedView checks if a User is allowed to view an Action
+// IsAllowedView checks if a User is allowed to view an Action.
+// Action.Hidden is not a security control — it only affects dashboard listing.
 func IsAllowedView(cfg *config.Config, user *authpublic.AuthenticatedUser, action *config.Action) bool {
-	if action.Hidden {
-		return false
-	}
-
 	return aclCheck(View, cfg.DefaultPermissions.View, cfg, "isAllowedView", user, action.Title, action.Acls, true)
 }
 
@@ -142,6 +139,16 @@ func IsAllowedViewDashboard(cfg *config.Config, user *authpublic.AuthenticatedUs
 	return aclCheck(View, cfg.DefaultPermissions.View, cfg, "isAllowedViewDashboard", user, dashboard.Title, dashboard.Acls, false)
 }
 
+// IsAllowedViewEntityType checks if a user may see an entity type (list, details, search).
+// Entity types with no acls are unrestricted. AddToEveryAction does not apply.
+func IsAllowedViewEntityType(cfg *config.Config, user *authpublic.AuthenticatedUser, entityFile *config.EntityFile) bool {
+	if entityFile == nil || len(entityFile.Acls) == 0 {
+		return true
+	}
+
+	return aclCheck(View, cfg.DefaultPermissions.View, cfg, "isAllowedViewEntityType", user, entityFile.Name, entityFile.Acls, false)
+}
+
 func isACLRelevant(resourceAcls []string, acl *config.AccessControlList, user *authpublic.AuthenticatedUser, includeAddToEvery bool) bool {
 	if !slices.Contains(user.Acls, acl.Name) {
 		return false

+ 80 - 0
service/internal/acl/acl_entity_test.go

@@ -0,0 +1,80 @@
+package acl
+
+import (
+	"testing"
+
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestIsAllowedViewEntityTypeAbsentAclsUnrestricted(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.DefaultPermissions.View = false
+
+	entityFile := &config.EntityFile{
+		Name: "printers",
+		File: "printers.yaml",
+	}
+
+	guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
+	guest.BuildUserAcls(cfg)
+
+	assert.True(t, IsAllowedViewEntityType(cfg, guest, entityFile))
+	assert.True(t, IsAllowedViewEntityType(cfg, guest, nil))
+}
+
+func TestIsAllowedViewEntityTypeAllowDenyAndDefaultFallback(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.DefaultPermissions.View = false
+	cfg.AccessControlLists = []*config.AccessControlList{
+		{
+			Name:           "ops",
+			MatchUsernames: []string{"admin"},
+			Permissions:    config.PermissionsList{View: true, Exec: true},
+		},
+	}
+
+	entityFile := &config.EntityFile{
+		Name: "servers",
+		File: "servers.yaml",
+		Acls: []string{"ops"},
+	}
+
+	guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
+	guest.BuildUserAcls(cfg)
+	admin := &authpublic.AuthenticatedUser{Username: "admin"}
+	admin.BuildUserAcls(cfg)
+
+	assert.False(t, IsAllowedViewEntityType(cfg, guest, entityFile))
+	assert.True(t, IsAllowedViewEntityType(cfg, admin, entityFile))
+
+	cfg.DefaultPermissions.View = true
+	assert.True(t, IsAllowedViewEntityType(cfg, guest, entityFile),
+		"when no relevant ACL matches, fall back to defaultPermissions.view")
+}
+
+func TestIsAllowedViewEntityTypeIgnoresAddToEveryAction(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.DefaultPermissions.View = false
+	cfg.AccessControlLists = []*config.AccessControlList{
+		{
+			Name:             "admins",
+			MatchUsernames:   []string{"admin"},
+			AddToEveryAction: true,
+			Permissions:      config.PermissionsList{View: true, Exec: true},
+		},
+	}
+
+	entityFile := &config.EntityFile{
+		Name: "secret",
+		File: "secret.yaml",
+		Acls: []string{"other"},
+	}
+
+	admin := &authpublic.AuthenticatedUser{Username: "admin"}
+	admin.BuildUserAcls(cfg)
+
+	assert.False(t, IsAllowedViewEntityType(cfg, admin, entityFile),
+		"AddToEveryAction must not grant entity view without listing the ACL on the entity")
+}

+ 46 - 0
service/internal/acl/acl_hidden_test.go

@@ -0,0 +1,46 @@
+package acl
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	config "github.com/OliveTin/OliveTin/internal/config"
+)
+
+func TestIsAllowedViewIgnoresHiddenFlag(t *testing.T) {
+	cfg := config.DefaultConfig()
+	guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
+	guest.BuildUserAcls(cfg)
+
+	visible := &config.Action{Title: "Visible", Shell: "echo"}
+	hidden := &config.Action{Title: "Hidden", Shell: "echo", Hidden: true}
+
+	assert.True(t, IsAllowedView(cfg, guest, visible))
+	assert.True(t, IsAllowedView(cfg, guest, hidden),
+		"hidden must not deny view; use ACLs to restrict access")
+}
+
+func TestIsAllowedViewHiddenStillRespectsAcl(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.DefaultPermissions.View = false
+	cfg.AccessControlLists = []*config.AccessControlList{
+		{
+			Name:             "admins",
+			MatchUsernames:   []string{"admin"},
+			AddToEveryAction: true,
+			Permissions:      config.PermissionsList{View: true},
+		},
+	}
+
+	admin := &authpublic.AuthenticatedUser{Username: "admin"}
+	admin.BuildUserAcls(cfg)
+	guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
+	guest.BuildUserAcls(cfg)
+
+	hidden := &config.Action{Title: "Webhook", Shell: "echo", Hidden: true}
+
+	assert.True(t, IsAllowedView(cfg, admin, hidden))
+	assert.False(t, IsAllowedView(cfg, guest, hidden))
+}

+ 115 - 16
service/internal/api/api.go

@@ -161,6 +161,10 @@ func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *connect.Request[apiv1.
 
 	authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
 	args := startActionArgumentsFromProto(req.Msg.Arguments)
+	if err := api.errUnlessStartEntityAccessAllowed(authenticatedUser, pair, args); err != nil {
+		return nil, err
+	}
+
 	justification := resolveStartJustification(pair.Action, pair, req.Msg.Justification, args)
 	if err := validateJustificationRequired(pair.Action, justification, authenticatedUser); err != nil {
 		return nil, connectInvalidJustification(err)
@@ -309,6 +313,10 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request
 
 	user := auth.UserFromApiCall(ctx, req, api.cfg)
 	args := startActionArgumentsFromProto(req.Msg.Arguments)
+	if err = api.errUnlessStartEntityAccessAllowed(user, binding, args); err != nil {
+		return nil, err
+	}
+
 	justification := resolveStartJustification(binding.Action, binding, req.Msg.Justification, args)
 
 	if err = validateJustificationRequired(binding.Action, justification, user); err != nil {
@@ -330,13 +338,18 @@ func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[a
 		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
 	}
 
+	user := auth.UserFromApiCall(ctx, req, api.cfg)
+	if err := api.errUnlessStartEntityAccessAllowed(user, binding, map[string]string{}); err != nil {
+		return nil, err
+	}
+
 	args := make(map[string]string)
 
 	execReq := executor.ExecutionRequest{
 		Binding:           binding,
 		TrackingID:        uuid.NewString(),
 		Arguments:         args,
-		AuthenticatedUser: auth.UserFromApiCall(ctx, req, api.cfg),
+		AuthenticatedUser: user,
 		Cfg:               api.cfg,
 	}
 
@@ -377,6 +390,10 @@ func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Re
 	}
 
 	user := auth.UserFromApiCall(ctx, req, api.cfg)
+	if err := api.errUnlessStartEntityAccessAllowed(user, binding, map[string]string{}); err != nil {
+		return nil, err
+	}
+
 	logEntry, err := api.startActionByGetAndWaitLogEntry(binding, user)
 	if err != nil {
 		return nil, err
@@ -586,7 +603,7 @@ func (api *oliveTinAPI) getActionBindingResponse(user *authpublic.AuthenticatedU
 		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", bindingId))
 	}
 
-	if !api.userCanViewAction(user, binding.Action) {
+	if !api.userCanViewBinding(user, binding) {
 		return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
 	}
 
@@ -603,6 +620,38 @@ func (api *oliveTinAPI) userCanViewAction(user *authpublic.AuthenticatedUser, ac
 	return acl.IsAllowedView(api.cfg, user, action)
 }
 
+func (api *oliveTinAPI) userCanViewEntityType(user *authpublic.AuthenticatedUser, entityType string) bool {
+	return acl.IsAllowedViewEntityType(api.cfg, user, entityFileForType(api.cfg, entityType))
+}
+
+func (api *oliveTinAPI) userCanViewBinding(user *authpublic.AuthenticatedUser, binding *executor.ActionBinding) bool {
+	if binding == nil || binding.Action == nil {
+		return false
+	}
+
+	if !api.userCanViewAction(user, binding.Action) {
+		return false
+	}
+
+	return api.bindingEntityTypeAllowed(user, binding)
+}
+
+func (api *oliveTinAPI) bindingEntityTypeAllowed(user *authpublic.AuthenticatedUser, binding *executor.ActionBinding) bool {
+	if binding == nil || binding.Action == nil || binding.Action.Entity == "" {
+		return true
+	}
+
+	return api.userCanViewEntityType(user, binding.Action.Entity)
+}
+
+func (api *oliveTinAPI) errUnlessBindingEntityTypeAllowed(user *authpublic.AuthenticatedUser, binding *executor.ActionBinding) error {
+	if api.bindingEntityTypeAllowed(user, binding) {
+		return nil
+	}
+
+	return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
+}
+
 func (api *oliveTinAPI) GetDashboard(ctx ctx.Context, req *connect.Request[apiv1.GetDashboardRequest]) (*connect.Response[apiv1.GetDashboardResponse], error) {
 	user := auth.UserFromApiCall(ctx, req, api.cfg)
 
@@ -848,7 +897,7 @@ func (api *oliveTinAPI) errUnlessUserMayValidateArgumentTypeForBinding(user *aut
 		return connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", bindingID))
 	}
 
-	if !api.userCanViewAction(user, binding.Action) {
+	if !api.userCanViewBinding(user, binding) {
 		return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
 	}
 
@@ -869,9 +918,31 @@ func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Reque
 		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", req.Msg.BindingId))
 	}
 
+	if err := api.validateArgumentTypeEntityAccess(user, req.Msg); err != nil {
+		return nil, err
+	}
+
 	return api.validateArgumentTypeConnectResponse(req.Msg)
 }
 
+func (api *oliveTinAPI) validateArgumentTypeEntityAccess(user *authpublic.AuthenticatedUser, msg *apiv1.ValidateArgumentTypeRequest) error {
+	arg := api.argumentFromValidationRequest(msg)
+	if arg == nil {
+		return nil
+	}
+
+	return api.errUnlessEntityArgumentAllowed(user, arg, "")
+}
+
+func (api *oliveTinAPI) argumentFromValidationRequest(msg *apiv1.ValidateArgumentTypeRequest) *config.ActionArgument {
+	if msg == nil || msg.BindingId == "" || msg.ArgumentName == "" {
+		return nil
+	}
+
+	arg, _ := api.findArgumentForValidation(msg.BindingId, msg.ArgumentName)
+	return arg
+}
+
 func (api *oliveTinAPI) validateArgumentTypeConnectResponse(msg *apiv1.ValidateArgumentTypeRequest) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
 	err := api.validateArgumentTypeInternal(msg)
 	desc := ""
@@ -895,6 +966,10 @@ func (api *oliveTinAPI) validateArgumentTypeInternal(msg *apiv1.ValidateArgument
 		return fmt.Errorf("argument not found")
 	}
 
+	if err := errUnlessEntityArgumentValueAllowed(arg, msg.Value); err != nil {
+		return err
+	}
+
 	return executor.ValidateArgument(arg, msg.Value, action)
 }
 
@@ -1227,23 +1302,18 @@ func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[api
 
 func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitRequest]) (*connect.Response[apiv1.InitResponse], error) {
 	user := auth.UserFromApiCall(ctx, req, api.cfg)
+	return connect.NewResponse(api.buildInitResponse(user)), nil
+}
 
+func (api *oliveTinAPI) buildInitResponse(user *authpublic.AuthenticatedUser) *apiv1.InitResponse {
 	loginRequired := user.IsGuest() && api.cfg.AuthRequireGuestsToLogin
-
-	showVersion := user.EffectivePolicy.ShowVersionNumber
-	currentVersion := ""
-	availableVersion := ""
-	if showVersion {
-		currentVersion = installationinfo.Build.Version
-		availableVersion = installationinfo.Runtime.AvailableVersion
-	}
-
+	currentVersion, availableVersion, showNewVersions := initVersionFields(user, api.cfg)
 	rootDashboardEntries := api.buildRootDashboardEntries(user, api.cfg.Dashboards)
 
 	res := &apiv1.InitResponse{
 		ShowFooter:                api.cfg.ShowFooter,
 		ShowNavigation:            api.cfg.ShowNavigation,
-		ShowNewVersions:           showVersion && api.cfg.ShowNewVersions,
+		ShowNewVersions:           showNewVersions,
 		AvailableVersion:          availableVersion,
 		CurrentVersion:            currentVersion,
 		PageTitle:                 api.cfg.PageTitle,
@@ -1268,9 +1338,29 @@ func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitReq
 		AvailableThemes:           discoverAvailableThemes(api.cfg),
 		ShowNavigateOnStartIcons:  api.cfg.ShowNavigateOnStartIcons,
 		ConfigIssueCount:          configIssueCountForUser(api, user),
+		Features: &apiv1.Features{
+			HeaderSearch: api.cfg.Features.HeaderSearch,
+		},
+		SearchHints: api.initSearchHints(user, loginRequired),
 	}
 
-	return connect.NewResponse(res), nil
+	return res
+}
+
+func initVersionFields(user *authpublic.AuthenticatedUser, cfg *config.Config) (currentVersion string, availableVersion string, showNewVersions bool) {
+	if !user.EffectivePolicy.ShowVersionNumber {
+		return "", "", false
+	}
+
+	return installationinfo.Build.Version, installationinfo.Runtime.AvailableVersion, cfg.ShowNewVersions
+}
+
+func (api *oliveTinAPI) initSearchHints(user *authpublic.AuthenticatedUser, loginRequired bool) *apiv1.SearchHints {
+	if loginRequired || !api.cfg.Features.HeaderSearch {
+		return nil
+	}
+
+	return api.buildSearchHints(user)
 }
 
 // discoverAvailableThemes finds all available themes in the custom-webui/themes directory.
@@ -1450,7 +1540,7 @@ func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.
 	}
 
 	entityMap := entities.GetEntities()
-	entityDefinitions := api.buildEntityDefinitionsResponse(req.Msg, entityMap)
+	entityDefinitions := api.buildEntityDefinitionsResponse(user, req.Msg, entityMap)
 
 	res := &apiv1.GetEntitiesResponse{
 		EntityDefinitions: entityDefinitions,
@@ -1570,6 +1660,10 @@ func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.Ge
 		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity type %s not found", req.Msg.Type))
 	}
 
+	if !api.userCanViewEntityType(user, req.Msg.Type) {
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity type %s not found", req.Msg.Type))
+	}
+
 	entity, ok := instances[req.Msg.UniqueKey]
 	if !ok {
 		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity with unique key %s not found in type %s", req.Msg.UniqueKey, req.Msg.Type))
@@ -1701,9 +1795,14 @@ func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv
 	}
 
 	authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
+	restartArgs := copyStringMap(execReqLogEntry.Arguments)
+	if err := api.errUnlessStartEntityAccessAllowed(authenticatedUser, execReqLogEntry.Binding, restartArgs); err != nil {
+		return nil, err
+	}
+
 	execReq := executor.ExecutionRequest{
 		Binding:           execReqLogEntry.Binding,
-		Arguments:         copyStringMap(execReqLogEntry.Arguments),
+		Arguments:         restartArgs,
 		Justification:     execReqLogEntry.Justification,
 		AuthenticatedUser: authenticatedUser,
 		Cfg:               api.cfg,

+ 46 - 14
service/internal/api/apiActions.go

@@ -81,18 +81,42 @@ func (rr *DashboardRenderRequest) findActionForEntity(title string, entity *enti
 	defer rr.ex.MapActionBindingsLock.RUnlock()
 
 	for _, binding := range rr.ex.MapActionBindings {
-		if !bindingMatchesTitleAndEntity(binding, title, entity) {
-			continue
-		}
-		if !acl.IsAllowedView(rr.cfg, rr.AuthenticatedUser, binding.Action) {
-			return nil
+		if action := rr.actionFromMatchingBinding(title, entity, binding); action != nil {
+			return action
 		}
-		return buildAction(binding, rr)
 	}
 
 	return nil
 }
 
+func (rr *DashboardRenderRequest) actionFromMatchingBinding(title string, entity *entities.Entity, binding *executor.ActionBinding) *apiv1.Action {
+	if !bindingMatchesTitleAndEntity(binding, title, entity) {
+		return nil
+	}
+
+	if !rr.canViewBindingForDashboard(binding) {
+		return nil
+	}
+
+	return buildAction(binding, rr)
+}
+
+func (rr *DashboardRenderRequest) canViewBindingForDashboard(binding *executor.ActionBinding) bool {
+	if binding == nil || binding.Action == nil {
+		return false
+	}
+
+	if !acl.IsAllowedView(rr.cfg, rr.AuthenticatedUser, binding.Action) {
+		return false
+	}
+
+	if binding.Action.Entity == "" {
+		return true
+	}
+
+	return acl.IsAllowedViewEntityType(rr.cfg, rr.AuthenticatedUser, entityFileForType(rr.cfg, binding.Action.Entity))
+}
+
 func matchesEntity(binding *executor.ActionBinding, entity *entities.Entity) bool {
 	if entity == nil {
 		return binding.Entity == nil
@@ -191,7 +215,7 @@ func applyActiveBindingStateToAction(btn *apiv1.Action, bindingID string, states
 	btn.HasQueuedInstance = state.hasQueued
 }
 
-func buildActionArguments(action *config.Action, entity *entities.Entity) []*apiv1.ActionArgument {
+func buildActionArguments(action *config.Action, entity *entities.Entity, rr *DashboardRenderRequest) []*apiv1.ActionArgument {
 	args := make([]*apiv1.ActionArgument, 0, len(action.Arguments))
 	for _, cfgArg := range action.Arguments {
 		args = append(args, &apiv1.ActionArgument{
@@ -200,7 +224,7 @@ func buildActionArguments(action *config.Action, entity *entities.Entity) []*api
 			Type:                  cfgArg.Type,
 			Description:           cfgArg.Description,
 			DefaultValue:          getDefaultArgumentValue(cfgArg, entity),
-			Choices:               buildChoices(cfgArg),
+			Choices:               buildChoices(cfgArg, rr),
 			Suggestions:           cfgArg.Suggestions,
 			SuggestionsBrowserKey: cfgArg.SuggestionsBrowserKey,
 		})
@@ -228,7 +252,7 @@ func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderReque
 
 	applyActiveBindingStateToAction(&btn, binding.ID, rr.activeBindingStates)
 	applyActionExecTriggers(&btn, action)
-	btn.Arguments = buildActionArguments(action, binding.Entity)
+	btn.Arguments = buildActionArguments(action, binding.Entity, rr)
 	btn.Groups = buildActionGroups(action, rr.cfg)
 
 	return &btn
@@ -262,15 +286,23 @@ func actionGroupMembershipFromConfig(name string, cfg *config.Config) *apiv1.Act
 	return membership
 }
 
-func buildChoices(arg config.ActionArgument) []*apiv1.ActionArgumentChoice {
-	if arg.Entity != "" && len(arg.Choices) == 1 {
-		return buildChoicesEntity(arg.Choices[0], arg.Entity)
-	} else {
+func buildChoices(arg config.ActionArgument, rr *DashboardRenderRequest) []*apiv1.ActionArgumentChoice {
+	if arg.Entity == "" {
 		return buildChoicesSimple(arg.Choices)
 	}
+
+	if len(arg.Choices) != 1 {
+		return []*apiv1.ActionArgumentChoice{}
+	}
+
+	return buildChoicesEntity(arg.Choices[0], arg.Entity, rr)
 }
 
-func buildChoicesEntity(firstChoice config.ActionArgumentChoice, entityTitle string) []*apiv1.ActionArgumentChoice {
+func buildChoicesEntity(firstChoice config.ActionArgumentChoice, entityTitle string, rr *DashboardRenderRequest) []*apiv1.ActionArgumentChoice {
+	if rr == nil || !acl.IsAllowedViewEntityType(rr.cfg, rr.AuthenticatedUser, entityFileForType(rr.cfg, entityTitle)) {
+		return []*apiv1.ActionArgumentChoice{}
+	}
+
 	ret := []*apiv1.ActionArgumentChoice{}
 
 	for _, ent := range entities.GetEntityInstancesOrdered(entityTitle) {

+ 52 - 33
service/internal/api/api_entities_list.go

@@ -5,6 +5,7 @@ import (
 	"strings"
 
 	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
 	config "github.com/OliveTin/OliveTin/internal/config"
 	"github.com/OliveTin/OliveTin/internal/entities"
 )
@@ -14,64 +15,71 @@ const (
 	maxEntityInstancesPageSize     = 100
 )
 
-func (api *oliveTinAPI) buildEntityDefinitionsResponse(req *apiv1.GetEntitiesRequest, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
+func (api *oliveTinAPI) buildEntityDefinitionsResponse(user *authpublic.AuthenticatedUser, req *apiv1.GetEntitiesRequest, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
 	if req != nil && req.EntityType != "" {
-		return api.buildFilteredEntityDefinitions(req, entityMap)
+		return api.buildFilteredEntityDefinitions(user, req, entityMap)
 	}
 
-	return api.buildAllEntityDefinitions(entityMap)
+	return api.buildAllEntityDefinitions(user, entityMap)
 }
 
-func (api *oliveTinAPI) buildAllEntityDefinitions(entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
+func (api *oliveTinAPI) buildAllEntityDefinitions(user *authpublic.AuthenticatedUser, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
 	entityNames := sortedEntityTypeNames(entityMap)
 	entityDefinitions := make([]*apiv1.EntityDefinition, 0, len(entityNames))
 
 	for _, name := range entityNames {
-		entityFile := entityFileForType(api.cfg, name)
-		properties := entityPropertiesFromFile(entityFile)
-		instances := buildSortedEntityInstances(name, entityMap[name], properties)
-
-		def := &apiv1.EntityDefinition{
-			Title:            name,
-			UsedOnDashboards: findDashboardsForEntity(name, api.cfg.Dashboards),
-			Icon:             entityTypeIcon(api.cfg, name),
-			Properties:       entityDefinitionProperties(properties),
-			TotalInstances:   int32(len(instances)),
-		}
-
-		if len(properties) == 0 {
-			def.Instances = instances
+		if !api.userCanViewEntityType(user, name) {
+			continue
 		}
 
+		def := api.buildEntityDefinition(name, entityMap[name], false, "", 0, 0)
 		entityDefinitions = append(entityDefinitions, def)
 	}
 
 	return entityDefinitions
 }
 
-func (api *oliveTinAPI) buildFilteredEntityDefinitions(req *apiv1.GetEntitiesRequest, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
+func (api *oliveTinAPI) buildFilteredEntityDefinitions(user *authpublic.AuthenticatedUser, req *apiv1.GetEntitiesRequest, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
+	if !api.userCanViewEntityType(user, req.EntityType) {
+		return nil
+	}
+
 	entityInstances, ok := entityMap[req.EntityType]
 	if !ok || len(entityInstances) == 0 {
 		return nil
 	}
 
-	entityFile := entityFileForType(api.cfg, req.EntityType)
-	properties := entityPropertiesFromFile(entityFile)
-	instances := buildSortedEntityInstances(req.EntityType, entityInstances, properties)
-	filtered := filterEntityInstances(instances, req.Filter)
 	pageSize := normalizeEntityInstancesPageSize(req.PageSize)
 	page := normalizeEntityInstancesPage(req.Page)
+	def := api.buildEntityDefinition(req.EntityType, entityInstances, true, req.Filter, page, pageSize)
+
+	return []*apiv1.EntityDefinition{def}
+}
+
+func (api *oliveTinAPI) buildEntityDefinition(entityType string, entityInstances map[string]*entities.Entity, paginate bool, filter string, page, pageSize int32) *apiv1.EntityDefinition {
+	entityFile := entityFileForType(api.cfg, entityType)
+	properties := entityPropertiesFromFile(entityFile)
+	instances := buildSortedEntityInstances(entityType, entityInstances, properties)
 
 	def := &apiv1.EntityDefinition{
-		Title:            req.EntityType,
-		UsedOnDashboards: findDashboardsForEntity(req.EntityType, api.cfg.Dashboards),
-		Icon:             entityTypeIcon(api.cfg, req.EntityType),
+		Title:            entityType,
+		UsedOnDashboards: findDashboardsForEntity(entityType, api.cfg.Dashboards),
+		Icon:             entityTypeIcon(api.cfg, entityType),
 		Properties:       entityDefinitionProperties(properties),
-		TotalInstances:   int32(len(filtered)),
-		Instances:        paginateEntityInstances(filtered, page, pageSize),
+		TotalInstances:   int32(len(instances)),
 	}
 
-	return []*apiv1.EntityDefinition{def}
+	if !paginate {
+		if len(properties) == 0 {
+			def.Instances = instances
+		}
+		return def
+	}
+
+	filtered := filterEntityInstances(instances, filter)
+	def.TotalInstances = int32(len(filtered))
+	def.Instances = paginateEntityInstances(filtered, page, pageSize)
+	return def
 }
 
 func sortedEntityTypeNames(entityMap entities.EntitiesByClass) []string {
@@ -112,17 +120,28 @@ func filterEntityInstances(instances []*apiv1.Entity, filter string) []*apiv1.En
 			filtered = append(filtered, instance)
 		}
 	}
-
 	return filtered
 }
 
 func entityInstanceMatchesFilter(instance *apiv1.Entity, filter string) bool {
-	if strings.Contains(strings.ToLower(instance.Title), filter) {
+	if instance == nil {
+		return false
+	}
+
+	if stringContainsFold(instance.Title, filter) || stringContainsFold(instance.UniqueKey, filter) {
 		return true
 	}
 
-	for _, value := range instance.Fields {
-		if strings.Contains(strings.ToLower(value), filter) {
+	return entityFieldsContainFilter(instance.Fields, filter)
+}
+
+func stringContainsFold(value, filter string) bool {
+	return strings.Contains(strings.ToLower(value), strings.ToLower(filter))
+}
+
+func entityFieldsContainFilter(fields map[string]string, filter string) bool {
+	for _, value := range fields {
+		if stringContainsFold(value, filter) {
 			return true
 		}
 	}

+ 214 - 0
service/internal/api/api_entity_acl_test.go

@@ -0,0 +1,214 @@
+package api
+
+import (
+	"context"
+	"testing"
+
+	"connectrpc.com/connect"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/entities"
+	"github.com/OliveTin/OliveTin/internal/executor"
+)
+
+func buildEntityAclTestConfig() *config.Config {
+	cfg := config.DefaultConfig()
+	cfg.DefaultPermissions.View = false
+	cfg.DefaultPermissions.Exec = false
+	cfg.AccessControlLists = []*config.AccessControlList{
+		{
+			Name:           "ops",
+			MatchUsernames: []string{"admin"},
+			Permissions:    config.PermissionsList{View: true, Exec: true},
+		},
+		{
+			Name:             "everyone",
+			MatchUsernames:   []string{"guest", "admin"},
+			Permissions:      config.PermissionsList{View: true, Exec: true},
+			AddToEveryAction: true,
+		},
+	}
+	cfg.Entities = []*config.EntityFile{
+		{Name: "printers", File: "printers.yaml"},
+		{Name: "servers", File: "servers.yaml", Acls: []string{"ops"}},
+	}
+	cfg.Actions = []*config.Action{
+		{
+			ID:     "restart-server",
+			Title:  "Restart {{ servers.name }}",
+			Shell:  "echo restart",
+			Entity: "servers",
+		},
+		{
+			ID:    "ping-printer",
+			Title: "Ping printer",
+			Shell: "echo ping",
+		},
+	}
+	cfg.Dashboards = []*config.DashboardComponent{
+		{
+			Title: "Infra",
+			Contents: []*config.DashboardComponent{
+				{
+					Title:  "{{ servers.name }}",
+					Type:   "fieldset",
+					Entity: "servers",
+					Contents: []*config.DashboardComponent{
+						{Title: "Restart {{ servers.name }}"},
+					},
+				},
+			},
+		},
+	}
+	cfg.Sanitize()
+	return cfg
+}
+
+func seedEntityAclTestEntities(t *testing.T) {
+	t.Helper()
+	entities.ClearEntitiesOfType("printers")
+	entities.ClearEntitiesOfType("servers")
+	t.Cleanup(func() {
+		entities.ClearEntitiesOfType("printers")
+		entities.ClearEntitiesOfType("servers")
+	})
+
+	entities.AddEntity("printers", "p1", map[string]any{"name": "lobby"})
+	entities.AddEntity("servers", "0", map[string]any{"name": "web01"})
+}
+
+func TestGetEntitiesOmitsRestrictedEntityTypes(t *testing.T) {
+	seedEntityAclTestEntities(t)
+	cfg := buildEntityAclTestConfig()
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	api := newServer(ex)
+
+	guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
+	guest.BuildUserAcls(cfg)
+
+	defs := api.buildEntityDefinitionsResponse(guest, &apiv1.GetEntitiesRequest{}, entities.GetEntities())
+	titles := make([]string, 0, len(defs))
+	for _, def := range defs {
+		titles = append(titles, def.Title)
+	}
+
+	assert.Contains(t, titles, "printers")
+	assert.NotContains(t, titles, "servers")
+}
+
+func TestGetEntityNotFoundForRestrictedType(t *testing.T) {
+	seedEntityAclTestEntities(t)
+	cfg := buildEntityAclTestConfig()
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
+	defer ts.Close()
+
+	// Guest Init/API without login uses guest user with default ACLs from UserFromApiCall.
+	_, err := client.GetEntity(context.Background(), connect.NewRequest(&apiv1.GetEntityRequest{
+		Type:      "servers",
+		UniqueKey: "0",
+	}))
+	require.Error(t, err)
+	assert.Equal(t, connect.CodeNotFound, connect.CodeOf(err))
+
+	resp, err := client.GetEntity(context.Background(), connect.NewRequest(&apiv1.GetEntityRequest{
+		Type:      "printers",
+		UniqueKey: "p1",
+	}))
+	require.NoError(t, err)
+	assert.Equal(t, "lobby", resp.Msg.Title)
+}
+
+func TestSearchHintsOmitRestrictedEntitiesAndEntityBoundActions(t *testing.T) {
+	seedEntityAclTestEntities(t)
+	cfg := buildEntityAclTestConfig()
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	api := newServer(ex)
+
+	guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
+	guest.BuildUserAcls(cfg)
+	admin := &authpublic.AuthenticatedUser{Username: "admin"}
+	admin.BuildUserAcls(cfg)
+
+	guestHints := api.buildSearchHints(guest)
+	require.NotNil(t, guestHints)
+
+	guestEntityKeys := make([]string, 0)
+	for _, hint := range guestHints.Entities {
+		guestEntityKeys = append(guestEntityKeys, hint.Type+":"+hint.UniqueKey)
+	}
+	assert.Contains(t, guestEntityKeys, "printers:p1")
+	assert.NotContains(t, guestEntityKeys, "servers:0")
+
+	guestActionIDs := actionHintBindingIDs(guestHints.Actions)
+	require.NotEmpty(t, guestActionIDs)
+	assert.Contains(t, guestActionIDs, "ping-printer")
+	assert.NotContains(t, guestActionIDs, "restart")
+
+	adminHints := api.buildSearchHints(admin)
+	require.NotNil(t, adminHints)
+
+	adminEntityKeys := make([]string, 0)
+	for _, hint := range adminHints.Entities {
+		adminEntityKeys = append(adminEntityKeys, hint.Type+":"+hint.UniqueKey)
+	}
+	assert.Contains(t, adminEntityKeys, "servers:0")
+	assert.NotEmpty(t, adminHints.Actions)
+}
+
+func TestEntityFieldsetOmitsRestrictedEntityType(t *testing.T) {
+	seedEntityAclTestEntities(t)
+	cfg := buildEntityAclTestConfig()
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	api := newServer(ex)
+
+	guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
+	guest.BuildUserAcls(cfg)
+	admin := &authpublic.AuthenticatedUser{Username: "admin"}
+	admin.BuildUserAcls(cfg)
+
+	guestRR := api.createDashboardRenderRequest(guest, "", "")
+	guestDB := renderDashboard(guestRR, "Infra")
+	require.NotNil(t, guestDB)
+	assert.Empty(t, guestDB.Contents, "restricted entity fieldsets must not leak instances")
+
+	adminRR := api.createDashboardRenderRequest(admin, "", "")
+	adminDB := renderDashboard(adminRR, "Infra")
+	require.NotNil(t, adminDB)
+	require.NotEmpty(t, adminDB.Contents)
+}
+
+func TestBuildChoicesEntityRespectsEntityACL(t *testing.T) {
+	seedEntityAclTestEntities(t)
+	cfg := buildEntityAclTestConfig()
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+
+	guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
+	guest.BuildUserAcls(cfg)
+	admin := &authpublic.AuthenticatedUser{Username: "admin"}
+	admin.BuildUserAcls(cfg)
+
+	arg := config.ActionArgument{
+		Entity: "servers",
+		Choices: []config.ActionArgumentChoice{
+			{Title: "{{ servers.name }}", Value: "{{ servers.name }}"},
+		},
+	}
+
+	guestRR := &DashboardRenderRequest{AuthenticatedUser: guest, cfg: cfg, ex: ex}
+	assert.Empty(t, buildChoices(arg, guestRR))
+
+	adminRR := &DashboardRenderRequest{AuthenticatedUser: admin, cfg: cfg, ex: ex}
+	choices := buildChoices(arg, adminRR)
+	require.Len(t, choices, 1)
+	assert.Equal(t, "web01", choices[0].Value)
+}

+ 175 - 0
service/internal/api/api_entity_argument_acl.go

@@ -0,0 +1,175 @@
+package api
+
+import (
+	"fmt"
+	"strings"
+
+	"connectrpc.com/connect"
+
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/entities"
+	"github.com/OliveTin/OliveTin/internal/executor"
+	"github.com/OliveTin/OliveTin/internal/tpl"
+)
+
+// errUnlessStartEntityAccessAllowed enforces entity-type view ACL on the binding
+// and rejects entity-backed argument values the user may not use.
+func (api *oliveTinAPI) errUnlessStartEntityAccessAllowed(user *authpublic.AuthenticatedUser, binding *executor.ActionBinding, args map[string]string) error {
+	if err := api.errUnlessBindingEntityTypeAllowed(user, binding); err != nil {
+		return err
+	}
+
+	if binding == nil {
+		return nil
+	}
+
+	return api.errUnlessEntityArgumentsAllowed(user, binding.Action, args)
+}
+
+// errUnlessEntityArgumentsAllowed rejects starts that use entity-backed arguments
+// the user may not view, or guessed values that are not in the allowed choice set.
+func (api *oliveTinAPI) errUnlessEntityArgumentsAllowed(user *authpublic.AuthenticatedUser, action *config.Action, args map[string]string) error {
+	if action == nil {
+		return nil
+	}
+
+	for argumentIndex := range action.Arguments {
+		arg := &action.Arguments[argumentIndex]
+		if arg.Entity == "" {
+			continue
+		}
+
+		if err := api.errUnlessEntityArgumentAllowed(user, arg, args[arg.Name]); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func isEntityBackedArgument(arg *config.ActionArgument) bool {
+	return arg != nil && arg.Entity != "" && len(arg.Choices) == 1
+}
+
+func isMalformedEntityArgument(arg *config.ActionArgument) bool {
+	return arg != nil && arg.Entity != "" && len(arg.Choices) != 1
+}
+
+func (api *oliveTinAPI) errUnlessEntityArgumentAllowed(user *authpublic.AuthenticatedUser, arg *config.ActionArgument, value string) error {
+	if err := errUnlessEntityArgumentShapeAllowed(arg); err != nil {
+		return err
+	}
+
+	if !isEntityBackedArgument(arg) {
+		return nil
+	}
+
+	if !api.userCanViewEntityType(user, arg.Entity) {
+		return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
+	}
+
+	if err := errUnlessEntityArgumentValueAllowed(arg, value); err != nil {
+		return connect.NewError(connect.CodeInvalidArgument, err)
+	}
+
+	return nil
+}
+
+func errUnlessEntityArgumentShapeAllowed(arg *config.ActionArgument) error {
+	if !isMalformedEntityArgument(arg) {
+		return nil
+	}
+
+	return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("argument %q with entity must define exactly one choice template", arg.Name))
+}
+
+func errUnlessEntityArgumentValueAllowed(arg *config.ActionArgument, value string) error {
+	if isMalformedEntityArgument(arg) {
+		return fmt.Errorf("argument %q with entity must define exactly one choice template", arg.Name)
+	}
+
+	if !isEntityBackedArgument(arg) {
+		return nil
+	}
+
+	value = strings.TrimSpace(value)
+	if value == "" {
+		return nil
+	}
+
+	if !entityArgumentValueAllowed(arg, value) {
+		return fmt.Errorf("argument %q is not a permitted entity value", arg.Name)
+	}
+
+	return nil
+}
+
+func entityArgumentValueAllowed(arg *config.ActionArgument, value string) bool {
+	allowed := entityArgumentAllowedValues(arg)
+	if strings.EqualFold(arg.Type, "checklist") {
+		return checklistEntityValuesAllowed(arg, value, allowed)
+	}
+
+	normalized := normalizeEntityArgumentValue(arg, value)
+	_, ok := allowed[normalized]
+	return ok
+}
+
+func entityArgumentAllowedValues(arg *config.ActionArgument) map[string]struct{} {
+	allowed := make(map[string]struct{})
+	if arg == nil || len(arg.Choices) != 1 {
+		return allowed
+	}
+
+	for _, ent := range entities.GetEntityInstancesOrdered(arg.Entity) {
+		resolved := tpl.ParseTemplateOfActionBeforeExec(arg.Choices[0].Value, ent)
+		if resolved == "" {
+			continue
+		}
+		allowed[resolved] = struct{}{}
+	}
+
+	return allowed
+}
+
+func normalizeEntityArgumentValue(arg *config.ActionArgument, value string) string {
+	if arg == nil || arg.Entity == "" || len(arg.Choices) != 1 {
+		return value
+	}
+
+	if resolved, ok := entityChoiceValueForTitle(arg, value); ok {
+		return resolved
+	}
+
+	return value
+}
+
+func entityChoiceValueForTitle(arg *config.ActionArgument, title string) (string, bool) {
+	for _, ent := range entities.GetEntityInstancesOrdered(arg.Entity) {
+		expandedTitle := tpl.ParseTemplateOfActionBeforeExec(arg.Choices[0].Title, ent)
+		if title != expandedTitle {
+			continue
+		}
+
+		return tpl.ParseTemplateOfActionBeforeExec(arg.Choices[0].Value, ent), true
+	}
+
+	return "", false
+}
+
+func checklistEntityValuesAllowed(arg *config.ActionArgument, value string, allowed map[string]struct{}) bool {
+	segments, err := config.ParseChecklistValue(value)
+	if err != nil || len(segments) == 0 {
+		return false
+	}
+
+	for _, segment := range segments {
+		normalized := normalizeEntityArgumentValue(arg, strings.TrimSpace(segment))
+		if _, ok := allowed[normalized]; !ok {
+			return false
+		}
+	}
+
+	return true
+}

+ 205 - 0
service/internal/api/api_entity_argument_acl_test.go

@@ -0,0 +1,205 @@
+package api
+
+import (
+	"context"
+	"testing"
+
+	"connectrpc.com/connect"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
+	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/entities"
+	"github.com/OliveTin/OliveTin/internal/executor"
+)
+
+func buildEntityArgumentGuessConfig() *config.Config {
+	cfg := config.DefaultConfig()
+	cfg.DefaultPermissions.View = false
+	cfg.DefaultPermissions.Exec = false
+	cfg.AccessControlLists = []*config.AccessControlList{
+		{
+			Name:           "ops",
+			MatchUsernames: []string{"admin"},
+			Permissions:    config.PermissionsList{View: true, Exec: true},
+		},
+		{
+			Name:             "everyone",
+			MatchUsernames:   []string{"guest", "admin"},
+			Permissions:      config.PermissionsList{View: true, Exec: true},
+			AddToEveryAction: true,
+		},
+	}
+	cfg.Entities = []*config.EntityFile{
+		{Name: "servers", File: "servers.yaml", Acls: []string{"ops"}},
+	}
+	cfg.Actions = []*config.Action{
+		{
+			ID:    "reboot-server",
+			Title: "Reboot server",
+			Shell: "echo reboot '{{ target }}'",
+			Arguments: []config.ActionArgument{
+				{
+					Name:   "target",
+					Title:  "Server",
+					Entity: "servers",
+					Choices: []config.ActionArgumentChoice{
+						{Title: "{{ servers.name }}", Value: "{{ servers.name }}"},
+					},
+				},
+			},
+		},
+	}
+	cfg.Sanitize()
+	return cfg
+}
+
+func seedEntityArgumentGuessEntities(t *testing.T) {
+	t.Helper()
+	entities.ClearEntitiesOfType("servers")
+	t.Cleanup(func() {
+		entities.ClearEntitiesOfType("servers")
+	})
+	entities.AddEntity("servers", "0", map[string]any{"name": "web01"})
+	entities.AddEntity("servers", "1", map[string]any{"name": "db01"})
+}
+
+func TestStartActionRejectsGuessedRestrictedEntityArgument(t *testing.T) {
+	seedEntityArgumentGuessEntities(t)
+	cfg := buildEntityArgumentGuessConfig()
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
+	defer ts.Close()
+
+	_, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
+		BindingId: "reboot-server",
+		Arguments: []*apiv1.StartActionArgument{
+			{Name: "target", Value: "web01"},
+		},
+	}))
+	require.Error(t, err)
+	assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err))
+}
+
+func TestStartActionRejectsUnknownEntityArgumentValue(t *testing.T) {
+	seedEntityArgumentGuessEntities(t)
+	cfg := buildEntityArgumentGuessConfig()
+	// Make servers unrestricted so guests can view the type but not invent values.
+	cfg.Entities[0].Acls = nil
+	cfg.Sanitize()
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
+	defer ts.Close()
+
+	_, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
+		BindingId: "reboot-server",
+		Arguments: []*apiv1.StartActionArgument{
+			{Name: "target", Value: "not-a-real-server"},
+		},
+	}))
+	require.Error(t, err)
+	assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
+}
+
+func TestStartActionAllowsListedEntityArgumentValue(t *testing.T) {
+	seedEntityArgumentGuessEntities(t)
+	cfg := buildEntityArgumentGuessConfig()
+	cfg.Entities[0].Acls = nil
+	cfg.Sanitize()
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
+	defer ts.Close()
+
+	resp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
+		BindingId: "reboot-server",
+		Arguments: []*apiv1.StartActionArgument{
+			{Name: "target", Value: "web01"},
+		},
+	}))
+	require.NoError(t, err)
+	assert.NotEmpty(t, resp.Msg.ExecutionTrackingId)
+}
+
+func TestChecklistEntityValuesAllowedRejectsBlankOnlyInput(t *testing.T) {
+	allowed := map[string]struct{}{"web01": {}, "db01": {}}
+	arg := &config.ActionArgument{Type: "checklist"}
+
+	assert.False(t, checklistEntityValuesAllowed(arg, ",,,", allowed))
+	assert.False(t, checklistEntityValuesAllowed(arg, " , ", allowed))
+	assert.False(t, checklistEntityValuesAllowed(arg, "", allowed),
+		"all-blank checklist parts are rejected here; empty string is accepted by the caller separately")
+	assert.True(t, checklistEntityValuesAllowed(arg, "web01", allowed))
+	assert.True(t, checklistEntityValuesAllowed(arg, `["web01","db01"]`, allowed))
+	assert.False(t, checklistEntityValuesAllowed(arg, `["web01","unknown"]`, allowed))
+}
+
+func TestChecklistEntityValuesAllowedAcceptsJSONArrayWithEntityTitles(t *testing.T) {
+	entities.ClearEntitiesOfType("servers")
+	t.Cleanup(func() {
+		entities.ClearEntitiesOfType("servers")
+	})
+	entities.AddEntity("servers", "0", map[string]any{"name": "web01", "label": "Web Server One"})
+	entities.AddEntity("servers", "1", map[string]any{"name": "db01", "label": "Database One"})
+
+	arg := &config.ActionArgument{
+		Type:   "checklist",
+		Entity: "servers",
+		Choices: []config.ActionArgumentChoice{
+			{Title: "{{ servers.label }}", Value: "{{ servers.name }}"},
+		},
+	}
+	allowed := entityArgumentAllowedValues(arg)
+
+	assert.True(t, checklistEntityValuesAllowed(arg, `["Web Server One","Database One"]`, allowed))
+	assert.False(t, checklistEntityValuesAllowed(arg, `["Web Server One","unknown"]`, allowed))
+}
+
+func TestEntityArgumentValueAllowedAcceptsEntityChoiceTitle(t *testing.T) {
+	entities.ClearEntitiesOfType("servers")
+	t.Cleanup(func() {
+		entities.ClearEntitiesOfType("servers")
+	})
+	entities.AddEntity("servers", "0", map[string]any{"name": "web01", "label": "Web Server One"})
+
+	arg := &config.ActionArgument{
+		Entity: "servers",
+		Choices: []config.ActionArgumentChoice{
+			{Title: "{{ servers.label }}", Value: "{{ servers.name }}"},
+		},
+	}
+
+	assert.True(t, entityArgumentValueAllowed(arg, "Web Server One"))
+	assert.True(t, entityArgumentValueAllowed(arg, "web01"))
+	assert.False(t, entityArgumentValueAllowed(arg, "unknown"))
+}
+
+func TestStartActionRejectsMalformedMultiChoiceEntityArgument(t *testing.T) {
+	seedEntityArgumentGuessEntities(t)
+	cfg := buildEntityArgumentGuessConfig()
+	// After sanitize, force an invalid entity+multi-choice shape that would
+	// previously skip ACL and fall through to static UI choices.
+	cfg.Actions[0].Arguments[0].Choices = []config.ActionArgumentChoice{
+		{Title: "{{ servers.name }}", Value: "{{ servers.name }}"},
+		{Title: "web01", Value: "web01"},
+	}
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
+	defer ts.Close()
+
+	_, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
+		BindingId: "reboot-server",
+		Arguments: []*apiv1.StartActionArgument{
+			{Name: "target", Value: "web01"},
+		},
+	}))
+	require.Error(t, err)
+	assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
+}

+ 5 - 1
service/internal/api/api_entity_related_actions.go

@@ -54,7 +54,11 @@ func tryAppendRelatedCandidate(candidates *[]relatedActionCandidate, seen map[st
 }
 
 func bindingViewableForRelated(seen map[string]bool, api *oliveTinAPI, user *authpublic.AuthenticatedUser, binding *executor.ActionBinding) bool {
-	return binding != nil && binding.Action != nil && !seen[binding.ID] && api.userCanViewAction(user, binding.Action)
+	if binding == nil || binding.Action == nil || seen[binding.ID] || binding.Action.Hidden {
+		return false
+	}
+
+	return api.userCanViewAction(user, binding.Action)
 }
 
 func relatedPrefillForBinding(binding *executor.ActionBinding, entityType string, entity *entities.Entity) (map[string]string, bool) {

+ 273 - 0
service/internal/api/api_init_search_hints_test.go

@@ -0,0 +1,273 @@
+package api
+
+import (
+	"context"
+	"fmt"
+	"testing"
+
+	"connectrpc.com/connect"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/entities"
+	"github.com/OliveTin/OliveTin/internal/executor"
+)
+
+func TestInitIncludesEntitySearchHints(t *testing.T) {
+	entities.ClearEntitiesOfType("server")
+	entities.ClearEntitiesOfType("database")
+	t.Cleanup(func() {
+		entities.ClearEntitiesOfType("server")
+		entities.ClearEntitiesOfType("database")
+	})
+
+	entities.AddEntity("server", "0", map[string]any{
+		"name":   "web01",
+		"secret": "must-not-appear-in-search-hints",
+	})
+	entities.AddEntity("database", "db-1", map[string]any{
+		"title": "postgres",
+	})
+
+	cfg := config.DefaultConfig()
+	cfg.Features.HeaderSearch = true
+	cfg.Sanitize()
+
+	testExecutor := executor.DefaultExecutor(cfg)
+	testExecutor.RebuildActionMap()
+	testServer, client := getNewTestServerAndClientWithExecutor(cfg, testExecutor)
+	defer testServer.Close()
+
+	resp, err := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{}))
+	require.NoError(t, err)
+	require.NotNil(t, resp.Msg.Features)
+	assert.True(t, resp.Msg.Features.HeaderSearch)
+	require.NotNil(t, resp.Msg.SearchHints)
+
+	byKey := map[string]*apiv1.EntitySearchHint{}
+	for _, hint := range resp.Msg.SearchHints.Entities {
+		byKey[hint.Type+":"+hint.UniqueKey] = hint
+	}
+
+	host, ok := byKey["server:0"]
+	require.True(t, ok, "expected server:0 search hint")
+	assert.Equal(t, "web01", host.Title)
+	assert.Equal(t, "server", host.Type)
+	assert.Equal(t, "0", host.UniqueKey)
+
+	db, ok := byKey["database:db-1"]
+	require.True(t, ok, "expected database:db-1 search hint")
+	assert.Equal(t, "postgres", db.Title)
+}
+
+func TestInitOmitsSearchHintsWhenLoginRequired(t *testing.T) {
+	entities.ClearEntitiesOfType("server")
+	t.Cleanup(func() {
+		entities.ClearEntitiesOfType("server")
+	})
+
+	entities.AddEntity("server", "0", map[string]any{"name": "web01"})
+
+	cfg := config.DefaultConfig()
+	cfg.AuthRequireGuestsToLogin = true
+	cfg.Features.HeaderSearch = true
+	cfg.Sanitize()
+
+	testExecutor := executor.DefaultExecutor(cfg)
+	testExecutor.RebuildActionMap()
+	testServer, client := getNewTestServerAndClientWithExecutor(cfg, testExecutor)
+	defer testServer.Close()
+
+	resp, err := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{}))
+	require.NoError(t, err)
+	require.True(t, resp.Msg.LoginRequired)
+	assert.Nil(t, resp.Msg.SearchHints)
+}
+
+func TestInitOmitsSearchHintsWhenHeaderSearchDisabled(t *testing.T) {
+	entities.ClearEntitiesOfType("server")
+	t.Cleanup(func() {
+		entities.ClearEntitiesOfType("server")
+	})
+
+	entities.AddEntity("server", "0", map[string]any{"name": "web01"})
+
+	cfg := config.DefaultConfig()
+	cfg.Sanitize()
+	require.False(t, cfg.Features.HeaderSearch)
+
+	testExecutor := executor.DefaultExecutor(cfg)
+	testExecutor.RebuildActionMap()
+	testServer, client := getNewTestServerAndClientWithExecutor(cfg, testExecutor)
+	defer testServer.Close()
+
+	resp, err := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{}))
+	require.NoError(t, err)
+	require.NotNil(t, resp.Msg.Features)
+	assert.False(t, resp.Msg.Features.HeaderSearch)
+	assert.Nil(t, resp.Msg.SearchHints)
+}
+
+func TestBuildSearchHintsRespectsActionACL(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.DefaultPermissions.View = false
+	cfg.DefaultPermissions.Exec = false
+	cfg.Actions = []*config.Action{
+		{ID: "public_action", Title: "Public Action", Shell: "echo public"},
+		{ID: "secret_action", Title: "Secret Action", Shell: "echo secret", Acls: []string{"admins"}},
+	}
+	cfg.AccessControlLists = []*config.AccessControlList{
+		{
+			Name:             "everyone",
+			MatchUsernames:   []string{"guest", "admin"},
+			AddToEveryAction: false,
+			Permissions:      config.PermissionsList{View: true, Exec: true},
+		},
+		{
+			Name:           "admins",
+			MatchUsernames: []string{"admin"},
+			Permissions:    config.PermissionsList{View: true, Exec: true},
+		},
+	}
+	cfg.Actions[0].Acls = []string{"everyone"}
+	cfg.Sanitize()
+
+	testExecutor := executor.DefaultExecutor(cfg)
+	testExecutor.RebuildActionMap()
+	api := newServer(testExecutor)
+
+	guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
+	guest.BuildUserAcls(cfg)
+	admin := &authpublic.AuthenticatedUser{Username: "admin"}
+	admin.BuildUserAcls(cfg)
+
+	guestHints := api.buildSearchHints(guest)
+	require.NotNil(t, guestHints)
+
+	guestActionIDs := actionHintBindingIDs(guestHints.Actions)
+	assert.Contains(t, guestActionIDs, "public_action")
+	assert.NotContains(t, guestActionIDs, "secret_action")
+
+	adminHints := api.buildSearchHints(admin)
+	require.NotNil(t, adminHints)
+
+	adminActionIDs := actionHintBindingIDs(adminHints.Actions)
+	assert.Contains(t, adminActionIDs, "public_action")
+	assert.Contains(t, adminActionIDs, "secret_action")
+}
+
+func TestBuildSearchHintsOmitsHiddenActions(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.Actions = []*config.Action{
+		{ID: "visible", Title: "Visible", Shell: "echo visible"},
+		{ID: "hidden", Title: "Hidden", Shell: "echo hidden", Hidden: true},
+	}
+	cfg.Sanitize()
+
+	testExecutor := executor.DefaultExecutor(cfg)
+	testExecutor.RebuildActionMap()
+	api := newServer(testExecutor)
+
+	user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
+	user.BuildUserAcls(cfg)
+
+	hints := api.buildSearchHints(user)
+	require.NotNil(t, hints)
+
+	ids := actionHintBindingIDs(hints.Actions)
+	assert.Contains(t, ids, "visible")
+	assert.NotContains(t, ids, "hidden")
+}
+
+func TestBuildSearchHintsCapsActionsAndEntitiesPerType(t *testing.T) {
+	entities.ClearEntitiesOfType("cap_host")
+	entities.ClearEntitiesOfType("cap_container")
+	t.Cleanup(func() {
+		entities.ClearEntitiesOfType("cap_host")
+		entities.ClearEntitiesOfType("cap_container")
+	})
+
+	for entityIndex := 0; entityIndex < maxSearchHintEntitiesPerType+5; entityIndex++ {
+		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)})
+	}
+
+	cfg := config.DefaultConfig()
+	cfg.Entities = []*config.EntityFile{
+		{Name: "cap_host", File: "cap_host.yaml"},
+		{Name: "cap_container", File: "cap_container.yaml"},
+	}
+	cfg.Actions = make([]*config.Action, 0, maxSearchHintActions+5)
+	for actionIndex := 0; actionIndex < maxSearchHintActions+5; actionIndex++ {
+		cfg.Actions = append(cfg.Actions, &config.Action{
+			ID:    fmt.Sprintf("action-%03d", actionIndex),
+			Title: fmt.Sprintf("Action %03d", actionIndex),
+			Shell: "echo",
+		})
+	}
+	cfg.Sanitize()
+
+	testExecutor := executor.DefaultExecutor(cfg)
+	testExecutor.RebuildActionMap()
+	api := newServer(testExecutor)
+
+	user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
+	user.BuildUserAcls(cfg)
+
+	hints := api.buildSearchHints(user)
+	require.NotNil(t, hints)
+	assert.Len(t, hints.Actions, maxSearchHintActions)
+	assert.Equal(t, maxSearchHintEntitiesPerType, countEntityHintsByType(hints.Entities, "cap_host"))
+	assert.Equal(t, maxSearchHintEntitiesPerType, countEntityHintsByType(hints.Entities, "cap_container"))
+}
+
+func countEntityHintsByType(hints []*apiv1.EntitySearchHint, entityType string) int {
+	count := 0
+	for _, hint := range hints {
+		if hint.Type == entityType {
+			count++
+		}
+	}
+	return count
+}
+
+func TestBuildSearchHintsPrefersNonEntityActions(t *testing.T) {
+	entities.ClearEntitiesOfType("host")
+	t.Cleanup(func() {
+		entities.ClearEntitiesOfType("host")
+	})
+
+	for i := 0; i < 10; i++ {
+		entities.AddEntity("host", fmt.Sprintf("%d", i), map[string]any{"name": fmt.Sprintf("host-%d", i)})
+	}
+
+	cfg := config.DefaultConfig()
+	cfg.Actions = []*config.Action{
+		{ID: "plain", Title: "Plain Action", Shell: "echo plain"},
+		{Title: "Entity Action {{ name }}", Shell: "echo entity", Entity: "host"},
+	}
+	cfg.Sanitize()
+
+	testExecutor := executor.DefaultExecutor(cfg)
+	testExecutor.RebuildActionMap()
+	api := newServer(testExecutor)
+
+	user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
+	user.BuildUserAcls(cfg)
+
+	hints := api.buildSearchHints(user)
+	require.NotNil(t, hints)
+	require.NotEmpty(t, hints.Actions)
+	assert.Equal(t, "plain", hints.Actions[0].BindingId)
+}
+
+func actionHintBindingIDs(hints []*apiv1.ActionSearchHint) []string {
+	ids := make([]string, 0, len(hints))
+	for _, hint := range hints {
+		ids = append(ids, hint.BindingId)
+	}
+	return ids
+}

+ 170 - 0
service/internal/api/api_search_hints.go

@@ -0,0 +1,170 @@
+package api
+
+import (
+	"sort"
+
+	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
+	acl "github.com/OliveTin/OliveTin/internal/acl"
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	"github.com/OliveTin/OliveTin/internal/entities"
+	executor "github.com/OliveTin/OliveTin/internal/executor"
+	"github.com/OliveTin/OliveTin/internal/tpl"
+)
+
+const (
+	maxSearchHintActions         = 100
+	maxSearchHintEntitiesPerType = 50
+)
+
+// buildSearchHints returns lightweight search index data for Init.
+// Dashboards are not included; clients index Init.root_dashboard_entries.
+// Visibility matches action/entity view ACL; omitted entirely when login is required.
+func (api *oliveTinAPI) buildSearchHints(user *authpublic.AuthenticatedUser) *apiv1.SearchHints {
+	return &apiv1.SearchHints{
+		Entities: api.buildEntitySearchHints(user),
+		Actions:  api.buildActionSearchHints(user),
+	}
+}
+
+func (api *oliveTinAPI) buildEntitySearchHints(user *authpublic.AuthenticatedUser) []*apiv1.EntitySearchHint {
+	hintsByType := make(map[string][]*apiv1.EntitySearchHint)
+
+	for _, hint := range entities.ListSearchHints() {
+		if allowedHint := api.entitySearchHintIfAllowed(user, hint); allowedHint != nil {
+			hintsByType[allowedHint.Type] = appendBoundedEntitySearchHints(
+				hintsByType[allowedHint.Type],
+				allowedHint,
+				maxSearchHintEntitiesPerType,
+			)
+		}
+	}
+
+	entityTypes := make([]string, 0, len(hintsByType))
+	for entityType := range hintsByType {
+		entityTypes = append(entityTypes, entityType)
+	}
+	sort.Strings(entityTypes)
+
+	out := make([]*apiv1.EntitySearchHint, 0, len(entityTypes)*maxSearchHintEntitiesPerType)
+	for _, entityType := range entityTypes {
+		out = append(out, hintsByType[entityType]...)
+	}
+
+	return out
+}
+
+func appendBoundedEntitySearchHints(hints []*apiv1.EntitySearchHint, hint *apiv1.EntitySearchHint, limit int) []*apiv1.EntitySearchHint {
+	hints = append(hints, hint)
+	sortEntitySearchHints(hints)
+	if len(hints) > limit {
+		hints = hints[:limit]
+	}
+
+	return hints
+}
+
+func (api *oliveTinAPI) entitySearchHintIfAllowed(user *authpublic.AuthenticatedUser, hint entities.SearchHint) *apiv1.EntitySearchHint {
+	if hint.UniqueKey == "" || hint.Type == "" {
+		return nil
+	}
+
+	if !api.userCanViewEntityType(user, hint.Type) {
+		return nil
+	}
+
+	return &apiv1.EntitySearchHint{
+		Title:     hint.Title,
+		Type:      hint.Type,
+		UniqueKey: hint.UniqueKey,
+	}
+}
+
+func sortEntitySearchHints(hints []*apiv1.EntitySearchHint) {
+	sort.SliceStable(hints, func(leftIndex, rightIndex int) bool {
+		if hints[leftIndex].Type != hints[rightIndex].Type {
+			return hints[leftIndex].Type < hints[rightIndex].Type
+		}
+
+		if hints[leftIndex].UniqueKey != hints[rightIndex].UniqueKey {
+			return hints[leftIndex].UniqueKey < hints[rightIndex].UniqueKey
+		}
+
+		return hints[leftIndex].Title < hints[rightIndex].Title
+	})
+}
+
+func (api *oliveTinAPI) buildActionSearchHints(user *authpublic.AuthenticatedUser) []*apiv1.ActionSearchHint {
+	candidates := make([]actionSearchCandidate, 0, maxSearchHintActions)
+
+	api.executor.MapActionBindingsLock.RLock()
+	for _, binding := range api.executor.MapActionBindings {
+		if candidate, ok := actionSearchCandidateFromBinding(api, user, binding); ok {
+			candidates = appendBoundedActionSearchCandidates(candidates, candidate, maxSearchHintActions)
+		}
+	}
+	api.executor.MapActionBindingsLock.RUnlock()
+
+	out := make([]*apiv1.ActionSearchHint, 0, len(candidates))
+	for _, candidate := range candidates {
+		out = append(out, &apiv1.ActionSearchHint{
+			Title:     candidate.title,
+			BindingId: candidate.bindingID,
+		})
+	}
+
+	return out
+}
+
+func appendBoundedActionSearchCandidates(candidates []actionSearchCandidate, candidate actionSearchCandidate, limit int) []actionSearchCandidate {
+	candidates = append(candidates, candidate)
+	sortActionSearchCandidates(candidates)
+	if len(candidates) > limit {
+		candidates = candidates[:limit]
+	}
+
+	return candidates
+}
+
+type actionSearchCandidate struct {
+	title     string
+	bindingID string
+	hasEntity bool
+}
+
+func actionSearchCandidateFromBinding(api *oliveTinAPI, user *authpublic.AuthenticatedUser, binding *executor.ActionBinding) (actionSearchCandidate, bool) {
+	if !isSearchableActionBinding(binding) {
+		return actionSearchCandidate{}, false
+	}
+
+	if !acl.IsAllowedView(api.cfg, user, binding.Action) {
+		return actionSearchCandidate{}, false
+	}
+
+	if !api.bindingEntityTypeAllowed(user, binding) {
+		return actionSearchCandidate{}, false
+	}
+
+	return actionSearchCandidate{
+		title:     tpl.ParseTemplateOfActionBeforeExec(binding.Action.Title, binding.Entity),
+		bindingID: binding.ID,
+		hasEntity: binding.Entity != nil,
+	}, true
+}
+
+func isSearchableActionBinding(binding *executor.ActionBinding) bool {
+	return binding != nil && binding.Action != nil && binding.ID != "" && !binding.Action.Hidden
+}
+
+func sortActionSearchCandidates(candidates []actionSearchCandidate) {
+	sort.SliceStable(candidates, func(leftIndex, rightIndex int) bool {
+		if candidates[leftIndex].hasEntity != candidates[rightIndex].hasEntity {
+			return !candidates[leftIndex].hasEntity
+		}
+
+		if candidates[leftIndex].title != candidates[rightIndex].title {
+			return candidates[leftIndex].title < candidates[rightIndex].title
+		}
+
+		return candidates[leftIndex].bindingID < candidates[rightIndex].bindingID
+	})
+}

+ 46 - 1
service/internal/api/api_test.go

@@ -632,6 +632,42 @@ func TestViewPermissionAllowedSeesAction(t *testing.T) {
 	assert.Equal(t, "secret_action", resp.Action.BindingId)
 }
 
+// TestGetActionBindingAllowsHiddenWhenViewAllowed asserts that hidden is not a security control:
+// users with view permission can fetch action details for hidden actions (e.g. webhook helpers).
+func TestGetActionBindingAllowsHiddenWhenViewAllowed(t *testing.T) {
+	cfg, lowUser, adminUser := buildViewPermissionTestConfig(t)
+	cfg.Actions = append(cfg.Actions, &config.Action{
+		ID:     "webhook_helper",
+		Title:  "Webhook Helper",
+		Shell:  "echo webhook",
+		Hidden: true,
+	})
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	api := newServer(ex)
+
+	rr := &DashboardRenderRequest{
+		AuthenticatedUser: adminUser,
+		cfg:               cfg,
+		ex:                ex,
+	}
+	db := buildDefaultDashboard(rr)
+	assert.NotContains(t, bindingIdsInDashboardContents(db.Contents), "webhook_helper",
+		"hidden actions must stay off the default dashboard")
+
+	resp, err := api.getActionBindingResponse(adminUser, "webhook_helper")
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	require.NotNil(t, resp.Action)
+	assert.Equal(t, "webhook_helper", resp.Action.BindingId)
+	assert.Equal(t, "Webhook Helper", resp.Action.Title)
+
+	_, err = api.getActionBindingResponse(lowUser, "webhook_helper")
+	require.Error(t, err)
+	assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err),
+		"users without view ACL must still be denied for hidden actions")
+}
+
 // TestViewPermissionExcludedFromCustomDashboard (issue #921) asserts that when a custom dashboard
 // lists an action by title, users without view permission do not see that action (title or icon).
 func TestViewPermissionExcludedFromCustomDashboard(t *testing.T) {
@@ -1055,6 +1091,15 @@ func TestBuildChoicesExpandsChecklistEntityChoices(t *testing.T) {
 		entities.ClearEntitiesOfType("room")
 	})
 
+	cfg := config.DefaultConfig()
+	cfg.Entities = []*config.EntityFile{
+		{Name: "room", File: "room.yaml"},
+	}
+	cfg.Sanitize()
+
+	user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
+	user.BuildUserAcls(cfg)
+
 	arg := config.ActionArgument{
 		Type:   "checklist",
 		Entity: "room",
@@ -1063,7 +1108,7 @@ func TestBuildChoicesExpandsChecklistEntityChoices(t *testing.T) {
 		},
 	}
 
-	choices := buildChoices(arg)
+	choices := buildChoices(arg, &DashboardRenderRequest{AuthenticatedUser: user, cfg: cfg})
 	require.Len(t, choices, 2)
 	assert.Equal(t, "attic", choices[0].Value)
 	assert.Equal(t, "attic", choices[0].Title)

+ 5 - 0
service/internal/api/dashboard_entities.go

@@ -2,6 +2,7 @@ package api
 
 import (
 	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
+	acl "github.com/OliveTin/OliveTin/internal/acl"
 	config "github.com/OliveTin/OliveTin/internal/config"
 	entities "github.com/OliveTin/OliveTin/internal/entities"
 	"github.com/OliveTin/OliveTin/internal/tpl"
@@ -9,6 +10,10 @@ import (
 )
 
 func buildEntityFieldsets(entityTitle string, tpl *config.DashboardComponent, rr *DashboardRenderRequest) []*apiv1.DashboardComponent {
+	if !acl.IsAllowedViewEntityType(rr.cfg, rr.AuthenticatedUser, entityFileForType(rr.cfg, entityTitle)) {
+		return nil
+	}
+
 	ret := make([]*apiv1.DashboardComponent, 0)
 
 	orderedEntities := entities.GetEntityInstancesOrdered(entityTitle)

+ 46 - 24
service/internal/api/dashboards.go

@@ -8,6 +8,7 @@ import (
 	acl "github.com/OliveTin/OliveTin/internal/acl"
 	config "github.com/OliveTin/OliveTin/internal/config"
 	entities "github.com/OliveTin/OliveTin/internal/entities"
+	"github.com/OliveTin/OliveTin/internal/executor"
 	"github.com/OliveTin/OliveTin/internal/tpl"
 	log "github.com/sirupsen/logrus"
 	"slices"
@@ -26,6 +27,10 @@ func getEntityFromRequest(rr *DashboardRenderRequest) *entities.Entity {
 		return nil
 	}
 
+	if !acl.IsAllowedViewEntityType(rr.cfg, rr.AuthenticatedUser, entityFileForType(rr.cfg, rr.EntityType)) {
+		return nil
+	}
+
 	entityInstances := entities.GetEntityInstances(rr.EntityType)
 	if entity, ok := entityInstances[rr.EntityKey]; ok {
 		return entity
@@ -131,7 +136,6 @@ func buildDashboardFromConfigWithEntity(dashboard *config.DashboardComponent, rr
 	}
 }
 
-//gocyclo:ignore
 func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
 	db := &apiv1.Dashboard{
 		Title:    "Actions",
@@ -145,33 +149,13 @@ func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
 	}
 
 	for _, binding := range rr.ex.MapActionBindings {
-		if binding == nil || binding.Action == nil || binding.Action.Hidden {
-			continue
-		}
-
-		if binding.IsOnConfiguredDashboard() {
-			continue
-		}
-
-		if !acl.IsAllowedView(rr.cfg, rr.AuthenticatedUser, binding.Action) {
-			continue
-		}
-
-		action := buildAction(binding, rr)
-		if action == nil {
+		if !defaultBindingEligibleForDashboard(rr, binding) {
 			continue
 		}
 
-		comp := &apiv1.DashboardComponent{
-			Type:   "link",
-			Title:  action.Title,
-			Icon:   action.Icon,
-			Action: action,
-		}
-		if binding.Entity != nil {
-			comp.EntityKey = binding.Entity.UniqueKey
+		if comp := defaultDashboardComponentFromBinding(binding, rr); comp != nil {
+			fieldset.Contents = append(fieldset.Contents, comp)
 		}
-		fieldset.Contents = append(fieldset.Contents, comp)
 	}
 
 	if len(fieldset.Contents) > 0 {
@@ -182,6 +166,44 @@ func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
 	return db
 }
 
+func defaultBindingEligibleForDashboard(rr *DashboardRenderRequest, binding *executor.ActionBinding) bool {
+	return defaultBindingWellFormed(binding) &&
+		!binding.IsOnConfiguredDashboard() &&
+		acl.IsAllowedView(rr.cfg, rr.AuthenticatedUser, binding.Action) &&
+		defaultBindingEntityTypeAllowed(rr, binding)
+}
+
+func defaultBindingWellFormed(binding *executor.ActionBinding) bool {
+	return binding != nil && binding.Action != nil && !binding.Action.Hidden
+}
+
+func defaultBindingEntityTypeAllowed(rr *DashboardRenderRequest, binding *executor.ActionBinding) bool {
+	if binding.Entity == nil || binding.Action.Entity == "" {
+		return true
+	}
+
+	return acl.IsAllowedViewEntityType(rr.cfg, rr.AuthenticatedUser, entityFileForType(rr.cfg, binding.Action.Entity))
+}
+
+func defaultDashboardComponentFromBinding(binding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
+	action := buildAction(binding, rr)
+	if action == nil {
+		return nil
+	}
+
+	comp := &apiv1.DashboardComponent{
+		Type:   "link",
+		Title:  action.Title,
+		Icon:   action.Icon,
+		Action: action,
+	}
+	if binding.Entity != nil {
+		comp.EntityKey = binding.Entity.UniqueKey
+	}
+
+	return comp
+}
+
 func entityKeyLess(a, b string) bool {
 	ai, errA := strconv.ParseInt(a, 10, 64)
 	bi, errB := strconv.ParseInt(b, 10, 64)

+ 7 - 0
service/internal/config/config.go

@@ -112,6 +112,7 @@ type EntityFile struct {
 	Icon       string           `koanf:"icon"`
 	SourceFile string           `koanf:"-"`
 	Properties []EntityProperty `koanf:"properties"`
+	Acls       []string         `koanf:"acls"`
 }
 
 // EntityProperty defines a column shown when listing entity instances in the UI.
@@ -145,6 +146,11 @@ type ConfigurationPolicy struct {
 	ShowVersionNumber bool `koanf:"showVersionNumber"`
 }
 
+// FeaturesConfig holds global opt-in feature flags. New flags default to false.
+type FeaturesConfig struct {
+	HeaderSearch bool `koanf:"headerSearch"`
+}
+
 type PrometheusConfig struct {
 	Enabled          bool `koanf:"enabled"`
 	DefaultGoMetrics bool `koanf:"defaultGoMetrics"`
@@ -228,6 +234,7 @@ type Config struct {
 	ShowFooter                         bool                 `koanf:"showFooter"`
 	UseSingleHTTPFrontend              bool                 `koanf:"useSingleHTTPFrontend"`
 	ThemeCacheDisabled                 bool                 `koanf:"themeCacheDisabled"`
+	Features                           FeaturesConfig       `koanf:"features"`
 }
 
 type AuthLocalUsersConfig struct {

+ 40 - 0
service/internal/config/entity_argument_choices_test.go

@@ -0,0 +1,40 @@
+package config
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestValidateEntityArgumentChoicesForArgument(t *testing.T) {
+	assert.NoError(t, validateEntityArgumentChoicesForArgument("Reboot", ActionArgument{
+		Name:   "target",
+		Entity: "servers",
+		Choices: []ActionArgumentChoice{
+			{Value: "{{ servers.name }}"},
+		},
+	}))
+
+	assert.Error(t, validateEntityArgumentChoicesForArgument("Reboot", ActionArgument{
+		Name:   "target",
+		Entity: "servers",
+		Choices: []ActionArgumentChoice{
+			{Value: "{{ servers.name }}"},
+			{Value: "web01"},
+		},
+	}))
+
+	assert.Error(t, validateEntityArgumentChoicesForArgument("Reboot", ActionArgument{
+		Name:    "target",
+		Entity:  "servers",
+		Choices: nil,
+	}))
+
+	assert.NoError(t, validateEntityArgumentChoicesForArgument("Reboot", ActionArgument{
+		Name: "plain",
+		Choices: []ActionArgumentChoice{
+			{Value: "a"},
+			{Value: "b"},
+		},
+	}))
+}

+ 40 - 0
service/internal/config/sanitize.go

@@ -40,6 +40,10 @@ func (cfg *Config) Sanitize() {
 	if err := cfg.validateChecklistChoiceValues(); err != nil {
 		log.Fatalf("%v", err)
 	}
+
+	if err := cfg.validateEntityArgumentChoices(); err != nil {
+		log.Fatalf("%v", err)
+	}
 }
 
 func (cfg *Config) validateReservedActionArgumentNames() error {
@@ -108,6 +112,42 @@ func validateChecklistChoicesForArgument(actionTitle string, arg ActionArgument)
 	return nil
 }
 
+func (cfg *Config) validateEntityArgumentChoices() error {
+	for _, action := range cfg.Actions {
+		if err := action.validateEntityArgumentChoices(); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (action *Action) validateEntityArgumentChoices() error {
+	if action == nil {
+		return nil
+	}
+
+	for _, arg := range action.Arguments {
+		if err := validateEntityArgumentChoicesForArgument(action.Title, arg); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func validateEntityArgumentChoicesForArgument(actionTitle string, arg ActionArgument) error {
+	if arg.Entity == "" || len(arg.Choices) == 1 {
+		return nil
+	}
+
+	return fmt.Errorf(
+		`action %q argument %q with entity must define exactly one choice template`,
+		actionTitle,
+		arg.Name,
+	)
+}
+
 func (cfg *Config) sanitizeDashboardsForInlineActions() {
 	for _, dashboard := range cfg.Dashboards {
 		cfg.sanitizeDashboardComponentForInlineActions(dashboard)

+ 96 - 9
service/internal/configcheck/rebuild.go

@@ -28,10 +28,12 @@ func Rebuild(cfg *config.Config, extra ...configissues.Issue) {
 	collected := make([]configissues.Issue, 0)
 	collected = append(collected, configissues.CopySticky()...)
 	collected = append(collected, collectActionGroupIssues(cfg)...)
+	collected = append(collected, collectAclReferenceIssues(cfg)...)
 	collected = append(collected, collectArgumentIssues(cfg)...)
 	collected = append(collected, collectIncludeIssues(cfg)...)
 	collected = append(collected, collectTemplateParseIssues(cfg)...)
 	collected = append(collected, collectEntityFileIssues(cfg)...)
+	collected = append(collected, collectOrphanEntityTypeIssues(cfg)...)
 	collected = append(collected, collectEntityEmptyIssues(cfg)...)
 	collected = append(collected, collectCronIssues(cfg)...)
 	collected = append(collected, collectWatcherPathIssues(cfg)...)
@@ -80,6 +82,55 @@ func actionIssue(action *config.Action, severity, code, message, source, argName
 	}
 }
 
+func collectAclReferenceIssues(cfg *config.Config) []configissues.Issue {
+	out := make([]configissues.Issue, 0)
+	out = append(out, collectActionAclIssues(cfg)...)
+	out = append(out, collectEntityAclIssues(cfg)...)
+	return out
+}
+
+func collectActionAclIssues(cfg *config.Config) []configissues.Issue {
+	out := make([]configissues.Issue, 0)
+	for _, action := range cfg.Actions {
+		if action == nil {
+			continue
+		}
+		for _, aclName := range action.Acls {
+			out = append(out, unknownAclIssue(cfg, aclName, action.ID, action.Title, action.SourceFile)...)
+		}
+	}
+	return out
+}
+
+func collectEntityAclIssues(cfg *config.Config) []configissues.Issue {
+	out := make([]configissues.Issue, 0)
+	for _, entityFile := range cfg.Entities {
+		if entityFile == nil {
+			continue
+		}
+		for _, aclName := range entityFile.Acls {
+			out = append(out, unknownAclIssue(cfg, aclName, "", entityFile.Name, entityFile.SourceFile)...)
+		}
+	}
+	return out
+}
+
+func unknownAclIssue(cfg *config.Config, aclName, actionID, title, configFile string) []configissues.Issue {
+	if cfg.FindAcl(aclName) != nil {
+		return nil
+	}
+
+	return []configissues.Issue{{
+		Severity:    configissues.SeverityError,
+		Code:        configissues.CodeAclUnknown,
+		Message:     fmt.Sprintf("References unknown ACL %q", aclName),
+		ActionID:    actionID,
+		ActionTitle: title,
+		Source:      aclName,
+		ConfigFile:  configFile,
+	}}
+}
+
 func collectArgumentIssues(cfg *config.Config) []configissues.Issue {
 	out := make([]configissues.Issue, 0)
 	for _, action := range cfg.Actions {
@@ -94,13 +145,13 @@ func collectArgumentIssues(cfg *config.Config) []configissues.Issue {
 }
 
 func argumentIssues(action *config.Action, arg *config.ActionArgument) []configissues.Issue {
-	if arg.Type != "checklist" {
-		return nil
+	out := make([]configissues.Issue, 0)
+	out = append(out, entityArgumentChoicesIssue(action, arg)...)
+
+	if arg.Type == "checklist" {
+		out = append(out, checklistNoChoicesIssue(action, arg)...)
 	}
 
-	out := make([]configissues.Issue, 0)
-	out = append(out, checklistNoChoicesIssue(action, arg)...)
-	out = append(out, checklistEntityChoicesIssue(action, arg)...)
 	return out
 }
 
@@ -113,13 +164,13 @@ func checklistNoChoicesIssue(action *config.Action, arg *config.ActionArgument)
 		"Checklist argument has no choices defined", "", arg.Name)}
 }
 
-func checklistEntityChoicesIssue(action *config.Action, arg *config.ActionArgument) []configissues.Issue {
-	if arg.Entity == "" || len(arg.Choices) <= 1 {
+func entityArgumentChoicesIssue(action *config.Action, arg *config.ActionArgument) []configissues.Issue {
+	if arg.Entity == "" || len(arg.Choices) == 1 {
 		return nil
 	}
 
-	return []configissues.Issue{actionIssue(action, configissues.SeverityWarning, configissues.CodeChecklistEntityChoices,
-		"Checklist argument with entity should define exactly one choice template", arg.Entity, arg.Name)}
+	return []configissues.Issue{actionIssue(action, configissues.SeverityError, configissues.CodeEntityArgumentChoices,
+		"Arguments with entity must define exactly one choice template", arg.Entity, arg.Name)}
 }
 
 func collectIncludeIssues(cfg *config.Config) []configissues.Issue {
@@ -215,6 +266,42 @@ func collectEntityFileIssues(cfg *config.Config) []configissues.Issue {
 	return out
 }
 
+func collectOrphanEntityTypeIssues(cfg *config.Config) []configissues.Issue {
+	configured := configuredEntityTypeNames(cfg)
+	out := make([]configissues.Issue, 0)
+
+	for entityType := range entities.GetEntities() {
+		if configured[entityType] {
+			continue
+		}
+
+		out = append(out, configissues.Issue{
+			Severity: configissues.SeverityWarning,
+			Code:     configissues.CodeEntityTypeUnconfigured,
+			Message:  fmt.Sprintf("Entity type %q is loaded but has no matching entities entry in configuration", entityType),
+			Source:   entityType,
+		})
+	}
+
+	return out
+}
+
+func configuredEntityTypeNames(cfg *config.Config) map[string]bool {
+	configured := make(map[string]bool)
+	if cfg == nil {
+		return configured
+	}
+
+	for _, entityFile := range cfg.Entities {
+		if entityFile == nil || entityFile.Name == "" {
+			continue
+		}
+		configured[entityFile.Name] = true
+	}
+
+	return configured
+}
+
 func entityFileIssuesFor(ef *config.EntityFile, baseDir string) []configissues.Issue {
 	if ef == nil || ef.File == "" {
 		return nil

+ 90 - 0
service/internal/configcheck/rebuild_test.go

@@ -191,6 +191,96 @@ func TestStickyEnvUnsetSurvivesRebuild(t *testing.T) {
 	assert.True(t, hasCode(configissues.List(), configissues.CodeEnvUnset))
 }
 
+func TestRebuildWarnsForEntityTypeWithoutConfigEntry(t *testing.T) {
+	entities.ClearEntitiesOfType("orphan_type")
+	t.Cleanup(func() {
+		entities.ClearEntitiesOfType("orphan_type")
+	})
+
+	entities.AddEntity("orphan_type", "0", map[string]any{"name": "lonely"})
+
+	configissues.BeginConfigLoad()
+	cfg := config.DefaultConfig()
+	cfg.Entities = nil
+	configcheck.Rebuild(cfg)
+
+	issues := configissues.List()
+	require.True(t, hasCode(issues, configissues.CodeEntityTypeUnconfigured))
+	found := false
+	for _, issue := range issues {
+		if issue.Code == configissues.CodeEntityTypeUnconfigured {
+			assert.Equal(t, configissues.SeverityWarning, issue.Severity)
+			assert.Equal(t, "orphan_type", issue.Source)
+			found = true
+		}
+	}
+	assert.True(t, found)
+}
+
+func TestRebuildEntityArgumentChoices(t *testing.T) {
+	configissues.BeginConfigLoad()
+	cfg := config.DefaultConfig()
+	cfg.Actions = []*config.Action{
+		{
+			Title: "Reboot",
+			ID:    "reboot",
+			Arguments: []config.ActionArgument{
+				{
+					Name:   "target",
+					Entity: "servers",
+					Choices: []config.ActionArgumentChoice{
+						{Value: "{{ servers.name }}"},
+						{Value: "web01"},
+					},
+				},
+			},
+		},
+	}
+
+	configcheck.Rebuild(cfg)
+
+	issues := configissues.List()
+	require.True(t, hasCode(issues, configissues.CodeEntityArgumentChoices))
+	for _, issue := range issues {
+		if issue.Code == configissues.CodeEntityArgumentChoices {
+			assert.Equal(t, configissues.SeverityError, issue.Severity)
+			assert.Equal(t, "target", issue.ArgumentName)
+		}
+	}
+}
+
+func TestRebuildUnknownAclReferences(t *testing.T) {
+	configissues.BeginConfigLoad()
+	cfg := config.DefaultConfig()
+	cfg.AccessControlLists = []*config.AccessControlList{
+		{Name: "ops", MatchUsernames: []string{"admin"}, Permissions: config.PermissionsList{View: true}},
+	}
+	cfg.Actions = []*config.Action{
+		{Title: "Restart", ID: "restart", Acls: []string{"ops"}},
+		{Title: "Secret", ID: "secret", Acls: []string{"missing-acl"}},
+	}
+	cfg.Entities = []*config.EntityFile{
+		{Name: "printers", File: "printers.yaml"},
+		{Name: "servers", File: "servers.yaml", Acls: []string{"missing-entity-acl"}},
+	}
+
+	configcheck.Rebuild(cfg)
+
+	issues := configissues.List()
+	require.True(t, hasCode(issues, configissues.CodeAclUnknown))
+
+	unknownSources := make([]string, 0)
+	for _, issue := range issues {
+		if issue.Code == configissues.CodeAclUnknown {
+			assert.Equal(t, configissues.SeverityError, issue.Severity)
+			unknownSources = append(unknownSources, issue.Source)
+		}
+	}
+	assert.Contains(t, unknownSources, "missing-acl")
+	assert.Contains(t, unknownSources, "missing-entity-acl")
+	assert.NotContains(t, unknownSources, "ops")
+}
+
 func hasCode(issues []configissues.Issue, code string) bool {
 	for _, issue := range issues {
 		if issue.Code == code {

+ 3 - 1
service/internal/configissues/issue.go

@@ -8,16 +8,18 @@ const (
 	CodeActionGroupUnenforced  = "action_group_unenforced"
 	CodeArgTypeUnset           = "arg_type_unset"
 	CodeChecklistNoChoices     = "checklist_no_choices"
-	CodeChecklistEntityChoices = "checklist_entity_choices"
+	CodeEntityArgumentChoices  = "entity_argument_choices"
 	CodeEnvUnset               = "env_unset"
 	CodeIncludeMissing         = "include_missing"
 	CodeTemplateParse          = "template_parse"
 	CodeArgDefaultInvalid      = "arg_default_invalid"
 	CodeEntityFile             = "entity_file"
 	CodeEntityEmpty            = "entity_empty"
+	CodeEntityTypeUnconfigured = "entity_type_unconfigured"
 	CodeCronInvalid            = "cron_invalid"
 	CodeCronEntityBinding      = "cron_entity_binding"
 	CodeWatcherPath            = "watcher_path"
+	CodeAclUnknown             = "acl_unknown"
 )
 
 // Issue is a configuration warning or error surfaced on Diagnostics.

+ 29 - 0
service/internal/entities/search_hints.go

@@ -0,0 +1,29 @@
+package entities
+
+type SearchHint struct {
+	Title     string
+	Type      string
+	UniqueKey string
+}
+
+func ListSearchHints() []SearchHint {
+	rwmutex.RLock()
+	defer rwmutex.RUnlock()
+
+	hints := make([]SearchHint, 0)
+	for entityType, instances := range entities {
+		for _, entity := range instances {
+			if entity == nil {
+				continue
+			}
+
+			hints = append(hints, SearchHint{
+				Title:     entity.Title,
+				Type:      entityType,
+				UniqueKey: entity.UniqueKey,
+			})
+		}
+	}
+
+	return hints
+}

+ 42 - 0
service/internal/entities/search_hints_test.go

@@ -0,0 +1,42 @@
+package entities
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestListSearchHintsReturnsLightweightIdentities(testContext *testing.T) {
+	ClearEntitiesOfType("search_hint_host")
+	ClearEntitiesOfType("search_hint_app")
+	testContext.Cleanup(func() {
+		ClearEntitiesOfType("search_hint_host")
+		ClearEntitiesOfType("search_hint_app")
+	})
+
+	AddEntity("search_hint_host", "0", map[string]any{
+		"name":     "web01",
+		"secret":   "should-not-be-copied-by-search-hints",
+		"hostname": "192.168.1.10",
+	})
+	AddEntity("search_hint_app", "app-1", map[string]any{
+		"title": "Frontend",
+	})
+
+	hints := ListSearchHints()
+	byKey := map[string]SearchHint{}
+	for _, hint := range hints {
+		byKey[hint.Type+":"+hint.UniqueKey] = hint
+	}
+
+	host, hostFound := byKey["search_hint_host:0"]
+	require.True(testContext, hostFound)
+	assert.Equal(testContext, "web01", host.Title)
+	assert.Equal(testContext, "search_hint_host", host.Type)
+	assert.Equal(testContext, "0", host.UniqueKey)
+
+	app, appFound := byKey["search_hint_app:app-1"]
+	require.True(testContext, appFound)
+	assert.Equal(testContext, "Frontend", app.Title)
+}

+ 5 - 1
service/internal/executor/arguments.go

@@ -300,6 +300,10 @@ func typecheckChoice(value string, arg *config.ActionArgument) error {
 }
 
 func typecheckChoiceEntity(value string, arg *config.ActionArgument) error {
+	if len(arg.Choices) != 1 {
+		return fmt.Errorf("entity-backed argument must define exactly one choice template")
+	}
+
 	templateChoice := arg.Choices[0].Value
 
 	for _, ent := range entities.GetEntityInstances(arg.Entity) {
@@ -613,7 +617,7 @@ func mangleChoiceSegment(arg *config.ActionArgument, value string, actionTitle s
 }
 
 func mangleChoiceSegmentEntity(arg *config.ActionArgument, value string, actionTitle string) (string, bool) {
-	if arg.Entity == "" || len(arg.Choices) == 0 {
+	if arg.Entity == "" || len(arg.Choices) != 1 {
 		return value, false
 	}
 

+ 88 - 0
specs/entity-acls.md

@@ -0,0 +1,88 @@
+# Spec: Entity access control
+
+This spec describes how OliveTin restricts which entity types a user may see, and how that interacts with entity-related actions.
+
+---
+
+## 1. Scope
+
+Access control applies at the **entity type** level (each configured entity definition), not per instance.
+
+- Instances remain data loaded from entity files.
+- Only the ability to **view** an entity type is consulted for these rules.
+- Separate action permissions (view, execute, logs, kill) continue to govern actions themselves.
+
+---
+
+## 2. Configuration
+
+Each entity definition may list zero or more named access-control entries.
+
+- If the list is omitted or empty, the entity type is **unrestricted**: any user who may use the dashboard UI can see that type (same rule as root dashboards with no access-control list).
+- If one or more named entries are listed, access is an allow list: a matching entry that grants view, otherwise the installation’s default view permission.
+- Marking an access-control entry as applying to every action does not apply to entity types. An entry must be listed on the entity definition to restrict it.
+
+---
+
+## 3. Listing and details
+
+When listing entity types and instances:
+
+- Types the user cannot view are omitted from the list. The response does not advertise that those types exist.
+- A request for a single entity whose type the user cannot view is treated as not found (the type is not confirmed to exist).
+
+The Entities navigation page remains available; it simply shows fewer (or no) types when some are restricted.
+
+---
+
+## 4. Search hints
+
+Client search hints for entities include only instances of types the user may view.
+
+Entity-bound action hints (actions generated per entity instance) appear only when the user may view both the action and the entity type. Action view alone is not enough if the entity type is restricted.
+
+Search hints are omitted from the initial client bootstrap when guests must log in, when header search is disabled for the installation, and the header search control is not shown until login is no longer required and header search is enabled.
+
+Hints are capped at 100 actions and 50 entity instances **per entity type** per bootstrap response. The client applies the same caps when indexing.
+
+Dashboards are not included in search hints. Clients build the dashboard search index from the bootstrap root dashboard entries (already filtered by dashboard access control).
+
+Entity types with no access-control list remain unrestricted for search and listing.
+
+---
+
+## 5. Entity-related actions
+
+There are two shapes of related actions on an entity details page:
+
+1. Actions bound to the entity type (one binding per instance).
+2. Unbound actions that prefill arguments from the entity.
+
+Rules:
+
+- Opening the entity details page requires view on that entity type.
+- Seeing or running a related action still requires the action’s own permissions.
+- Entity type access does not replace action permissions.
+
+---
+
+## 6. Dashboards and argument forms
+
+When a dashboard expands an entity fieldset, instances of types the user cannot view are not rendered.
+
+When an action argument draws choices from an entity type, it must define **exactly one** choice template and name the entity type. OliveTin expands that template per instance. Only instances of types the user may view are included. Users who can view the action but not the entity type must not learn instance names from the argument form.
+
+Arguments that name an entity type with zero or multiple choice templates are invalid configuration: startup/reload rejects them, Diagnostics reports an error, the argument form shows no choices, and start/validate requests are rejected.
+
+Starting or validating an action rejects entity-backed argument values when:
+
+- The user may not view that entity type (permission denied), or
+- The value is not one of the entity-expanded choice values for that argument (invalid argument).
+
+Guessing an instance name must not bypass entity type access control.
+
+---
+
+## 7. Compatibility
+
+Existing entity definitions without access-control lists stay unrestricted. Setting the default view permission to false alone does not hide unrestricted entity types; operators must list access-control entries on entity definitions to lock them down.

+ 66 - 0
specs/feature-flags.md

@@ -0,0 +1,66 @@
+# Spec: Feature flags
+
+This spec describes how OliveTin opts into unfinished or optional product features for the whole installation.
+
+---
+
+## 1. Scope
+
+Feature flags are **global** settings. They apply to every user and are not overridden by access control lists or policy.
+
+They are distinct from policy options such as diagnostics or log list visibility, which may differ per user.
+
+All feature-flagged functionality is **alpha / experimental**. Flags default to off. Private security reports for vulnerabilities that affect enabled experimental functionality are accepted under the project security policy.
+
+---
+
+## 2. Configuration
+
+Operators configure flags in a dedicated section of the installation configuration.
+
+- Each flag is a boolean.
+- Omitted flags are **false**.
+- Enabling a feature requires setting it to true explicitly.
+
+Unknown keys in that section are ignored by the configuration loader (same as other unknown config keys).
+
+---
+
+## 3. Bootstrap projection
+
+Every client bootstrap response includes the current feature flag values.
+
+Clients must not assume a missing flag means enabled. Treat absent or false as off.
+
+---
+
+## 4. Server and client behavior
+
+When a feature is off:
+
+- The server skips work that exists only to support that feature (for example, building search hints when header search is off).
+- The web UI hides controls and does not index or call APIs that exist only for that feature.
+
+When a feature is on, normal access control still applies to the data and actions the feature exposes. Operators who enable a flag accept that the surface is experimental.
+
+---
+
+## 5. Header search
+
+A header search flag controls the header search control and bootstrap search hints.
+
+- Default: false (alpha / experimental).
+- When false: bootstrap omits search hints; the client does not show header search or populate the search index.
+- When true: bootstrap includes access-control-filtered search hints (unless guests must log in); the client shows header search after login is satisfied.
+
+---
+
+## 6. Adding a new flag
+
+To add a flag:
+
+1. Add a boolean setting in the feature-flags configuration section (default false).
+2. Expose the same setting on every client bootstrap response.
+3. Gate server work and UI behind the flag.
+4. Document the flag as alpha / experimental and update this spec’s feature list.
+5. Keep the security policy aligned: private reports remain accepted for enabled experimental functionality until the feature graduates (flag removed or stable default-on).

Некоторые файлы не были показаны из-за большого количества измененных файлов