James Read 4 meses atrás
pai
commit
2eb5f0ba79

+ 29 - 2
.pre-commit-config.yaml

@@ -9,6 +9,19 @@ repos:
       - id: end-of-file-fixer
       - id: check-yaml
       - id: check-added-large-files
+      - id: check-merge-conflict
+      - id: detect-private-key
+      - id: mixed-line-ending
+        args: ['--fix', 'lf']
+      - id: check-json
+        exclude: |
+          (?x)^(
+            service/internal/entities/testdata/.*\.json|
+            integration-tests/tests/.*/entities/.*\.json|
+            var/entities/.*\.json
+          )$
+      - id: check-case-conflict
+      - id: detect-aws-credentials
 
   # Alternative semantic commit checker
   - repo: https://github.com/compilerla/conventional-pre-commit
@@ -34,9 +47,23 @@ repos:
         pass_filenames: false
         always_run: true
 
+      - id: service-unittests
+        name: service-unittests
+        entry: make service-unittests
+        language: system
+        pass_filenames: false
+        always_run: true
+
+      - id: service-build
+        name: service-build
+        entry: make service
+        language: system
+        pass_filenames: false
+        always_run: true
+
       - id: it
-        name: it
-        entry: make service-codestyle frontend-codestyle
+        name: integration-tests
+        entry: make it
         language: system
         pass_filenames: false
         always_run: true

+ 3 - 0
AGENTS.md

@@ -19,6 +19,8 @@ If you are looking for OliveTin's AI policy, you can find it in `AI.md`.
   - From repo root: `go run ./service`
 - Unit tests (Go):
   - From repo root: `cd service && make unittests`
+- Code style (after editing code in `service/`):
+  - From repo root: `cd service && make codestyle`
 - Integration tests (Mocha + Selenium):
   - Single test: `cd integration-tests && npx --yes mocha test/general.mjs`
   - All tests: `cd integration-tests && npx --yes mocha`
@@ -41,6 +43,7 @@ If you are looking for OliveTin's AI policy, you can find it in `AI.md`.
 - Do not swallow errors; propagate or log meaningfully.
 - Match existing formatting; avoid unrelated reformatting.
 - Be safe around nils in executor steps (e.g., guard `req.Binding` and `req.Binding.Action`).
+- Cyclomatic complexity over 4 is not permitted.
 
 ### API and Execution Flow (High-level)
 1. Client calls Connect RPC (e.g., `Init`, `GetDashboard`, `StartAction`).

+ 84 - 103
frontend/package-lock.json

@@ -11,7 +11,7 @@
 			"dependencies": {
 				"@connectrpc/connect": "^2.1.1",
 				"@connectrpc/connect-web": "^2.1.1",
-				"@hugeicons/core-free-icons": "^3.1.1",
+				"@hugeicons/core-free-icons": "^3.3.0",
 				"@hugeicons/vue": "^1.0.4",
 				"@vitejs/plugin-vue": "^6.0.4",
 				"@xterm/addon-fit": "^0.11.0",
@@ -21,13 +21,13 @@
 				"standard": "^17.1.2",
 				"unplugin-vue-components": "^31.0.0",
 				"vite": "^7.3.1",
-				"vue": "^3.5.28",
+				"vue": "^3.5.29",
 				"vue-i18n": "^11.2.8",
-				"vue-router": "^5.0.2"
+				"vue-router": "^5.0.3"
 			},
 			"devDependencies": {
 				"process": "^0.11.10",
-				"stylelint": "^17.3.0",
+				"stylelint": "^17.4.0",
 				"stylelint-config-standard": "^40.0.0"
 			}
 		},
@@ -905,9 +905,9 @@
 			}
 		},
 		"node_modules/@hugeicons/core-free-icons": {
-			"version": "3.1.1",
-			"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-3.1.1.tgz",
-			"integrity": "sha512-UpS2lUQFi5sKyJSWwM6rO+BnPLvVz1gsyCpPHeZyVuZqi89YH8ksliza4cwaODqKOZyeXmG8juo1ty4QtQofkg==",
+			"version": "3.3.0",
+			"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-3.3.0.tgz",
+			"integrity": "sha512-qYyr4JQ2eQIHTSTbITvnJvs6ERNK64D9gpwZnf2IyuG0exzqfyABLO/oTB71FB3RZPfu1GbwycdiGSo46apjMQ==",
 			"license": "MIT"
 		},
 		"node_modules/@hugeicons/vue": {
@@ -1435,39 +1435,39 @@
 			}
 		},
 		"node_modules/@vue/compiler-core": {
-			"version": "3.5.28",
-			"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.28.tgz",
-			"integrity": "sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ==",
+			"version": "3.5.29",
+			"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz",
+			"integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==",
 			"license": "MIT",
 			"dependencies": {
 				"@babel/parser": "^7.29.0",
-				"@vue/shared": "3.5.28",
+				"@vue/shared": "3.5.29",
 				"entities": "^7.0.1",
 				"estree-walker": "^2.0.2",
 				"source-map-js": "^1.2.1"
 			}
 		},
 		"node_modules/@vue/compiler-dom": {
-			"version": "3.5.28",
-			"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.28.tgz",
-			"integrity": "sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA==",
+			"version": "3.5.29",
+			"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz",
+			"integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/compiler-core": "3.5.28",
-				"@vue/shared": "3.5.28"
+				"@vue/compiler-core": "3.5.29",
+				"@vue/shared": "3.5.29"
 			}
 		},
 		"node_modules/@vue/compiler-sfc": {
-			"version": "3.5.28",
-			"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.28.tgz",
-			"integrity": "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g==",
+			"version": "3.5.29",
+			"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
+			"integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
 			"license": "MIT",
 			"dependencies": {
 				"@babel/parser": "^7.29.0",
-				"@vue/compiler-core": "3.5.28",
-				"@vue/compiler-dom": "3.5.28",
-				"@vue/compiler-ssr": "3.5.28",
-				"@vue/shared": "3.5.28",
+				"@vue/compiler-core": "3.5.29",
+				"@vue/compiler-dom": "3.5.29",
+				"@vue/compiler-ssr": "3.5.29",
+				"@vue/shared": "3.5.29",
 				"estree-walker": "^2.0.2",
 				"magic-string": "^0.30.21",
 				"postcss": "^8.5.6",
@@ -1475,13 +1475,13 @@
 			}
 		},
 		"node_modules/@vue/compiler-ssr": {
-			"version": "3.5.28",
-			"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.28.tgz",
-			"integrity": "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g==",
+			"version": "3.5.29",
+			"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz",
+			"integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/compiler-dom": "3.5.28",
-				"@vue/shared": "3.5.28"
+				"@vue/compiler-dom": "3.5.29",
+				"@vue/shared": "3.5.29"
 			}
 		},
 		"node_modules/@vue/devtools-api": {
@@ -1491,12 +1491,12 @@
 			"license": "MIT"
 		},
 		"node_modules/@vue/devtools-kit": {
-			"version": "8.0.5",
-			"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.0.5.tgz",
-			"integrity": "sha512-q2VV6x1U3KJMTQPUlRMyWEKVbcHuxhqJdSr6Jtjz5uAThAIrfJ6WVZdGZm5cuO63ZnSUz0RCsVwiUUb0mDV0Yg==",
+			"version": "8.0.6",
+			"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.0.6.tgz",
+			"integrity": "sha512-9zXZPTJW72OteDXeSa5RVML3zWDCRcO5t77aJqSs228mdopYj5AiTpihozbsfFJ0IodfNs7pSgOGO3qfCuxDtw==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/devtools-shared": "^8.0.5",
+				"@vue/devtools-shared": "^8.0.6",
 				"birpc": "^2.6.1",
 				"hookable": "^5.5.3",
 				"mitt": "^3.0.1",
@@ -1506,62 +1506,62 @@
 			}
 		},
 		"node_modules/@vue/devtools-shared": {
-			"version": "8.0.5",
-			"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.5.tgz",
-			"integrity": "sha512-bRLn6/spxpmgLk+iwOrR29KrYnJjG9DGpHGkDFG82UM21ZpJ39ztUT9OXX3g+usW7/b2z+h46I9ZiYyB07XMXg==",
+			"version": "8.0.6",
+			"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.6.tgz",
+			"integrity": "sha512-Pp1JylTqlgMJvxW6MGyfTF8vGvlBSCAvMFaDCYa82Mgw7TT5eE5kkHgDvmOGHWeJE4zIDfCpCxHapsK2LtIAJg==",
 			"license": "MIT",
 			"dependencies": {
 				"rfdc": "^1.4.1"
 			}
 		},
 		"node_modules/@vue/reactivity": {
-			"version": "3.5.28",
-			"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.28.tgz",
-			"integrity": "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw==",
+			"version": "3.5.29",
+			"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz",
+			"integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/shared": "3.5.28"
+				"@vue/shared": "3.5.29"
 			}
 		},
 		"node_modules/@vue/runtime-core": {
-			"version": "3.5.28",
-			"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.28.tgz",
-			"integrity": "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ==",
+			"version": "3.5.29",
+			"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz",
+			"integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/reactivity": "3.5.28",
-				"@vue/shared": "3.5.28"
+				"@vue/reactivity": "3.5.29",
+				"@vue/shared": "3.5.29"
 			}
 		},
 		"node_modules/@vue/runtime-dom": {
-			"version": "3.5.28",
-			"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.28.tgz",
-			"integrity": "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA==",
+			"version": "3.5.29",
+			"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz",
+			"integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/reactivity": "3.5.28",
-				"@vue/runtime-core": "3.5.28",
-				"@vue/shared": "3.5.28",
+				"@vue/reactivity": "3.5.29",
+				"@vue/runtime-core": "3.5.29",
+				"@vue/shared": "3.5.29",
 				"csstype": "^3.2.3"
 			}
 		},
 		"node_modules/@vue/server-renderer": {
-			"version": "3.5.28",
-			"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.28.tgz",
-			"integrity": "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg==",
+			"version": "3.5.29",
+			"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz",
+			"integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/compiler-ssr": "3.5.28",
-				"@vue/shared": "3.5.28"
+				"@vue/compiler-ssr": "3.5.29",
+				"@vue/shared": "3.5.29"
 			},
 			"peerDependencies": {
-				"vue": "3.5.28"
+				"vue": "3.5.29"
 			}
 		},
 		"node_modules/@vue/shared": {
-			"version": "3.5.28",
-			"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.28.tgz",
-			"integrity": "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==",
+			"version": "3.5.29",
+			"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz",
+			"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
 			"license": "MIT"
 		},
 		"node_modules/@xterm/addon-fit": {
@@ -2121,13 +2121,13 @@
 			}
 		},
 		"node_modules/css-functions-list": {
-			"version": "3.2.3",
-			"resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.3.tgz",
-			"integrity": "sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==",
+			"version": "3.3.3",
+			"resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.3.3.tgz",
+			"integrity": "sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==",
 			"dev": true,
 			"license": "MIT",
 			"engines": {
-				"node": ">=12 || >=16"
+				"node": ">=12"
 			}
 		},
 		"node_modules/css-tree": {
@@ -4276,13 +4276,6 @@
 				"node": ">=0.10.0"
 			}
 		},
-		"node_modules/known-css-properties": {
-			"version": "0.37.0",
-			"resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz",
-			"integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==",
-			"dev": true,
-			"license": "MIT"
-		},
 		"node_modules/levn": {
 			"version": "0.4.1",
 			"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -5854,9 +5847,9 @@
 			}
 		},
 		"node_modules/stylelint": {
-			"version": "17.3.0",
-			"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.3.0.tgz",
-			"integrity": "sha512-1POV91lcEMhj6SLVaOeA0KlS9yattS+qq+cyWqP/nYzWco7K5jznpGH1ExngvPlTM9QF1Kjd2bmuzJu9TH2OcA==",
+			"version": "17.4.0",
+			"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.4.0.tgz",
+			"integrity": "sha512-3kQ2/cHv3Zt8OBg+h2B8XCx9evEABQIrv4hh3uXahGz/ZEHrTR80zxBiK2NfXNaSoyBzxO1pjsz1Vhdzwn5XSw==",
 			"dev": true,
 			"funding": [
 				{
@@ -5872,15 +5865,14 @@
 			"dependencies": {
 				"@csstools/css-calc": "^3.1.1",
 				"@csstools/css-parser-algorithms": "^4.0.0",
-				"@csstools/css-syntax-patches-for-csstree": "^1.0.26",
+				"@csstools/css-syntax-patches-for-csstree": "^1.0.27",
 				"@csstools/css-tokenizer": "^4.0.0",
 				"@csstools/media-query-list-parser": "^5.0.0",
 				"@csstools/selector-resolve-nested": "^4.0.0",
 				"@csstools/selector-specificity": "^6.0.0",
-				"balanced-match": "^3.0.1",
 				"colord": "^2.9.3",
 				"cosmiconfig": "^9.0.0",
-				"css-functions-list": "^3.2.3",
+				"css-functions-list": "^3.3.3",
 				"css-tree": "^3.1.0",
 				"debug": "^4.4.3",
 				"fast-glob": "^3.3.3",
@@ -5894,7 +5886,6 @@
 				"import-meta-resolve": "^4.2.0",
 				"imurmurhash": "^0.1.4",
 				"is-plain-object": "^5.0.0",
-				"known-css-properties": "^0.37.0",
 				"mathml-tag-names": "^4.0.0",
 				"meow": "^14.0.0",
 				"micromatch": "^4.0.8",
@@ -5979,16 +5970,6 @@
 				"url": "https://github.com/chalk/ansi-regex?sponsor=1"
 			}
 		},
-		"node_modules/stylelint/node_modules/balanced-match": {
-			"version": "3.0.1",
-			"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-3.0.1.tgz",
-			"integrity": "sha512-vjtV3hiLqYDNRoiAv0zC4QaGAMPomEoq83PRmYIofPswwZurCeWR5LByXm7SyoL0Zh5+2z0+HC7jG8gSZJUh0w==",
-			"dev": true,
-			"license": "MIT",
-			"engines": {
-				"node": ">= 16"
-			}
-		},
 		"node_modules/stylelint/node_modules/file-entry-cache": {
 			"version": "11.1.2",
 			"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.2.tgz",
@@ -6615,16 +6596,16 @@
 			}
 		},
 		"node_modules/vue": {
-			"version": "3.5.28",
-			"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz",
-			"integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==",
+			"version": "3.5.29",
+			"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
+			"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/compiler-dom": "3.5.28",
-				"@vue/compiler-sfc": "3.5.28",
-				"@vue/runtime-dom": "3.5.28",
-				"@vue/server-renderer": "3.5.28",
-				"@vue/shared": "3.5.28"
+				"@vue/compiler-dom": "3.5.29",
+				"@vue/compiler-sfc": "3.5.29",
+				"@vue/runtime-dom": "3.5.29",
+				"@vue/server-renderer": "3.5.29",
+				"@vue/shared": "3.5.29"
 			},
 			"peerDependencies": {
 				"typescript": "*"
@@ -6656,14 +6637,14 @@
 			}
 		},
 		"node_modules/vue-router": {
-			"version": "5.0.2",
-			"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.2.tgz",
-			"integrity": "sha512-YFhwaE5c5JcJpNB1arpkl4/GnO32wiUWRB+OEj1T0DlDxEZoOfbltl2xEwktNU/9o1sGcGburIXSpbLpPFe/6w==",
+			"version": "5.0.3",
+			"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.3.tgz",
+			"integrity": "sha512-nG1c7aAFac7NYj8Hluo68WyWfc41xkEjaR0ViLHCa3oDvTQ/nIuLJlXJX1NUPw/DXzx/8+OKMng045HHQKQKWw==",
 			"license": "MIT",
 			"dependencies": {
 				"@babel/generator": "^7.28.6",
 				"@vue-macros/common": "^3.1.1",
-				"@vue/devtools-api": "^8.0.0",
+				"@vue/devtools-api": "^8.0.6",
 				"ast-walker-scope": "^0.8.3",
 				"chokidar": "^5.0.0",
 				"json5": "^2.2.3",
@@ -6701,12 +6682,12 @@
 			}
 		},
 		"node_modules/vue-router/node_modules/@vue/devtools-api": {
-			"version": "8.0.5",
-			"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.0.5.tgz",
-			"integrity": "sha512-DgVcW8H/Nral7LgZEecYFFYXnAvGuN9C3L3DtWekAncFBedBczpNW8iHKExfaM559Zm8wQWrwtYZ9lXthEHtDw==",
+			"version": "8.0.6",
+			"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.0.6.tgz",
+			"integrity": "sha512-+lGBI+WTvJmnU2FZqHhEB8J1DXcvNlDeEalz77iYgOdY1jTj1ipSBaKj3sRhYcy+kqA8v/BSuvOz1XJucfQmUA==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/devtools-kit": "^8.0.5"
+				"@vue/devtools-kit": "^8.0.6"
 			}
 		},
 		"node_modules/vue-router/node_modules/json5": {

+ 4 - 4
frontend/package.json

@@ -6,7 +6,7 @@
 	"source": "index.html",
 	"devDependencies": {
 		"process": "^0.11.10",
-		"stylelint": "^17.3.0",
+		"stylelint": "^17.4.0",
 		"stylelint-config-standard": "^40.0.0"
 	},
 	"scripts": {
@@ -24,7 +24,7 @@
 	"dependencies": {
 		"@connectrpc/connect": "^2.1.1",
 		"@connectrpc/connect-web": "^2.1.1",
-		"@hugeicons/core-free-icons": "^3.1.1",
+		"@hugeicons/core-free-icons": "^3.3.0",
 		"@hugeicons/vue": "^1.0.4",
 		"@vitejs/plugin-vue": "^6.0.4",
 		"@xterm/addon-fit": "^0.11.0",
@@ -34,8 +34,8 @@
 		"standard": "^17.1.2",
 		"unplugin-vue-components": "^31.0.0",
 		"vite": "^7.3.1",
-    "vue": "^3.5.28",
+    "vue": "^3.5.29",
 		"vue-i18n": "^11.2.8",
-		"vue-router": "^5.0.2"
+		"vue-router": "^5.0.3"
 	}
 }

+ 114 - 205
integration-tests/package-lock.json

@@ -13,9 +13,9 @@
       },
       "devDependencies": {
         "chai": "^6.2.2",
-        "eslint": "^9.39.2",
+        "eslint": "^10.0.2",
         "mocha": "^11.7.5",
-        "selenium-webdriver": "^4.40.0"
+        "selenium-webdriver": "^4.41.0"
       }
     },
     "node_modules/@aashutoshrathi/word-wrap": {
@@ -67,9 +67,9 @@
       }
     },
     "node_modules/@eslint-community/regexpp": {
-      "version": "4.12.1",
-      "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
-      "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
+      "version": "4.12.2",
+      "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+      "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -77,105 +77,68 @@
       }
     },
     "node_modules/@eslint/config-array": {
-      "version": "0.21.1",
-      "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
-      "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
+      "version": "0.23.2",
+      "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz",
+      "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
-        "@eslint/object-schema": "^2.1.7",
+        "@eslint/object-schema": "^3.0.2",
         "debug": "^4.3.1",
-        "minimatch": "^3.1.2"
+        "minimatch": "^10.2.1"
       },
       "engines": {
-        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+        "node": "^20.19.0 || ^22.13.0 || >=24"
       }
     },
     "node_modules/@eslint/config-helpers": {
-      "version": "0.4.2",
-      "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
-      "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz",
+      "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
-        "@eslint/core": "^0.17.0"
+        "@eslint/core": "^1.1.0"
       },
       "engines": {
-        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+        "node": "^20.19.0 || ^22.13.0 || >=24"
       }
     },
     "node_modules/@eslint/core": {
-      "version": "0.17.0",
-      "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
-      "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz",
+      "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
         "@types/json-schema": "^7.0.15"
       },
       "engines": {
-        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-      }
-    },
-    "node_modules/@eslint/eslintrc": {
-      "version": "3.3.1",
-      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
-      "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "ajv": "^6.12.4",
-        "debug": "^4.3.2",
-        "espree": "^10.0.1",
-        "globals": "^14.0.0",
-        "ignore": "^5.2.0",
-        "import-fresh": "^3.2.1",
-        "js-yaml": "^4.1.0",
-        "minimatch": "^3.1.2",
-        "strip-json-comments": "^3.1.1"
-      },
-      "engines": {
-        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/eslint"
-      }
-    },
-    "node_modules/@eslint/js": {
-      "version": "9.39.2",
-      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
-      "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-      },
-      "funding": {
-        "url": "https://eslint.org/donate"
+        "node": "^20.19.0 || ^22.13.0 || >=24"
       }
     },
     "node_modules/@eslint/object-schema": {
-      "version": "2.1.7",
-      "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
-      "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz",
+      "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==",
       "dev": true,
       "license": "Apache-2.0",
       "engines": {
-        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+        "node": "^20.19.0 || ^22.13.0 || >=24"
       }
     },
     "node_modules/@eslint/plugin-kit": {
-      "version": "0.4.1",
-      "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
-      "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz",
+      "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
-        "@eslint/core": "^0.17.0",
+        "@eslint/core": "^1.1.0",
         "levn": "^0.4.1"
       },
       "engines": {
-        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+        "node": "^20.19.0 || ^22.13.0 || >=24"
       }
     },
     "node_modules/@hapi/address": {
@@ -326,10 +289,17 @@
       "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
       "license": "MIT"
     },
+    "node_modules/@types/esrecurse": {
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
+      "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/estree": {
-      "version": "1.0.6",
-      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
-      "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
       "dev": true,
       "license": "MIT"
     },
@@ -341,9 +311,9 @@
       "license": "MIT"
     },
     "node_modules/acorn": {
-      "version": "8.15.0",
-      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
-      "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+      "version": "8.16.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+      "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
       "dev": true,
       "license": "MIT",
       "bin": {
@@ -364,9 +334,9 @@
       }
     },
     "node_modules/ajv": {
-      "version": "6.12.6",
-      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
-      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "version": "6.14.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
+      "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -438,14 +408,26 @@
       "dev": true
     },
     "node_modules/brace-expansion": {
-      "version": "1.1.12",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
-      "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+      "version": "5.0.3",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+      "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "balanced-match": "^1.0.0",
-        "concat-map": "0.0.1"
+        "balanced-match": "^4.0.2"
+      },
+      "engines": {
+        "node": "18 || 20 || >=22"
+      }
+    },
+    "node_modules/brace-expansion/node_modules/balanced-match": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+      "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "18 || 20 || >=22"
       }
     },
     "node_modules/browser-stdout": {
@@ -467,16 +449,6 @@
         "node": ">= 0.4"
       }
     },
-    "node_modules/callsites": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
-      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6"
-      }
-    },
     "node_modules/camelcase": {
       "version": "6.3.0",
       "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
@@ -639,13 +611,6 @@
         "node": ">= 0.8"
       }
     },
-    "node_modules/concat-map": {
-      "version": "0.0.1",
-      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
-      "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/core-util-is": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -818,33 +783,30 @@
       }
     },
     "node_modules/eslint": {
-      "version": "9.39.2",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
-      "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
+      "version": "10.0.2",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz",
+      "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.8.0",
-        "@eslint-community/regexpp": "^4.12.1",
-        "@eslint/config-array": "^0.21.1",
-        "@eslint/config-helpers": "^0.4.2",
-        "@eslint/core": "^0.17.0",
-        "@eslint/eslintrc": "^3.3.1",
-        "@eslint/js": "9.39.2",
-        "@eslint/plugin-kit": "^0.4.1",
+        "@eslint-community/regexpp": "^4.12.2",
+        "@eslint/config-array": "^0.23.2",
+        "@eslint/config-helpers": "^0.5.2",
+        "@eslint/core": "^1.1.0",
+        "@eslint/plugin-kit": "^0.6.0",
         "@humanfs/node": "^0.16.6",
         "@humanwhocodes/module-importer": "^1.0.1",
         "@humanwhocodes/retry": "^0.4.2",
         "@types/estree": "^1.0.6",
-        "ajv": "^6.12.4",
-        "chalk": "^4.0.0",
+        "ajv": "^6.14.0",
         "cross-spawn": "^7.0.6",
         "debug": "^4.3.2",
         "escape-string-regexp": "^4.0.0",
-        "eslint-scope": "^8.4.0",
-        "eslint-visitor-keys": "^4.2.1",
-        "espree": "^10.4.0",
-        "esquery": "^1.5.0",
+        "eslint-scope": "^9.1.1",
+        "eslint-visitor-keys": "^5.0.1",
+        "espree": "^11.1.1",
+        "esquery": "^1.7.0",
         "esutils": "^2.0.2",
         "fast-deep-equal": "^3.1.3",
         "file-entry-cache": "^8.0.0",
@@ -854,8 +816,7 @@
         "imurmurhash": "^0.1.4",
         "is-glob": "^4.0.0",
         "json-stable-stringify-without-jsonify": "^1.0.1",
-        "lodash.merge": "^4.6.2",
-        "minimatch": "^3.1.2",
+        "minimatch": "^10.2.1",
         "natural-compare": "^1.4.0",
         "optionator": "^0.9.3"
       },
@@ -863,7 +824,7 @@
         "eslint": "bin/eslint.js"
       },
       "engines": {
-        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+        "node": "^20.19.0 || ^22.13.0 || >=24"
       },
       "funding": {
         "url": "https://eslint.org/donate"
@@ -878,58 +839,61 @@
       }
     },
     "node_modules/eslint-scope": {
-      "version": "8.4.0",
-      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
-      "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+      "version": "9.1.1",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz",
+      "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==",
       "dev": true,
       "license": "BSD-2-Clause",
       "dependencies": {
+        "@types/esrecurse": "^4.3.1",
+        "@types/estree": "^1.0.8",
         "esrecurse": "^4.3.0",
         "estraverse": "^5.2.0"
       },
       "engines": {
-        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+        "node": "^20.19.0 || ^22.13.0 || >=24"
       },
       "funding": {
         "url": "https://opencollective.com/eslint"
       }
     },
     "node_modules/eslint-visitor-keys": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
-      "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+      "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
       "dev": true,
       "license": "Apache-2.0",
       "engines": {
-        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+        "node": "^20.19.0 || ^22.13.0 || >=24"
       },
       "funding": {
         "url": "https://opencollective.com/eslint"
       }
     },
     "node_modules/espree": {
-      "version": "10.4.0",
-      "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
-      "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+      "version": "11.1.1",
+      "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz",
+      "integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==",
       "dev": true,
       "license": "BSD-2-Clause",
       "dependencies": {
-        "acorn": "^8.15.0",
+        "acorn": "^8.16.0",
         "acorn-jsx": "^5.3.2",
-        "eslint-visitor-keys": "^4.2.1"
+        "eslint-visitor-keys": "^5.0.1"
       },
       "engines": {
-        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+        "node": "^20.19.0 || ^22.13.0 || >=24"
       },
       "funding": {
         "url": "https://opencollective.com/eslint"
       }
     },
     "node_modules/esquery": {
-      "version": "1.5.0",
-      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
-      "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+      "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
       "dev": true,
+      "license": "BSD-3-Clause",
       "dependencies": {
         "estraverse": "^5.1.0"
       },
@@ -955,6 +919,7 @@
       "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
       "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
       "dev": true,
+      "license": "BSD-2-Clause",
       "engines": {
         "node": ">=4.0"
       }
@@ -1216,19 +1181,6 @@
         "url": "https://github.com/sponsors/isaacs"
       }
     },
-    "node_modules/globals": {
-      "version": "14.0.0",
-      "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
-      "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=18"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/gopd": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -1314,23 +1266,6 @@
       "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
       "dev": true
     },
-    "node_modules/import-fresh": {
-      "version": "3.3.1",
-      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
-      "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "parent-module": "^1.0.0",
-        "resolve-from": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=6"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/imurmurhash": {
       "version": "0.1.4",
       "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@@ -1552,12 +1487,6 @@
       "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
       "license": "MIT"
     },
-    "node_modules/lodash.merge": {
-      "version": "4.6.2",
-      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
-      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
-      "dev": true
-    },
     "node_modules/log-symbols": {
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
@@ -1612,16 +1541,19 @@
       }
     },
     "node_modules/minimatch": {
-      "version": "3.1.2",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
-      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "version": "10.2.4",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
+      "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
       "dev": true,
-      "license": "ISC",
+      "license": "BlueOak-1.0.0",
       "dependencies": {
-        "brace-expansion": "^1.1.7"
+        "brace-expansion": "^5.0.2"
       },
       "engines": {
-        "node": "*"
+        "node": "18 || 20 || >=22"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
       }
     },
     "node_modules/minimist": {
@@ -1793,19 +1725,6 @@
       "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
       "dev": true
     },
-    "node_modules/parent-module": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
-      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "callsites": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=6"
-      }
-    },
     "node_modules/path-exists": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -1928,16 +1847,6 @@
         "node": ">=0.10.0"
       }
     },
-    "node_modules/resolve-from": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
-      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=4"
-      }
-    },
     "node_modules/rxjs": {
       "version": "7.8.2",
       "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
@@ -1954,9 +1863,9 @@
       "dev": true
     },
     "node_modules/selenium-webdriver": {
-      "version": "4.40.0",
-      "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.40.0.tgz",
-      "integrity": "sha512-dU0QbnVKdPmoNP8OtMCazRdtU2Ux6Wl4FEpG1iwUbDeajJK1dBAywBLrC1D7YFRtogHzN96AbXBgBAJaarcysw==",
+      "version": "4.41.0",
+      "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.41.0.tgz",
+      "integrity": "sha512-1XxuKVhr9az24xwixPBEDGSZP+P0z3ZOnCmr9Oiep0MlJN2Mk+flIjD3iBS9BgyjS4g14dikMqnrYUPIjhQBhA==",
       "dev": true,
       "funding": [
         {
@@ -1973,7 +1882,7 @@
         "@bazel/runfiles": "^6.5.0",
         "jszip": "^3.10.1",
         "tmp": "^0.2.5",
-        "ws": "^8.18.3"
+        "ws": "^8.19.0"
       },
       "engines": {
         "node": ">= 20.0.0"
@@ -2347,9 +2256,9 @@
       }
     },
     "node_modules/ws": {
-      "version": "8.18.3",
-      "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
-      "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
+      "version": "8.19.0",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+      "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
       "dev": true,
       "license": "MIT",
       "engines": {

+ 2 - 2
integration-tests/package.json

@@ -12,9 +12,9 @@
   "license": "AGPL-3.0-only",
   "devDependencies": {
     "chai": "^6.2.2",
-    "eslint": "^9.39.2",
+    "eslint": "^10.0.2",
     "mocha": "^11.7.5",
-    "selenium-webdriver": "^4.40.0"
+    "selenium-webdriver": "^4.41.0"
   },
   "dependencies": {
     "wait-on": "^9.0.4"

+ 68 - 49
service/internal/api/api.go

@@ -3,6 +3,7 @@ package api
 import (
 	ctx "context"
 	"encoding/json"
+	"errors"
 	"os"
 	"path"
 	"sort"
@@ -144,6 +145,9 @@ func (api *oliveTinAPI) PasswordHash(ctx ctx.Context, req *connect.Request[apiv1
 	hash, err := createHash(req.Msg.Password)
 
 	if err != nil {
+		if errors.Is(err, ErrArgon2Busy) {
+			return nil, connect.NewError(connect.CodeResourceExhausted, err)
+		}
 		return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error creating hash: %w", err))
 	}
 
@@ -154,52 +158,50 @@ func (api *oliveTinAPI) PasswordHash(ctx ctx.Context, req *connect.Request[apiv1
 	return connect.NewResponse(ret), nil
 }
 
-func (api *oliveTinAPI) LocalUserLogin(ctx ctx.Context, req *connect.Request[apiv1.LocalUserLoginRequest]) (*connect.Response[apiv1.LocalUserLoginResponse], error) {
-	// Check if local user authentication is enabled
-	if !api.cfg.AuthLocalUsers.Enabled {
-		return connect.NewResponse(&apiv1.LocalUserLoginResponse{
-			Success: false,
-		}), nil
-	}
-
-	match := checkUserPassword(api.cfg, req.Msg.Username, req.Msg.Password)
-
-	response := connect.NewResponse(&apiv1.LocalUserLoginResponse{
-		Success: match,
-	})
+func (api *oliveTinAPI) cookieSecure(header http.Header) bool {
+	useTLS := header.Get("X-Forwarded-Proto") == "https"
+	return useTLS || api.cfg.Security.ForceSecureCookies
+}
 
+func (api *oliveTinAPI) applyLocalLoginResult(req *apiv1.LocalUserLoginRequest, response *connect.Response[apiv1.LocalUserLoginResponse], match bool, secure bool) {
 	if match {
-		// Set authentication cookie for successful login
-		user := api.cfg.FindUserByUsername(req.Msg.Username)
+		user := api.cfg.FindUserByUsername(req.Username)
 		if user != nil {
 			sid := uuid.NewString()
-			// Register the session in the session storage
 			auth.RegisterUserSession(api.cfg, "local", sid, user.Username)
-
-			log.WithFields(log.Fields{
-				"username": user.Username,
-			}).Info("LocalUserLogin: Session created and registered")
-
-			// Set the authentication cookie in the response headers
+			log.WithFields(log.Fields{"username": user.Username}).Info("LocalUserLogin: Session created and registered")
 			cookie := &http.Cookie{
 				Name:     "olivetin-sid-local",
 				Value:    sid,
-				MaxAge:   31556952, // 1 year
+				MaxAge:   31556952,
 				HttpOnly: true,
 				Path:     "/",
+				Secure:   secure,
+				SameSite: http.SameSiteLaxMode,
 			}
 			response.Header().Set("Set-Cookie", cookie.String())
+			log.WithFields(log.Fields{"username": user.Username}).Info("LocalUserLogin: User logged in successfully.")
+		} else {
+			log.WithFields(log.Fields{"username": req.Username}).Warn("LocalUserLogin: Password matched but user lookup failed.")
 		}
-
-		log.WithFields(log.Fields{
-			"username": req.Msg.Username,
-		}).Info("LocalUserLogin: User logged in successfully.")
 	} else {
-		log.WithFields(log.Fields{
-			"username": req.Msg.Username,
-		}).Warn("LocalUserLogin: User login failed.")
+		log.WithFields(log.Fields{"username": req.Username}).Warn("LocalUserLogin: User login failed.")
 	}
+}
 
+func (api *oliveTinAPI) LocalUserLogin(ctx ctx.Context, req *connect.Request[apiv1.LocalUserLoginRequest]) (*connect.Response[apiv1.LocalUserLoginResponse], error) {
+	if !api.cfg.AuthLocalUsers.Enabled {
+		return connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: false}), nil
+	}
+	match, err := checkUserPassword(api.cfg, req.Msg.Username, req.Msg.Password)
+	if err != nil {
+		if errors.Is(err, ErrArgon2Busy) {
+			return nil, connect.NewError(connect.CodeResourceExhausted, err)
+		}
+		return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("checking password: %w", err))
+	}
+	response := connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: match})
+	api.applyLocalLoginResult(req.Msg, response, match, api.cookieSecure(req.Header()))
 	return response, nil
 }
 
@@ -354,30 +356,36 @@ func getMostRecentExecutionStatusByActionId(api *oliveTinAPI, actionId string) *
 	return ile
 }
 
-func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[apiv1.ExecutionStatusRequest]) (*connect.Response[apiv1.ExecutionStatusResponse], error) {
-	res := &apiv1.ExecutionStatusResponse{}
+func (api *oliveTinAPI) resolveExecutionStatusForView(msg *apiv1.ExecutionStatusRequest, user *authpublic.AuthenticatedUser) (*executor.InternalLogEntry, error) {
+	ile := api.getExecutionStatusByRequest(msg)
+	if ile == nil {
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s or action ID %s", msg.ExecutionTrackingId, msg.ActionId))
+	}
+	if !isValidLogEntry(ile) || !api.isLogEntryAllowed(ile, user) {
+		return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied to view this execution"))
+	}
+	return ile, nil
+}
 
-	user := auth.UserFromApiCall(ctx, req, api.cfg)
+func (api *oliveTinAPI) getExecutionStatusByRequest(msg *apiv1.ExecutionStatusRequest) *executor.InternalLogEntry {
+	if msg.ExecutionTrackingId != "" {
+		return getExecutionStatusByTrackingID(api, msg.ExecutionTrackingId)
+	}
+	return getMostRecentExecutionStatusByActionId(api, msg.ActionId)
+}
 
+func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[apiv1.ExecutionStatusRequest]) (*connect.Response[apiv1.ExecutionStatusResponse], error) {
+	user := auth.UserFromApiCall(ctx, req, api.cfg)
 	if err := api.checkDashboardAccess(user); err != nil {
 		return nil, err
 	}
-
-	var ile *executor.InternalLogEntry
-
-	if req.Msg.ExecutionTrackingId != "" {
-		ile = getExecutionStatusByTrackingID(api, req.Msg.ExecutionTrackingId)
-
-	} else {
-		ile = getMostRecentExecutionStatusByActionId(api, req.Msg.ActionId)
+	ile, err := api.resolveExecutionStatusForView(req.Msg, user)
+	if err != nil {
+		return nil, err
 	}
-
-	if ile == nil {
-		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s or action ID %s", req.Msg.ExecutionTrackingId, req.Msg.ActionId))
-	} else {
-		res.LogEntry = api.internalLogEntryToPb(ile, user)
+	res := &apiv1.ExecutionStatusResponse{
+		LogEntry: api.internalLogEntryToPb(ile, user),
 	}
-
 	return connect.NewResponse(res), nil
 }
 
@@ -390,6 +398,7 @@ func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.Logou
 	}).Info("Logout: User logged out")
 
 	response := connect.NewResponse(&apiv1.LogoutResponse{})
+	secure := api.cookieSecure(req.Header())
 
 	// Clear the local authentication cookie by setting it to expire
 	localCookie := &http.Cookie{
@@ -398,6 +407,8 @@ func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.Logou
 		MaxAge:   -1, // This tells the browser to delete the cookie
 		HttpOnly: true,
 		Path:     "/",
+		Secure:   secure,
+		SameSite: http.SameSiteLaxMode,
 	}
 	response.Header().Set("Set-Cookie", localCookie.String())
 
@@ -408,6 +419,8 @@ func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.Logou
 		MaxAge:   -1, // This tells the browser to delete the cookie
 		HttpOnly: true,
 		Path:     "/",
+		Secure:   secure,
+		SameSite: http.SameSiteLaxMode,
 	}
 	response.Header().Add("Set-Cookie", oauth2Cookie.String())
 
@@ -882,11 +895,17 @@ func (api *oliveTinAPI) OnExecutionFinished(ile *executor.InternalLogEntry) {
 }
 
 func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[apiv1.GetDiagnosticsRequest]) (*connect.Response[apiv1.GetDiagnosticsResponse], error) {
+	user := auth.UserFromApiCall(ctx, req, api.cfg)
+	if err := api.checkDashboardAccess(user); err != nil {
+		return nil, err
+	}
+	if !user.EffectivePolicy.ShowDiagnostics {
+		return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("diagnostics are not available for your account"))
+	}
 	res := &apiv1.GetDiagnosticsResponse{
 		SshFoundKey:    installationinfo.Runtime.SshFoundKey,
 		SshFoundConfig: installationinfo.Runtime.SshFoundConfig,
 	}
-
 	return connect.NewResponse(res), nil
 }
 
@@ -1263,7 +1282,7 @@ func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv
 
 	return api.StartAction(ctx, &connect.Request[apiv1.StartActionRequest]{
 		Msg: &apiv1.StartActionRequest{
-			// FIXME
+			BindingId:        execReqLogEntry.GetBindingId(),
 			UniqueTrackingId: req.Msg.ExecutionTrackingId,
 		},
 	})

+ 40 - 24
service/internal/api/local_user_login.go

@@ -1,6 +1,7 @@
 package api
 
 import (
+	"errors"
 	"runtime"
 
 	config "github.com/OliveTin/OliveTin/internal/config"
@@ -8,6 +9,12 @@ import (
 	log "github.com/sirupsen/logrus"
 )
 
+var ErrArgon2Busy = errors.New("too many concurrent password operations")
+
+const argon2MaxConcurrent = 10
+
+var argon2Sem = make(chan struct{}, argon2MaxConcurrent)
+
 var defaultParams = argon2id.Params{
 	Memory:      64 * 1024,
 	Iterations:  4,
@@ -17,10 +24,16 @@ var defaultParams = argon2id.Params{
 }
 
 func CreateHash(password string) (string, error) {
+	select {
+	case argon2Sem <- struct{}{}:
+		defer func() { <-argon2Sem }()
+	default:
+		return "", ErrArgon2Busy
+	}
 	hash, err := argon2id.CreateHash(password, &defaultParams)
 
 	if err != nil {
-		log.Fatal("Error creating hash: ", err)
+		log.Warnf("Error creating hash: %v", err)
 		return "", err
 	}
 
@@ -31,37 +44,40 @@ func createHash(password string) (string, error) {
 	return CreateHash(password)
 }
 
-func comparePasswordAndHash(password, hash string) bool {
+func comparePasswordAndHash(password, hash string) (bool, error) {
+	select {
+	case argon2Sem <- struct{}{}:
+		defer func() { <-argon2Sem }()
+	default:
+		return false, ErrArgon2Busy
+	}
 	match, err := argon2id.ComparePasswordAndHash(password, hash)
 
 	if err != nil {
 		log.Errorf("Error comparing password and hash: %v", err)
-		return false
+		return false, nil
 	}
 
-	return match
+	return match, nil
 }
 
-func checkUserPassword(cfg *config.Config, username, password string) bool {
-	for _, user := range cfg.AuthLocalUsers.Users {
-		if user.Username == username {
-			match := comparePasswordAndHash(password, user.Password)
-
-			if match {
-				return true
-			} else {
-				log.WithFields(log.Fields{
-					"username": username,
-				}).Warn("Password does not match for user")
-
-				return false
-			}
-		}
+func checkUserPassword(cfg *config.Config, username, password string) (bool, error) {
+	user := cfg.FindUserByUsername(username)
+	if user == nil {
+		log.WithFields(log.Fields{"username": username}).Warn("Failed to check password for user, as username was not found")
+		return false, nil
 	}
+	return comparePasswordAndLogResult(password, user.Password, username)
+}
 
-	log.WithFields(log.Fields{
-		"username": username,
-	}).Warn("Failed to check password for user, as username was not found")
-
-	return false
+func comparePasswordAndLogResult(password, hash, username string) (bool, error) {
+	match, err := comparePasswordAndHash(password, hash)
+	if err != nil {
+		return false, err
+	}
+	if !match {
+		log.WithFields(log.Fields{"username": username}).Warn("Password does not match for user")
+		return false, nil
+	}
+	return true, nil
 }

+ 7 - 1
service/internal/auth/otoauth2/restapi_auth_oauth2.go

@@ -108,14 +108,20 @@ func randString(nByte int) (string, error) {
 	return base64.URLEncoding.EncodeToString(b), nil
 }
 
+func (h *OAuth2Handler) cookieSecure(r *http.Request) bool {
+	useTLS := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
+	return useTLS || h.cfg.Security.ForceSecureCookies
+}
+
 func (h *OAuth2Handler) setOAuthCallbackCookie(w http.ResponseWriter, r *http.Request, name, value string) {
 	cookie := &http.Cookie{
 		Name:     name,
 		Value:    value,
 		MaxAge:   900, // 15 minutes
-		Secure:   r.TLS != nil,
+		Secure:   h.cookieSecure(r),
 		HttpOnly: true,
 		Path:     "/",
+		SameSite: http.SameSiteLaxMode,
 	}
 
 	http.SetCookie(w, cookie)

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

@@ -107,6 +107,16 @@ type PrometheusConfig struct {
 	DefaultGoMetrics bool `koanf:"defaultGoMetrics"`
 }
 
+// SecurityConfig allows users to fine tune the security related HTTP headers and cookie options.
+type SecurityConfig struct {
+	HeaderContentSecurityPolicy bool   `koanf:"headerContentSecurityPolicy"`
+	ContentSecurityPolicy       string `koanf:"contentSecurityPolicy"`
+	HeaderXContentTypeOptions   bool   `koanf:"headerXContentTypeOptions"`
+	HeaderXFrameOptions         bool   `koanf:"headerXFrameOptions"`
+	XFrameOptions               string `koanf:"xFrameOptions"`
+	ForceSecureCookies          bool   `koanf:"forceSecureCookies"`
+}
+
 // Config is the global config used through the whole app.
 type Config struct {
 	UseSingleHTTPFrontend           bool                       `koanf:"useSingleHTTPFrontend"`
@@ -160,6 +170,7 @@ type Config struct {
 	InsecureAllowDumpActionMap      bool                       `koanf:"insecureAllowDumpActionMap"`
 	InsecureAllowDumpJwtClaims      bool                       `koanf:"insecureAllowDumpJwtClaims"`
 	Prometheus                      PrometheusConfig           `koanf:"prometheus"`
+	Security                        SecurityConfig             `koanf:"security"`
 	SaveLogs                        SaveLogsConfig             `koanf:"saveLogs"`
 	DefaultIconForActions           string                     `koanf:"defaultIconForActions"`
 	DefaultIconForDirectories       string                     `koanf:"defaultIconForDirectories"`
@@ -268,6 +279,11 @@ func DefaultConfigWithBasePort(basePort int) *Config {
 	config.InsecureAllowDumpJwtClaims = false
 	config.Prometheus.Enabled = false
 	config.Prometheus.DefaultGoMetrics = false
+	config.Security.HeaderContentSecurityPolicy = true
+	config.Security.ContentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'"
+	config.Security.HeaderXContentTypeOptions = true
+	config.Security.HeaderXFrameOptions = true
+	config.Security.XFrameOptions = "DENY"
 	config.DefaultIconForActions = "&#x1F600;"
 	config.DefaultIconForDirectories = "&#128193"
 	config.DefaultIconForBack = "&laquo;"

+ 21 - 2
service/internal/config/sanitize.go

@@ -16,6 +16,7 @@ func (cfg *Config) Sanitize() {
 	cfg.sanitizeAuthRequireGuestsToLogin()
 	cfg.sanitizeLogHistoryPageSize()
 	cfg.sanitizeLocalUserPasswords()
+	cfg.sanitizeSecurityHeaders()
 
 	// log.Infof("cfg %p", cfg)
 
@@ -183,6 +184,25 @@ func (cfg *Config) sanitizeLocalUserPasswords() {
 	}
 }
 
+func (cfg *Config) sanitizeSecurityHeaders() {
+	cfg.sanitizeSecurityHeadersCSP()
+	cfg.sanitizeSecurityHeadersXFrameOptions()
+}
+
+func (cfg *Config) sanitizeSecurityHeadersCSP() {
+	if !cfg.Security.HeaderContentSecurityPolicy || cfg.Security.ContentSecurityPolicy != "" {
+		return
+	}
+	cfg.Security.ContentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'"
+}
+
+func (cfg *Config) sanitizeSecurityHeadersXFrameOptions() {
+	if !cfg.Security.HeaderXFrameOptions || cfg.Security.XFrameOptions != "" {
+		return
+	}
+	cfg.Security.XFrameOptions = "DENY"
+}
+
 // parsePasswordTemplate expands {{ .Env.VAR }} in local user password fields using the process environment.
 func parsePasswordTemplate(source string) string {
 	t, err := template.New("password").Option("missingkey=error").Parse(source)
@@ -239,8 +259,7 @@ func (arg *ActionArgument) sanitize() {
 
 	arg.sanitizeNoType()
 
-	// TODO Validate the default against the type checker, but this creates a
-	// import loop
+	// Default value validation runs in executor at config load (validateArgumentDefaults).
 }
 
 func (arg *ActionArgument) sanitizeNoType() {

+ 0 - 23
service/internal/cors/cors.go

@@ -1,23 +0,0 @@
-package cors
-
-import (
-	log "github.com/sirupsen/logrus"
-	"net/http"
-)
-
-// AllowCors takes a HTTP handler and adds Access-Control-Allow-Origin headers to
-// responses.
-//
-// Note: HTTP OPTIONS requests (which need to be preflighted" for CORS) are not
-// handled because this app does not use HTTP PUT/PATCH/etc.
-func AllowCors(h http.Handler) http.Handler {
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		if origin := r.Header.Get("Origin"); origin != "" {
-			log.Debugf("Adding CORS header origin: %q", origin)
-
-			w.Header().Set("Access-Control-Allow-Origin", origin)
-		}
-
-		h.ServeHTTP(w, r)
-	})
-}

+ 0 - 22
service/internal/cors/cors_test.go

@@ -1,22 +0,0 @@
-package cors
-
-import (
-	"github.com/stretchr/testify/assert"
-	"net/http"
-	"net/http/httptest"
-	"testing"
-)
-
-func TestCors(t *testing.T) {
-	req, _ := http.NewRequest("GET", "/health-check", nil)
-	req.Header.Add("Origin", "1.2.3.4")
-
-	blat := AllowCors(http.FileServer(http.Dir(".")))
-
-	rr := httptest.NewRecorder()
-
-	blat.ServeHTTP(rr, req)
-
-	assert.Equal(t, http.StatusNotFound, rr.Code, "HTTP 404 on CORS")
-	assert.Equal(t, "1.2.3.4", rr.Header().Get("Access-Control-Allow-Origin"), "CORS Header set")
-}

+ 34 - 21
service/internal/executor/executor.go

@@ -603,14 +603,14 @@ func getExecutionsCount(rate config.RateSpec, req *ExecutionRequest) int {
 
 	then := time.Now().Add(-duration)
 
+	currentEntityPrefix := ""
+	if req.Binding != nil && req.Binding.Entity != nil {
+		currentEntityPrefix = req.Binding.Entity.UniqueKey
+	}
 	for _, logEntry := range req.executor.GetLogsByBindingId(req.Binding.ID) {
-		// FIXME
-		/*
-			if logEntry.EntityPrefix != req.EntityPrefix {
-				continue
-			}
-		*/
-
+		if logEntry.EntityPrefix != currentEntityPrefix {
+			continue
+		}
 		if logEntry.DatetimeStarted.After(then) && !logEntry.Blocked {
 
 			executions += 1
@@ -761,37 +761,50 @@ func fail(req *ExecutionRequest, err error) bool {
 func stepRequestAction(req *ExecutionRequest) bool {
 	metricActionsRequested.Inc()
 
-	// If there is no binding or action, do not proceed. Leave default
-	// log entry values (icon/title/id) and stop execution gracefully.
+	if !stepRequestActionHasBinding(req) {
+		return false
+	}
+
+	stepRequestActionPopulateLogEntry(req)
+	stepRequestActionRegisterLog(req)
+
+	log.WithFields(log.Fields{
+		"actionTitle": req.logEntry.ActionTitle,
+		"tags":        req.Tags,
+	}).Infof("Action requested")
+
+	notifyListenersStarted(req)
+
+	return true
+}
+
+func stepRequestActionHasBinding(req *ExecutionRequest) bool {
 	if req.Binding == nil || req.Binding.Action == nil {
 		log.Warnf("Action request has no binding/action; skipping execution")
 		return false
 	}
+	return true
+}
 
+func stepRequestActionPopulateLogEntry(req *ExecutionRequest) {
 	req.logEntry.Binding = req.Binding
 	req.logEntry.ActionConfigTitle = req.Binding.Action.Title
 	req.logEntry.ActionTitle = tpl.ParseTemplateOfActionBeforeExec(req.Binding.Action.Title, req.Binding.Entity)
 	req.logEntry.ActionIcon = req.Binding.Action.Icon
 	req.logEntry.Tags = req.Tags
+	if req.Binding.Entity != nil {
+		req.logEntry.EntityPrefix = req.Binding.Entity.UniqueKey
+	}
+}
 
+func stepRequestActionRegisterLog(req *ExecutionRequest) {
 	req.executor.logmutex.Lock()
+	defer req.executor.logmutex.Unlock()
 
 	if _, containsKey := req.executor.LogsByBindingId[req.Binding.ID]; !containsKey {
 		req.executor.LogsByBindingId[req.Binding.ID] = make([]*InternalLogEntry, 0)
 	}
-
 	req.executor.LogsByBindingId[req.Binding.ID] = append(req.executor.LogsByBindingId[req.Binding.ID], req.logEntry)
-
-	req.executor.logmutex.Unlock()
-
-	log.WithFields(log.Fields{
-		"actionTitle": req.logEntry.ActionTitle,
-		"tags":        req.Tags,
-	}).Infof("Action requested")
-
-	notifyListenersStarted(req)
-
-	return true
 }
 
 func stepLogStart(req *ExecutionRequest) bool {

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

@@ -41,7 +41,41 @@ type RebuildActionMapRequest struct {
 	DashboardActionTitles []string
 }
 
+func validateArgumentDefaults(cfg *config.Config) {
+	if cfg == nil {
+		return
+	}
+	for _, action := range cfg.Actions {
+		validateActionArgumentDefaults(action)
+	}
+}
+
+func validateActionArgumentDefaults(action *config.Action) {
+	if action == nil {
+		return
+	}
+	for i := range action.Arguments {
+		validateArgumentDefault(action, &action.Arguments[i])
+	}
+}
+
+func validateArgumentDefault(action *config.Action, arg *config.ActionArgument) {
+	if arg.Default == "" {
+		return
+	}
+	if err := ValidateArgument(arg, arg.Default, action); err != nil {
+		log.WithFields(log.Fields{
+			"actionTitle": action.Title,
+			"argName":     arg.Name,
+			"default":     arg.Default,
+			"error":       err,
+		}).Warn("Argument default value failed validation")
+	}
+}
+
 func (e *Executor) RebuildActionMap() {
+	validateArgumentDefaults(e.Cfg)
+
 	e.MapActionBindingsLock.Lock()
 
 	clear(e.MapActionBindings)

+ 35 - 1
service/internal/httpservers/frontend.go

@@ -23,6 +23,40 @@ import (
 	log "github.com/sirupsen/logrus"
 )
 
+func applySecurityHeaders(cfg *config.Config, w http.ResponseWriter) {
+	applyCSP(cfg, w)
+	applyXContentTypeOptions(cfg, w)
+	applyXFrameOptions(cfg, w)
+}
+
+func applyCSP(cfg *config.Config, w http.ResponseWriter) {
+	if !cfg.Security.HeaderContentSecurityPolicy || cfg.Security.ContentSecurityPolicy == "" {
+		return
+	}
+	w.Header().Set("Content-Security-Policy", cfg.Security.ContentSecurityPolicy)
+}
+
+func applyXContentTypeOptions(cfg *config.Config, w http.ResponseWriter) {
+	if !cfg.Security.HeaderXContentTypeOptions {
+		return
+	}
+	w.Header().Set("X-Content-Type-Options", "nosniff")
+}
+
+func applyXFrameOptions(cfg *config.Config, w http.ResponseWriter) {
+	if !cfg.Security.HeaderXFrameOptions || cfg.Security.XFrameOptions == "" {
+		return
+	}
+	w.Header().Set("X-Frame-Options", cfg.Security.XFrameOptions)
+}
+
+func securityHeadersMiddleware(cfg *config.Config, next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		applySecurityHeaders(cfg, w)
+		next.ServeHTTP(w, r)
+	})
+}
+
 func logDebugRequest(cfg *config.Config, source string, r *http.Request) {
 	if cfg.LogDebugOptions.SingleFrontendRequests {
 		log.Debugf("SingleFrontend HTTP Req URL %v: %q", source, r.URL)
@@ -96,7 +130,7 @@ func StartFrontendMux(cfg *config.Config, ex *executor.Executor) {
 
 	srv := &http.Server{
 		Addr:    cfg.ListenAddressSingleHTTPFrontend,
-		Handler: mux,
+		Handler: securityHeadersMiddleware(cfg, mux),
 	}
 
 	log.Fatal(srv.ListenAndServe())

+ 0 - 2
service/internal/httpservers/webuiServer.go

@@ -1,8 +1,6 @@
 package httpservers
 
 import (
-
-	//	cors "github.com/OliveTin/OliveTin/internal/cors"
 	"net/http"
 	"os"
 	"path"