Răsfoiți Sursa

feat: Alpha support for search (behind feature gate)

jamesread 1 săptămână în urmă
părinte
comite
5741c8ea2c
46 a modificat fișierele cu 2933 adăugiri și 469 ștergeri
  1. 5 4
      .github/SECURITY_ADVISORY_DUPLICATES.md
  2. 12 0
      SECURITY.md
  3. 1 1
      docs/modules/ROOT/pages/action_customization/hidden.adoc
  4. 17 0
      docs/modules/ROOT/pages/advanced_configuration/webui.adoc
  5. 2 0
      docs/modules/ROOT/pages/args/input_checklist.adoc
  6. 2 0
      docs/modules/ROOT/pages/args/input_dropdown.adoc
  7. 1 0
      docs/modules/ROOT/pages/config.adoc
  8. 4 0
      docs/modules/ROOT/pages/entities/intro.adoc
  9. 52 0
      docs/modules/ROOT/pages/security/acl.adoc
  10. 1 0
      docs/modules/ROOT/pages/security/design_choices.adoc
  11. 207 292
      frontend/package-lock.json
  12. 4 4
      frontend/package.json
  13. 105 0
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts
  14. 0 0
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js
  15. 38 3
      frontend/resources/vue/App.vue
  16. 228 0
      frontend/resources/vue/stores/searchIndex.js
  17. 197 0
      frontend/resources/vue/stores/searchIndex.test.mjs
  18. 0 1
      frontend/resources/vue/views/EntitiesView.vue
  19. 0 1
      frontend/resources/vue/views/LogsCalendarView.vue
  20. 0 1
      frontend/resources/vue/views/LogsListView.vue
  21. 30 0
      proto/olivetin/api/v1/olivetin.proto
  22. 299 87
      service/gen/olivetin/api/v1/olivetin.pb.go
  23. 10 0
      service/internal/acl/acl.go
  24. 80 0
      service/internal/acl/acl_entity_test.go
  25. 115 16
      service/internal/api/api.go
  26. 46 14
      service/internal/api/apiActions.go
  27. 52 33
      service/internal/api/api_entities_list.go
  28. 214 0
      service/internal/api/api_entity_acl_test.go
  29. 152 0
      service/internal/api/api_entity_argument_acl.go
  30. 164 0
      service/internal/api/api_entity_argument_acl_test.go
  31. 273 0
      service/internal/api/api_init_search_hints_test.go
  32. 168 0
      service/internal/api/api_search_hints.go
  33. 1 1
      service/internal/api/api_test.go
  34. 5 0
      service/internal/api/dashboard_entities.go
  35. 9 0
      service/internal/api/dashboards.go
  36. 7 0
      service/internal/config/config.go
  37. 40 0
      service/internal/config/entity_argument_choices_test.go
  38. 40 0
      service/internal/config/sanitize.go
  39. 46 9
      service/internal/configcheck/rebuild.go
  40. 58 0
      service/internal/configcheck/rebuild_test.go
  41. 2 1
      service/internal/configissues/issue.go
  42. 37 0
      service/internal/entities/search_hints.go
  43. 42 0
      service/internal/entities/search_hints_test.go
  44. 5 1
      service/internal/executor/arguments.go
  45. 88 0
      specs/entity-acls.md
  46. 74 0
      specs/feature-flags.md

+ 5 - 4
.github/SECURITY_ADVISORY_DUPLICATES.md

@@ -7,10 +7,11 @@ This document lists known duplicate security advisory clusters for [OliveTin/Oli
 ## Triage checklist
 
 1. Search open advisories for the same component and attack path.
-2. Match against clusters below.
-3. If duplicate: close the newer advisory, link to canonical, add reporter to canonical credits.
-4. If unique: accept, patch on a private branch, reassess CVSS with OliveTin context (see SECURITY.md — OliveTin is intentional RCE by design).
-5. Merge fix to `next`, publish advisory, credit reporters in advisory body (not commit message).
+2. Reject if the issue only affects feature-flagged / alpha functionality (`features.*` in config; see [SECURITY.md](../SECURITY.md#feature-flags-alpha--experimental)). Ask the reporter to open a normal GitHub issue instead; do not request a CVE.
+3. Match against clusters below.
+4. If duplicate: close the newer advisory, link to canonical, add reporter to canonical credits.
+5. If unique: accept, patch on a private branch, reassess CVSS with OliveTin context (see SECURITY.md — OliveTin is intentional RCE by design).
+6. Merge fix to `next`, publish advisory, credit reporters in advisory body (not commit message).
 
 ---
 

+ 12 - 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.
+* **Security reports and CVEs are not accepted** for bugs that only affect feature-flagged (alpha) functionality. Prefer filing a normal GitHub issue (or a PR) instead of a security advisory.
+* 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.
@@ -52,6 +63,7 @@ Maintainers: see [.github/SECURITY_ADVISORY_DUPLICATES.md](.github/SECURITY_ADVI
 Once a vulnerability is reported, the process is;
 
 * Check [.github/SECURITY_ADVISORY_DUPLICATES.md](.github/SECURITY_ADVISORY_DUPLICATES.md) and open advisories for duplicates before accepting.
+* Reject reports that only affect feature-flagged / alpha functionality (see [Feature flags](#feature-flags-alpha--experimental) above); ask the reporter to open a normal issue instead.
 * Accept or reject the report, and communicate with the reporter about next steps.
 * If accepted, patch using a temporary branch, and code review will be requested from the original reporter if they are interested.
 * The severity of the vulnerability will be assessed using CVSS, and the patch will be prioritised accordingly.

+ 1 - 1
docs/modules/ROOT/pages/action_customization/hidden.adoc

@@ -12,6 +12,6 @@ actions:
     execOnStartup: true
 ----
 
-IMPORTANT: `hidden` is **not** a security control. It only affects where the action appears in the UI. Users who are allowed to view the action (via `defaultPermissions` or ACLs) can still open its Action Details page, see it in logs, and call APIs such as `GetActionBinding`. To restrict who can see or run an action, use xref:security/acl.adoc[Access Control Lists].
+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.

+ 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

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

@@ -160,6 +160,58 @@ In the example above, guests can open **Public tools**, but **Services** is hidd
 
 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;
 };
 
 /**

Fișier diff suprimat deoarece este prea mare
+ 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/${title}`
+
+  if (entityType && entityKey) {
+    path += `/${entityType}/${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/${hint.type}/${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/${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)
+}

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

@@ -0,0 +1,197 @@
+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'
+  )
+})
+
+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' }
+    ],
+    actions: [
+      { title: 'Ping Host', bindingId: 'bind-ping' },
+      { title: '', bindingId: 'bind-empty-title' },
+      { title: 'Ignored', bindingId: '' }
+    ]
+  })
+
+  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['action:bind-ping'].title, 'Ping Host')
+  assert.equal(byId['action:bind-ping'].path, '/action/bind-ping')
+  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 Server')
+  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()
 

+ 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 {

Fișier diff suprimat deoarece este prea mare
+ 299 - 87
service/gen/olivetin/api/v1/olivetin.pb.go


+ 10 - 0
service/internal/acl/acl.go

@@ -139,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")
+}

+ 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.Entity == 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), 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)
+	for _, id := range guestActionIDs {
+		assert.NotContains(t, id, "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)
+}

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

@@ -0,0 +1,152 @@
+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 i := range action.Arguments {
+		arg := &action.Arguments[i]
+		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(value, allowed)
+	}
+
+	_, ok := allowed[value]
+	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 checklistEntityValuesAllowed(value string, allowed map[string]struct{}) bool {
+	parts := strings.Split(value, ",")
+	sawItem := false
+
+	for _, part := range parts {
+		part = strings.TrimSpace(part)
+		if part == "" {
+			continue
+		}
+
+		sawItem = true
+		if _, ok := allowed[part]; !ok {
+			return false
+		}
+	}
+
+	return sawItem
+}

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

@@ -0,0 +1,164 @@
+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": {}}
+
+	assert.False(t, checklistEntityValuesAllowed(",,,", allowed))
+	assert.False(t, checklistEntityValuesAllowed(" , ", allowed))
+	assert.False(t, checklistEntityValuesAllowed("", allowed),
+		"all-blank checklist parts are rejected here; empty string is accepted by the caller separately")
+	assert.True(t, checklistEntityValuesAllowed("web01", allowed))
+	assert.True(t, checklistEntityValuesAllowed("web01, db01", allowed))
+	assert.False(t, checklistEntityValuesAllowed("web01, unknown", allowed))
+}
+
+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))
+}

+ 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()
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
+	defer ts.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()
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
+	defer ts.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)
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
+	defer ts.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()
+
+	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)
+
+	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()
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	api := newServer(ex)
+
+	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 i := 0; i < maxSearchHintEntitiesPerType+5; i++ {
+		entities.AddEntity("cap_host", fmt.Sprintf("%03d", i), map[string]any{"name": fmt.Sprintf("host-%03d", i)})
+		entities.AddEntity("cap_container", fmt.Sprintf("%03d", i), map[string]any{"name": fmt.Sprintf("ctr-%03d", i)})
+	}
+
+	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 i := 0; i < maxSearchHintActions+5; i++ {
+		cfg.Actions = append(cfg.Actions, &config.Action{
+			ID:    fmt.Sprintf("action-%03d", i),
+			Title: fmt.Sprintf("Action %03d", i),
+			Shell: "echo",
+		})
+	}
+	cfg.Sanitize()
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	api := newServer(ex)
+
+	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()
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	api := newServer(ex)
+
+	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
+}

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

@@ -0,0 +1,168 @@
+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 {
+	hints := entities.ListSearchHints()
+	out := make([]*apiv1.EntitySearchHint, 0, len(hints))
+
+	for _, hint := range hints {
+		if pb := api.entitySearchHintIfAllowed(user, hint); pb != nil {
+			out = append(out, pb)
+		}
+	}
+
+	sortEntitySearchHints(out)
+	return capEntitySearchHintsPerType(out, maxSearchHintEntitiesPerType)
+}
+
+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(i, j int) bool {
+		if hints[i].Type != hints[j].Type {
+			return hints[i].Type < hints[j].Type
+		}
+
+		if hints[i].UniqueKey != hints[j].UniqueKey {
+			return hints[i].UniqueKey < hints[j].UniqueKey
+		}
+
+		return hints[i].Title < hints[j].Title
+	})
+}
+
+func capEntitySearchHintsPerType(hints []*apiv1.EntitySearchHint, perType int) []*apiv1.EntitySearchHint {
+	if perType < 1 || len(hints) == 0 {
+		return hints
+	}
+
+	counts := make(map[string]int)
+	out := make([]*apiv1.EntitySearchHint, 0, len(hints))
+
+	for _, hint := range hints {
+		if counts[hint.Type] >= perType {
+			continue
+		}
+
+		counts[hint.Type]++
+		out = append(out, hint)
+	}
+
+	return out
+}
+
+func (api *oliveTinAPI) buildActionSearchHints(user *authpublic.AuthenticatedUser) []*apiv1.ActionSearchHint {
+	candidates := api.collectViewableActionBindings(user)
+	sortActionSearchCandidates(candidates)
+
+	if len(candidates) > maxSearchHintActions {
+		candidates = candidates[:maxSearchHintActions]
+	}
+
+	out := make([]*apiv1.ActionSearchHint, 0, len(candidates))
+	for _, candidate := range candidates {
+		out = append(out, &apiv1.ActionSearchHint{
+			Title:     candidate.title,
+			BindingId: candidate.bindingID,
+		})
+	}
+
+	return out
+}
+
+type actionSearchCandidate struct {
+	title     string
+	bindingID string
+	hasEntity bool
+}
+
+func (api *oliveTinAPI) collectViewableActionBindings(user *authpublic.AuthenticatedUser) []actionSearchCandidate {
+	api.executor.MapActionBindingsLock.RLock()
+	defer api.executor.MapActionBindingsLock.RUnlock()
+
+	candidates := make([]actionSearchCandidate, 0)
+	for _, binding := range api.executor.MapActionBindings {
+		if candidate, ok := actionSearchCandidateFromBinding(api, user, binding); ok {
+			candidates = append(candidates, candidate)
+		}
+	}
+
+	return candidates
+}
+
+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(i, j int) bool {
+		if candidates[i].hasEntity != candidates[j].hasEntity {
+			return !candidates[i].hasEntity
+		}
+
+		if candidates[i].title != candidates[j].title {
+			return candidates[i].title < candidates[j].title
+		}
+
+		return candidates[i].bindingID < candidates[j].bindingID
+	})
+}

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

@@ -1099,7 +1099,7 @@ func TestBuildChoicesExpandsChecklistEntityChoices(t *testing.T) {
 		},
 	}
 
-	choices := buildChoices(arg)
+	choices := buildChoices(arg, nil)
 	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)

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

@@ -26,6 +26,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
@@ -157,6 +161,11 @@ func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
 			continue
 		}
 
+		if binding.Entity != nil && binding.Action.Entity != "" &&
+			!acl.IsAllowedViewEntityType(rr.cfg, rr.AuthenticatedUser, entityFileForType(rr.cfg, binding.Action.Entity)) {
+			continue
+		}
+
 		action := buildAction(binding, rr)
 		if action == nil {
 			continue

+ 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)

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

@@ -32,6 +32,7 @@ func Rebuild(cfg *config.Config, extra ...configissues.Issue) {
 	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)...)
@@ -94,13 +95,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 +114,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 +216,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

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

@@ -191,6 +191,64 @@ 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 hasCode(issues []configissues.Issue, code string) bool {
 	for _, issue := range issues {
 		if issue.Code == code {

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

@@ -8,13 +8,14 @@ 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"

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

@@ -0,0 +1,37 @@
+package entities
+
+// SearchHint is a minimal entity identity for client search indexes.
+type SearchHint struct {
+	Title     string
+	Type      string
+	UniqueKey string
+}
+
+// ListSearchHints returns title/type/key for every entity instance.
+// It holds only a read lock and does not copy entity Data payloads.
+func ListSearchHints() []SearchHint {
+	rwmutex.RLock()
+	defer rwmutex.RUnlock()
+
+	count := 0
+	for _, instances := range entities {
+		count += len(instances)
+	}
+
+	hints := make([]SearchHint, 0, count)
+	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(t *testing.T) {
+	ClearEntitiesOfType("search_hint_host")
+	ClearEntitiesOfType("search_hint_app")
+	t.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, ok := byKey["search_hint_host:0"]
+	require.True(t, ok)
+	assert.Equal(t, "web01", host.Title)
+	assert.Equal(t, "search_hint_host", host.Type)
+	assert.Equal(t, "0", host.UniqueKey)
+
+	app, ok := byKey["search_hint_app:app-1"]
+	require.True(t, ok)
+	assert.Equal(t, "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 entry under entities in configuration), not per instance.
+
+- Instances remain data loaded from entity files.
+- Only the **view** permission is consulted for entity types.
+- Action permissions (view, exec, logs, kill) continue to govern actions themselves.
+
+---
+
+## 2. Configuration
+
+Each entity definition may list zero or more ACL names.
+
+- 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 ACLs).
+- If one or more ACL names are listed, access is an allow list: a matching ACL that grants view, otherwise the default view permission.
+- Adding an ACL to every action does not apply to entity types. An ACL 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 Init when guests must log in, when the header search feature flag is off, and the QuickSearch 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 Init response. The client applies the same caps when indexing.
+
+Dashboards are not included in search hints. Clients build the dashboard search index from Init root dashboard entries (already filtered by dashboard ACL).
+
+Entity types with no ACL 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 plus `entity`. 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 set `entity` with zero or multiple choices 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 ACL lists stay unrestricted. Setting default view to false alone does not hide unrestricted entity types; operators must list ACLs on entity definitions to lock them down.

+ 74 - 0
specs/feature-flags.md

@@ -0,0 +1,74 @@
+# 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. Security advisories and CVEs are not accepted for issues that only affect feature-flagged code; see the project security policy.
+
+---
+
+## 2. Configuration
+
+Flags live under `features` in configuration.
+
+- Each flag is a boolean.
+- Omitted flags are **false**.
+- Enabling a feature requires setting it to true explicitly.
+
+Example:
+
+```yaml
+features:
+  headerSearch: true
+```
+
+Unknown keys under `features` are ignored by the configuration loader (same as other unknown config keys).
+
+---
+
+## 3. Init projection
+
+Every Init response includes a `features` object with the current 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
+
+`features.headerSearch` controls the header QuickSearch control and Init search hints.
+
+- Default: false (alpha / experimental).
+- When false: Init omits search hints; the client does not show QuickSearch or populate the search index.
+- When true: Init includes ACL-filtered search hints (unless guests must log in); the client shows QuickSearch after login is satisfied.
+
+---
+
+## 6. Adding a new flag
+
+To add a flag:
+
+1. Add a boolean field under `features` in configuration (default false).
+2. Add the same field to the Init `features` message.
+3. Project the value on every Init response.
+4. Gate server work and UI behind the flag.
+5. Document the flag as alpha / experimental and update this spec’s feature list.
+6. Keep the security policy statement that CVEs are not accepted for feature-flagged functionality until the feature graduates (flag removed or stable default-on).

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff