James Read 14 giờ trước cách đây
mục cha
commit
1fbd215027
61 tập tin đã thay đổi với 2153 bổ sung và 926 xóa
  1. 1 1
      .github/PULL_REQUEST_TEMPLATE.md
  2. 8 1
      AGENTS.md
  3. 2 0
      Dockerfile.multiarches
  4. 2 0
      Dockerfile.singlearch
  5. 1 1
      README.md
  6. 2 2
      docs/modules/ROOT/nav.adoc
  7. 15 5
      docs/modules/ROOT/pages/api/intro.adoc
  8. 3 3
      docs/modules/ROOT/pages/api/login.adoc
  9. 7 5
      docs/modules/ROOT/pages/api/method_StartAction.adoc
  10. 12 2
      docs/modules/ROOT/pages/api/method_StartActionAndWait.adoc
  11. 13 19
      docs/modules/ROOT/pages/api/method_StartActionByGet.adoc
  12. 19 7
      docs/modules/ROOT/pages/api/method_StartActionByGetAndWait.adoc
  13. 16 6
      docs/modules/ROOT/pages/api/misc.adoc
  14. 96 41
      docs/modules/ROOT/pages/api/start_action.adoc
  15. 2 0
      docs/modules/ROOT/pages/index.adoc
  16. 10 0
      docs/modules/ROOT/pages/install/podmandocker.adoc
  17. 14 4
      docs/modules/ROOT/pages/integrations/homeassistant.adoc
  18. 2 0
      docs/modules/ROOT/pages/reference/network-ports.adoc
  19. 91 9
      docs/modules/ROOT/pages/security/oauth2_pocketid.adoc
  20. 1 1
      docs/modules/ROOT/pages/solutions/on-git-push/index.adoc
  21. 3 3
      docs/modules/ROOT/pages/troubleshooting/advanced.adoc
  22. 16 6
      docs/modules/ROOT/pages/troubleshooting/server-diagnostics.adoc
  23. 12 18
      docs/modules/ROOT/partials/api/start_action_methods.adoc
  24. 279 317
      frontend/package-lock.json
  25. 14 14
      frontend/package.json
  26. 7 1
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts
  27. 1 1
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js
  28. 177 165
      frontend/resources/vue/App.vue
  29. 7 11
      frontend/resources/vue/components/ConnectionBanner.vue
  30. 22 0
      frontend/resources/vue/utils/localeSelection.js
  31. 23 0
      frontend/resources/vue/utils/localeSelection.test.mjs
  32. 8 0
      integration-tests/mochaSetup.mjs
  33. 24 24
      integration-tests/package-lock.json
  34. 3 3
      integration-tests/package.json
  35. 51 0
      integration-tests/tests/authChangeEventStream/authChangeEventStream.mjs
  36. 46 0
      integration-tests/tests/authChangeEventStream/config.yaml
  37. 27 0
      integration-tests/tests/authRequireGuestsToLogin/authRequireGuestsToLogin.mjs
  38. 5 0
      integration-tests/tests/authRequireGuestsToLogin/config.yaml
  39. 22 0
      integration-tests/tests/topbarNavigation/config.yaml
  40. 38 0
      integration-tests/tests/topbarNavigation/topbarNavigation.mjs
  41. 2 2
      lang/go.mod
  42. 4 4
      lang/go.sum
  43. 9 3
      proto/olivetin/api/v1/olivetin.proto
  44. 1 1
      service/.golangci.yml
  45. 16 0
      service/gen/olivetin/api/v1/apiv1connect/olivetin.connect.go
  46. 4 4
      service/gen/olivetin/api/v1/olivetin.pb.go
  47. 51 50
      service/go.mod
  48. 108 106
      service/go.sum
  49. 29 3
      service/internal/api/api.go
  50. 21 0
      service/internal/api/api_deprecated.go
  51. 82 0
      service/internal/api/api_deprecated_test.go
  52. 30 0
      service/internal/api/api_init_search_hints_test.go
  53. 9 7
      service/internal/auth/otjwt/jwt.go
  54. 160 0
      service/internal/auth/otjwt/jwt_jwks_refresh_test.go
  55. 181 60
      service/internal/auth/otoauth2/restapi_auth_oauth2.go
  56. 252 12
      service/internal/auth/otoauth2/restapi_auth_oauth2_test.go
  57. 3 2
      service/internal/configcheck/rebuild.go
  58. 1 0
      service/internal/configissues/issue.go
  59. 47 0
      service/internal/configissues/runtime.go
  60. 30 0
      service/internal/configissues/runtime_test.go
  61. 11 2
      service/internal/webhooks/handler.go

+ 1 - 1
.github/PULL_REQUEST_TEMPLATE.md

@@ -13,7 +13,7 @@ Helpful information to understand the project can be found here: [CONTRIBUTING](
 # Checklist
 # Checklist
 Please put a X in the boxes as evidence of reading through the checklist.
 Please put a X in the boxes as evidence of reading through the checklist.
 
 
-- [ ] I have read the [CONTRIBUTORS](CONTRIBUTORS.adoc) guide
+- [ ] I have read the [CONTRIBUTING](https://github.com/OliveTin/OliveTin/blob/main/CONTRIBUTING.adoc) guide
   - [ ] I considered the "3 line" suggestion.
   - [ ] I considered the "3 line" suggestion.
   - [ ] I followed the "1 logical change" rule.
   - [ ] I followed the "1 logical change" rule.
 - [ ] I have forked the project, and raised this PR on a feature branch.
 - [ ] I have forked the project, and raised this PR on a feature branch.

+ 8 - 1
AGENTS.md

@@ -46,7 +46,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.
 - Do not swallow errors; propagate or log meaningfully.
 - Match existing formatting; avoid unrelated reformatting.
 - Match existing formatting; avoid unrelated reformatting.
 - Be safe around nils in executor steps (e.g., guard `req.Binding` and `req.Binding.Action`).
 - Be safe around nils in executor steps (e.g., guard `req.Binding` and `req.Binding.Action`).
-- Cyclomatic complexity over 4 is not permitted (`gocyclo` `min-complexity: 5` in `service/.golangci.yml`).
+- Cyclomatic complexity over 5 is not permitted (`gocyclo` `min-complexity: 5` in `service/.golangci.yml`).
 
 
 ### API and Execution Flow (High-level)
 ### API and Execution Flow (High-level)
 1. Client calls Connect RPC (e.g., `Init`, `GetDashboard`, `StartAction`).
 1. Client calls Connect RPC (e.g., `Init`, `GetDashboard`, `StartAction`).
@@ -54,6 +54,13 @@ If you are looking for OliveTin's AI policy, you can find it in `AI.md`.
 3. Executor runs a chain of steps: request binding → concurrency/rate/ACL checks → arg parsing → exec → post-exec → logging/triggering.
 3. Executor runs a chain of steps: request binding → concurrency/rate/ACL checks → arg parsing → exec → post-exec → logging/triggering.
 4. Logs are stored and can be fetched via `ExecutionStatus`/`GetLogs`.
 4. Logs are stored and can be fetched via `ExecutionStatus`/`GetLogs`.
 
 
+### Machine-readable API
+- The OliveTin API is completely machine readable: it is defined in Protobuf and served with Connect RPC (Connect JSON/binary and gRPC-Web).
+- Source of truth: `proto/olivetin/api/v1/olivetin.proto` (service `olivetin.api.v1.OliveTinApiService`), online at https://github.com/OliveTin/OliveTin/tree/main/proto
+- Prefer reading the proto over guessing endpoints. Each RPC is callable as `POST /api/<MethodName>` with a protobuf-JSON body (lowerCamelCase fields, eg `{"bindingId": "..."}`).
+- After changing the proto, regenerate code with `cd proto && make` (buf) — generated code lives in `service/gen/` and `frontend/resources/scripts/gen/`.
+- User docs for the API: `docs/modules/ROOT/pages/api/intro.adoc`. LLM-oriented docs index: https://docs.olivetin.app/llm.txt
+
 ### Common Tasks
 ### Common Tasks
 - Add/modify actions: update `config.yaml` and ensure `executor.RebuildActionMap()` is called when needed.
 - Add/modify actions: update `config.yaml` and ensure `executor.RebuildActionMap()` is called when needed.
 - Adjust dashboard rendering: see `service/internal/api/dashboards.go` and `apiActions.go`.
 - Adjust dashboard rendering: see `service/internal/api/dashboards.go` and `apiActions.go`.

+ 2 - 0
Dockerfile.multiarches

@@ -54,3 +54,5 @@ COPY var/helper-actions/* /usr/bin/
 USER olivetin
 USER olivetin
 
 
 ENTRYPOINT [ "/usr/bin/OliveTin" ]
 ENTRYPOINT [ "/usr/bin/OliveTin" ]
+
+HEALTHCHECK CMD curl -fsS "http://127.0.0.1:${PORT:-1337}/readyz"

+ 2 - 0
Dockerfile.singlearch

@@ -46,3 +46,5 @@ COPY var/helper-actions/* /usr/bin/
 USER olivetin
 USER olivetin
 
 
 ENTRYPOINT [ "/usr/bin/OliveTin" ]
 ENTRYPOINT [ "/usr/bin/OliveTin" ]
+
+HEALTHCHECK CMD curl -fsS "http://127.0.0.1:${PORT:-1337}/readyz"

+ 1 - 1
README.md

@@ -50,7 +50,7 @@ The AsciiDoc sources for that site live in this repository under [`docs/`](docs/
 * **Accessible** - passes all the accessibility checks in Firefox, and issues with accessibility are taken seriously.
 * **Accessible** - passes all the accessibility checks in Firefox, and issues with accessibility are taken seriously.
 * **Container** - available for quickly testing and getting it up and running, great for the selfhosted community.
 * **Container** - available for quickly testing and getting it up and running, great for the selfhosted community.
 * **Integrate with anything** - OliveTin just runs Linux shell commands, so theoretically you could integrate with a bunch of stuff just by using curl, ping, etc. However, writing your own shell scripts is a great way to extend OliveTin.
 * **Integrate with anything** - OliveTin just runs Linux shell commands, so theoretically you could integrate with a bunch of stuff just by using curl, ping, etc. However, writing your own shell scripts is a great way to extend OliveTin.
-* **Lightweight on resources** - uses only a few MB of RAM and barely any CPU. Written in Go, with a web interface written as a modern, responsive, Single Page App that uses the REST/Connect RPC API.
+* **Lightweight on resources** - uses only a few MB of RAM and barely any CPU. Written in Go, with a web interface written as a modern, responsive, Single Page App that uses the REST/Connect RPC API. The API is completely machine readable, defined in [Protobuf](https://github.com/OliveTin/OliveTin/tree/main/proto) (gRPC / Connect RPC), so it is easy to integrate with from scripts, generated clients, and AI agents.
 * **Good amount of unit tests and style checks** - helps potential contributors be consistent, and helps with maintainability.
 * **Good amount of unit tests and style checks** - helps potential contributors be consistent, and helps with maintainability.
 
 
 ## Screenshots
 ## Screenshots

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

@@ -182,8 +182,8 @@
 * xref:api/intro.adoc[API]
 * xref:api/intro.adoc[API]
 ** xref:api/start_action.adoc[Start Actions from the API]
 ** xref:api/start_action.adoc[Start Actions from the API]
 *** xref:api/method_StartAction.adoc[StartAction]
 *** xref:api/method_StartAction.adoc[StartAction]
-*** xref:api/method_StartActionByGet.adoc[StartActionByGet]
 *** xref:api/method_StartActionAndWait.adoc[StartActionAndWait]
 *** xref:api/method_StartActionAndWait.adoc[StartActionAndWait]
-*** xref:api/method_StartActionByGetAndWait.adoc[StartActionByGetAndWait]
+*** xref:api/method_StartActionByGet.adoc[StartActionByGet (deprecated)]
+*** xref:api/method_StartActionByGetAndWait.adoc[StartActionByGetAndWait (deprecated)]
 ** xref:api/misc.adoc[Misc API calls]
 ** xref:api/misc.adoc[Misc API calls]
 ** xref:api/login.adoc[Local user login via the API]
 ** xref:api/login.adoc[Local user login via the API]

+ 15 - 5
docs/modules/ROOT/pages/api/intro.adoc

@@ -6,13 +6,22 @@ This section of the documentation is intended for developers, and those who want
 **Short version**: 
 **Short version**: 
 
 
 * http://olivetinServer:1337/api on your OliveTin server to get the REST API. 
 * http://olivetinServer:1337/api on your OliveTin server to get the REST API. 
-* link:http://docs.olivetin.app/api/swagger/[Swagger] documents the API.
+* link:https://docs.olivetin.app/api/swagger/3k/[Swagger] documents the API.
 
 
-**Longer version**: The OliveTin API is formally defined using the Protobuf IDL, which generates gRPC stubs, as well as a REST Gateway. 
+**Longer version**: The OliveTin API is formally defined using the Protobuf IDL, in link:https://github.com/OliveTin/OliveTin/tree/main/proto[the `proto/` directory of the OliveTin repository]. Server and client code is generated from that definition, and the API is served using link:https://connectrpc.com[Connect RPC].
 
 
-The REST API gateway is used by the WebUI, and you can use it too by default - it is exposed at "/api" by default.
+The API is exposed at `/api/` on the single HTTP frontend (port 1337 by default), and is the same API used by the WebUI. You can call it with plain JSON over HTTP (eg: `curl http://olivetinServer:1337/api/WhoAmI --json '{}'`), with binary Protobuf, or with gRPC-Web clients.
 
 
-The gRPC API only listens on localhost default, but it can be set to listen publicly. See xref:reference/network-ports.adoc[the network ports documentation] for a better description of how the APIs are exposed. Most people do not need to use the gRPC API. 
+[#machine-readable-api]
+== Machine readable API (for scripts, integrations and AI agents)
+
+The OliveTin API is completely machine readable - every method, request and response message, field, and enum is defined in the Protobuf files. This makes it straightforward for code generators, API tooling, and LLMs / AI agents to discover and use the API without guessing.
+
+* **Source of truth**: link:https://github.com/OliveTin/OliveTin/tree/main/proto[`proto/`] on GitHub - the service is `olivetin.api.v1.OliveTinApiService` in link:https://github.com/OliveTin/OliveTin/blob/main/proto/olivetin/api/v1/olivetin.proto[`olivetin/api/v1/olivetin.proto`].
+* **Generate a client**: use link:https://buf.build[buf] or `protoc` with the Connect, gRPC, or gRPC-Web plugins for your language of choice (Go, TypeScript, Python, Java, etc). Set the client's base URL to `http://olivetinServer:1337/api` (not the server root) - the API is only served under `/api/`, and requests to `/olivetin.api.v1.OliveTinApiService/<MethodName>` at the root go to the web UI instead.
+* **Call it directly**: each unary RPC is available as `POST /api/<MethodName>` with a JSON body matching the request message, using protobuf JSON field names (lowerCamelCase, eg: `bindingId`). Server-streaming RPCs (`EventStream`) use the Connect streaming protocol (enveloped `application/connect+json` messages), so they cannot be called with a plain JSON request - use a generated Connect client for those.
+* **HTTP method**: every unary RPC is called with `POST` - a `GET` request returns `405 Method Not Allowed`.
+* **OpenAPI**: the Swagger / OpenAPI links below are generated from the Protobuf files, and are also available for tools that prefer OpenAPI.
 
 
 == Links
 == Links
 
 
@@ -20,7 +29,8 @@ The gRPC API only listens on localhost default, but it can be set to listen publ
 * link:http://docs.olivetin.app/api/swagger/OliveTin.openapi.json[OliveTin 2k API: OpenAPI JSON Definition]
 * link:http://docs.olivetin.app/api/swagger/OliveTin.openapi.json[OliveTin 2k API: OpenAPI JSON Definition]
 * link:http://docs.olivetin.app/api/swagger/3k/[OliveTin 3k API: Swagger UI]
 * link:http://docs.olivetin.app/api/swagger/3k/[OliveTin 3k API: Swagger UI]
 * link:http://docs.olivetin.app/api/swagger/3k/OliveTin.openapi.json[OliveTin 3k API: OpenAPI JSON Definition]
 * link:http://docs.olivetin.app/api/swagger/3k/OliveTin.openapi.json[OliveTin 3k API: OpenAPI JSON Definition]
-* link:https://github.com/OliveTin/OliveTin/blob/main/proto/olivetin/api/v1/olivetin.proto[The OliveTin Protobuf file]. 
+* link:https://github.com/OliveTin/OliveTin/tree/main/proto[The OliveTin Protobuf definitions (source)]
+* link:https://github.com/OliveTin/OliveTin/blob/main/proto/olivetin/api/v1/olivetin.proto[The OliveTin Protobuf file]
 
 
 Please do talk to the developers on Discord if you'd like help using the API, or you're thinking about building something interesting using the API!
 Please do talk to the developers on Discord if you'd like help using the API, or you're thinking about building something interesting using the API!
 
 

+ 3 - 3
docs/modules/ROOT/pages/api/login.adoc

@@ -2,7 +2,7 @@
 
 
 OliveTin supports serveral different ways to login, and most installations will probably login via reverse proxy, or via local user login.
 OliveTin supports serveral different ways to login, and most installations will probably login via reverse proxy, or via local user login.
 
 
-To login via local user login, you can use the following API call: `/LocalUserLogin`. This is documented in the API documentation which can be found here; https://docs.olivetin.app/api/ 
+To login via local user login, you can use the following API call: `/LocalUserLogin`. This is documented in the xref:api/intro.adoc[API documentation].
 
 
 The API call is a POST request, and you need to provide a `username` and `password` as a JSON object in the body of the request. Here is an example of how you can login via a cURL request;
 The API call is a POST request, and you need to provide a `username` and `password` as a JSON object in the body of the request. Here is an example of how you can login via a cURL request;
 
 
@@ -20,9 +20,9 @@ You can also that the body of the response is a JSON object with a simple `succe
 
 
 == Check login with `WhoAmI`
 == Check login with `WhoAmI`
 
 
-You can check your current login status by using the `/WhoAmI` API call. This is a GET request, and you need to provide the `olivetin-sid-local` cookie in the request. Here is an example of how you can check your login status via a cURL request;
+You can check your current login status by using the `/WhoAmI` API call. Like all API methods, this is a POST request with a JSON body (an empty object is fine), and you need to provide the `olivetin-sid-local` cookie in the request. Here is an example of how you can check your login status via a cURL request;
 
 
 ```bash
 ```bash
-user@host: curl -X GET http://localhost:1337/api/WhoAmI -H "accept: application/json" -H 'Content-Type: application/json' -b "olivetin-sid-local=cd33aa9c-c613-473e-8581-2b742716ab8e"
+user@host: curl http://localhost:1337/api/WhoAmI --json '{}' -b "olivetin-sid-local=cd33aa9c-c613-473e-8581-2b742716ab8e"
 {"authenticatedUser":"admin", "usergroup":"", "provider":"local", "acls":[], "sid":""}
 {"authenticatedUser":"admin", "usergroup":"", "provider":"local", "acls":[], "sid":""}
 ```
 ```

+ 7 - 5
docs/modules/ROOT/pages/api/method_StartAction.adoc

@@ -3,9 +3,11 @@
 This is the method the OliveTin web UI uses to start actions, and is probably the best method to use if you are writing scripts.
 This is the method the OliveTin web UI uses to start actions, and is probably the best method to use if you are writing scripts.
 
 
 * **HTTP Method**: `POST`
 * **HTTP Method**: `POST`
-* **Request Type**: OliveTin request object
+* **Request Type**: xref:api/start_action.adoc#api-request-startaction[StartAction request object] (`bindingId`, `arguments`, `uniqueTrackingId`, `justification`)
 * **Response Type**: Execution Tracking ID
 * **Response Type**: Execution Tracking ID
 
 
+NOTE: `StartAction` selects the action with `bindingId`, not `actionId`. For a normal action, the binding ID is the same as the action `id` from your `config.yaml`. See xref:api/start_action.adoc#api-ids[Action IDs vs Binding IDs].
+
 There are several variants of this API call available which might be easier for scripts (or humans) to work with:
 There are several variants of this API call available which might be easier for scripts (or humans) to work with:
 
 
 include::partial$api/start_action_methods.adoc[]
 include::partial$api/start_action_methods.adoc[]
@@ -15,14 +17,15 @@ include::partial$api/start_action_methods.adoc[]
 [source,bash]
 [source,bash]
 .curl
 .curl
 ----
 ----
-user@host: curl "http://olivetin.webapps.teratan.lan/api/StartAction" --json '{"bindingId": "nuclear_reactor_shutdown"}'
+user@host: curl "http://olivetin.example.com:1337/api/StartAction" --json '{"bindingId": "nuclear_reactor_shutdown"}'
+{"executionTrackingId":"6ecda25d-b6e1-4621-898c-d4e6f2dbeda5"}
 ----
 ----
 
 
 [source,powershell]
 [source,powershell]
 .Powershell
 .Powershell
 ----
 ----
 PS C:\Users\xcons> $json = '{"bindingId": "deploy_attack_gnomes"}'
 PS C:\Users\xcons> $json = '{"bindingId": "deploy_attack_gnomes"}'
-PS C:\Users\xcons> Invoke-RestMethod -Method "Post" -Uri "http://olivetinServer:1337/api/StartAction" -Body $json 
+PS C:\Users\xcons> Invoke-RestMethod -Method "Post" -Uri "http://olivetinServer:1337/api/StartAction" -ContentType "application/json" -Body $json
 ----
 ----
 
 
 == Example API call: Start an action using `StartAction` with arguments
 == Example API call: Start an action using `StartAction` with arguments
@@ -30,6 +33,5 @@ PS C:\Users\xcons> Invoke-RestMethod -Method "Post" -Uri "http://olivetinServer:
 [source,bash]
 [source,bash]
 .curl
 .curl
 ----
 ----
-user:host: curl 'http://olivetin.example.com/api/StartAction' --json '{"bindingId": "Ping_host", "arguments": [{"name": "host", "value": "example.com"},{"name": "count", "value": "1"}]}'
+user@host: curl 'http://olivetin.example.com:1337/api/StartAction' --json '{"bindingId": "Ping_host", "arguments": [{"name": "host", "value": "example.com"},{"name": "count", "value": "1"}]}'
 ----
 ----
-

+ 12 - 2
docs/modules/ROOT/pages/api/method_StartActionAndWait.adoc

@@ -3,10 +3,20 @@
 This method is useful is you are writing a script, and want to wait for the action to finish so that you can check the result, or get the output.
 This method is useful is you are writing a script, and want to wait for the action to finish so that you can check the result, or get the output.
 
 
 * **HTTP Method**: `POST`
 * **HTTP Method**: `POST`
-* **Request Type**: OliveTin request object
+* **Request Type**: xref:api/start_action.adoc#api-request-startactionandwait[StartActionAndWait request object] (`actionId`, `arguments`, `justification`)
 * **Response Type**: Log Entry (waits for the action to finish)
 * **Response Type**: Log Entry (waits for the action to finish)
 
 
 There are several variants of this API call available which might be easier for scripts (or humans) to work with:
 There are several variants of this API call available which might be easier for scripts (or humans) to work with:
 
 
-
 include::partial$api/start_action_methods.adoc[]
 include::partial$api/start_action_methods.adoc[]
+
+== Example API call: Start an action with arguments, and wait for it to finish
+
+[source,bash]
+.curl
+----
+user@host: curl 'http://olivetin.example.com:1337/api/StartActionAndWait' --json '{"actionId": "Ping_host", "arguments": [{"name": "host", "value": "example.com"}]}'
+{"logEntry":{"actionTitle":"Ping host","output":"...","exitCode":0, ... }}
+----
+
+See xref:api/start_action.adoc#api-response-logentry[Response type: LogEntry] for the full response.

+ 13 - 19
docs/modules/ROOT/pages/api/method_StartActionByGet.adoc

@@ -1,29 +1,23 @@
-= API Method: StartActionByGet
+= API Method: StartActionByGet (deprecated)
 
 
-This is the method that allows you to specify the action ID in the URL, and is probably the best to do quick integrations - QR Codes, streamdeck, etc. You cannot pass arguments using this method.
+[WARNING]
+====
+`StartActionByGet` is **deprecated** and will be removed in a future version of OliveTin. Use xref:api/method_StartAction.adoc[StartAction] instead.
 
 
-* **HTTP Method**: `GET`
-* **Request Type**: Action ID in the URL
-* **Response Type**: Execution Tracking ID
+Despite its name, this method no longer supports `GET` requests. The OliveTin 2k style of putting the action ID in the URL (eg: `GET /api/StartActionByGet/pingGithub`) returns `404` in OliveTin 3k, and is not coming back.
+====
 
 
-include::partial$api/start_action_methods.adoc[]
+== Migrating to `StartAction`
 
 
-[#api-eg-startIdUrl]
-== Example API call; Start an action by ID in the URL
+`StartActionByGet` takes only an `actionId`, and returns an Execution Tracking ID. `StartAction` does the same with `bindingId` - for a normal action, the binding ID is the same as the action `id` from your `config.yaml` (see xref:api/start_action.adoc#api-ids[Action IDs vs Binding IDs]).
 
 
-.curl
+.Before (deprecated)
 ----
 ----
-user@host: curl http://olivetin.example.com/api/StartActionByGet/pingGithub
+user@host: curl http://olivetin.example.com:1337/api/StartActionByGet --json '{"actionId": "pingGithub"}'
 ----
 ----
 
 
-The corresponding config.yaml would look like this;
-
-[source,yaml]
+.After
 ----
 ----
-actions:
-  - title: Ping GitHub.com
-    id: pingGithub
-    shell: ping github.com -c 1
+user@host: curl http://olivetin.example.com:1337/api/StartAction --json '{"bindingId": "pingGithub"}'
+{"executionTrackingId":"ff7d6f13-af8f-4ebe-8174-ad4cc7197b45"}
 ----
 ----
-
-IDs are used by these API calls, as you probably want the interface to display a human-readable title, whereas the API call doesn't want to have spaces or punctuation.

+ 19 - 7
docs/modules/ROOT/pages/api/method_StartActionByGetAndWait.adoc

@@ -1,11 +1,23 @@
-= API Method: StartActionByGetAndWait
+= API Method: StartActionByGetAndWait (deprecated)
 
 
-This method is also a very easy way to quickly start an action, but it waits for the action to finish before returning the result. This is useful if you want to get the output of the action or check its result without having to poll for it.
+[WARNING]
+====
+`StartActionByGetAndWait` is **deprecated** and will be removed in a future version of OliveTin. Use xref:api/method_StartActionAndWait.adoc[StartActionAndWait] instead.
 
 
-* **HTTP Method**: `GET`
-* **Request Type**: Action ID in the URL
-* **Response Type**: Log Entry (waits for the action to finish)
+Despite its name, this method no longer supports `GET` requests. The OliveTin 2k style of putting the action ID in the URL (eg: `GET /api/StartActionByGetAndWait/pingGithub`) returns `404` in OliveTin 3k, and is not coming back.
+====
 
 
-There are several variants of this API call available which might be easier for scripts (or humans) to work with:
+== Migrating to `StartActionAndWait`
 
 
-include::partial$api/start_action_methods.adoc[]
+`StartActionAndWait` takes the same `actionId` field, and returns the same Log Entry, so you only need to change the method name. It also accepts `arguments` and a `justification`.
+
+.Before (deprecated)
+----
+user@host: curl http://olivetin.example.com:1337/api/StartActionByGetAndWait --json '{"actionId": "pingGithub"}'
+----
+
+.After
+----
+user@host: curl http://olivetin.example.com:1337/api/StartActionAndWait --json '{"actionId": "pingGithub"}'
+{"logEntry":{"actionTitle":"Ping GitHub.com","output":"...","exitCode":0, ... }}
+----

+ 16 - 6
docs/modules/ROOT/pages/api/misc.adoc

@@ -1,20 +1,30 @@
 = Misc API calls
 = Misc API calls
 
 
-== Example API call: Get the dashboard buttons ("components")
+== Example API call: Get the dashboard buttons
+
+`GetDashboard` returns the dashboard and its action buttons, including each action's `bindingId`. Send an empty object for the default dashboard, or set `title` to request a specific dashboard.
 
 
 .curl
 .curl
 ----
 ----
-user@host: curl http://olivetinServer:1337/api/GetDashboardComponents 
+user@host: curl http://olivetinServer:1337/api/GetDashboard --json '{}'
 ----
 ----
 
 
 == Example API call: readyz Healthcheck
 == Example API call: readyz Healthcheck
 
 
-This is useful for configuring healthchecks in docker containers, or on Kubernetes.
+This is useful for configuring healthchecks in docker containers, or on Kubernetes. The simplest option is the plain HTTP `/readyz` endpoint (not under `/api/`), which works with a `GET` request;
+
+.curl
+----
+user@host: curl http://olivetinServer:1337/readyz
+OK. Single HTTP Frontend is ready.
+----
+
+There is also an API method, `GetReadyz`;
 
 
 .curl
 .curl
 ----
 ----
-user@host: curl http://olivetinServer:1337/api/readyz
-{"status": "ok"}
+user@host: curl http://olivetinServer:1337/api/GetReadyz --json '{}'
+{"status":"OK"}
 ----
 ----
 
 
-The response will always be "status: ok" to indicate that the API is up, or it will timeout.
+Either will respond with a `200` status to indicate that OliveTin is up, or it will timeout.

+ 96 - 41
docs/modules/ROOT/pages/api/start_action.adoc

@@ -1,57 +1,83 @@
 [#api-start-action]
 [#api-start-action]
 = Starting Actions from the API
 = Starting Actions from the API
 
 
-There are several variants of this API call available which might be easier for scripts (or humans) to work with!
+There are two API methods for starting actions: `StartAction`, which returns straight away, and `StartActionAndWait`, which waits for the action to finish.
 
 
 include::partial$api/start_action_methods.adoc[]
 include::partial$api/start_action_methods.adoc[]
 
 
-[#api-request-idurl]
-== Request type: Action ID in the URL
+All request and response field names below are the protobuf JSON names (lowerCamelCase) from the link:https://github.com/OliveTin/OliveTin/blob/main/proto/olivetin/api/v1/olivetin.proto[OliveTin Protobuf definition], which is the source of truth for the API.
 
 
-Used by:
+[#api-ids]
+== Action IDs vs Binding IDs
 
 
-*** xref:api/method_StartActionByGet.adoc[StartActionByGet]
-*** xref:api/method_StartActionByGetAndWait.adoc[StartActionByGetAndWait]
+* `actionId` is the `id` you set on the action in your `config.yaml`. To find your Action ID, and understand how Action IDs work, see the xref:action_customization/ids.adoc[Action ID] documentation.
+* `bindingId` identifies one button/instance of an action. For a normal action this is the same as the action ID. For actions generated from xref:entities/intro.adoc[entities], each entity gets its own binding ID. Binding IDs are returned by `GetDashboard` (as `action.bindingId`).
 
 
-If you are trying to integrate OliveTin with your own scripts or processes, it's probably easiest to start actions by using an ID directly in the URL, xref:api/method_StartActionByGet.adoc#api-eg-startIdUrl[see the example].
+NOTE: Despite its name, the `actionId` field used by `StartActionAndWait` is looked up as a binding ID. For normal actions this makes no difference, but for entity-generated actions you must send the entity's binding ID - sending the configured action `id` returns `not_found`.
 
 
-[#api-request-obj]
-== Request type: OliveTin request object
+[#api-request-startaction]
+== Request type: StartAction request object
 
 
 Used by:
 Used by:
 
 
 *** xref:api/method_StartAction.adoc[StartAction]
 *** xref:api/method_StartAction.adoc[StartAction]
-*** xref:api/method_StartActionAndWait.adoc[StartActionAndWait]
+
+This is the request the OliveTin web UI uses, and gives you the most control over the execution.
 
 
 [source,json]
 [source,json]
-.OliveTin request object structure
+.`StartActionRequest`
 ----
 ----
 {
 {
-  "actionId": "string",
+  "bindingId": "string",
   "arguments": [
   "arguments": [
     {
     {
       "name": "string",
       "name": "string",
       "value": "string"
       "value": "string"
     }
     }
   ],
   ],
-  "uniqueTrackingId": "string"
+  "uniqueTrackingId": "string",
+  "justification": "string"
 }
 }
 ----
 ----
 
 
-To find your Action ID, and understand how Action IDs work, see the xref:action_customization/ids.adoc[Action ID] documentation
+* `bindingId` (required) - see <<api-ids>>.
+* `arguments` (optional) - values for the action's xref:args/intro.adoc[arguments].
+* `uniqueTrackingId` (optional) - if you set this, it is used as the execution tracking ID, otherwise one is generated for you. It must be up to 36 characters of hex digits and dashes (eg: a UUID), and must not already have been used. An invalid or previously used ID is silently replaced with a generated UUID, so always track the execution using the `executionTrackingId` returned in the response.
+* `justification` (optional) - the reason for running the action, for actions that require a justification.
 
 
-If you need more control over the execution, then the only other option is to submit a `OliveTin reqjest object`, which is just a very simple JSON structure like this;
+[source,json]
+.Example
+----
+{
+  "bindingId": "generate_cryptocurrency",
+  "arguments": [],
+  "uniqueTrackingId": "0f8b1c2e-6d3a-4e5f-9a7b-1c2d3e4f5a6b"
+}
+----
+
+[#api-request-startactionandwait]
+== Request type: StartActionAndWait request object
+
+Used by:
+
+*** xref:api/method_StartActionAndWait.adoc[StartActionAndWait]
 
 
 [source,json]
 [source,json]
+.`StartActionAndWaitRequest`
 ----
 ----
 {
 {
-    "actionId": "Generate cryptocurrency",
-    "arguments": [],
-    "uniqueTrackingId": "my-tracking-id",
+  "actionId": "string",
+  "arguments": [
+    {
+      "name": "string",
+      "value": "string"
+    }
+  ],
+  "justification": "string"
 }
 }
 ----
 ----
 
 
-More detailed examples can be seen below.
+Note that this request selects the action with `actionId` (not `bindingId`), and does not accept a `uniqueTrackingId`.
 
 
 [#api-response-trackingid]
 [#api-response-trackingid]
 == Response type: Execution Tracking ID
 == Response type: Execution Tracking ID
@@ -59,7 +85,6 @@ More detailed examples can be seen below.
 Used by:
 Used by:
 
 
 *** xref:api/method_StartAction.adoc[StartAction]
 *** xref:api/method_StartAction.adoc[StartAction]
-*** xref:api/method_StartActionByGet.adoc[StartActionByGet]
 
 
 .Example Execution Tracking ID response
 .Example Execution Tracking ID response
 [source,json]
 [source,json]
@@ -73,31 +98,61 @@ Used by:
 Used by:
 Used by:
 
 
 *** xref:api/method_StartActionAndWait.adoc[StartActionAndWait]
 *** xref:api/method_StartActionAndWait.adoc[StartActionAndWait]
-*** xref:api/method_StartActionByGetAndWait.adoc[StartActionByGetAndWait]
+
+The `output` field contains the combined stdout and stderr of the action.
 
 
 .Example log entry
 .Example log entry
 [source,json]
 [source,json]
 ----
 ----
 {
 {
-    "logEntry": {
-        "datetimeStarted": "2024-02-27 14:14:49",
-        "actionTitle": "Restart httpd on server1",
-        "stdout": "",
-        "stderr": "",
-        "timedOut": true,
-        "exitCode": -1,
-        "user": "",
-        "userClass": "",
-        "actionIcon": "&#128260;",
-        "tags": [
-
-        ],
-        "executionTrackingId": "b04b1e90-d457-4158-b7dc-da9e81f21568",
-        "datetimeFinished": "2024-02-27 14:14:52",
-        "actionId": "restart_httpd",
-        "executionStarted": true,
-        "executionFinished": true,
-        "blocked": false
-    }
+  "logEntry": {
+    "datetimeStarted": "2026-11-12 11:56:29",
+    "actionTitle": "Restart httpd on server1",
+    "output": "Restarting httpd...\n",
+    "timedOut": false,
+    "exitCode": 0,
+    "user": "guest",
+    "userClass": "",
+    "actionIcon": "&#128260;",
+    "tags": [],
+    "executionTrackingId": "56588051-80da-4b31-8cd8-461a4566e3a9",
+    "datetimeFinished": "2026-11-12 11:56:31",
+    "executionStarted": true,
+    "executionFinished": true,
+    "blocked": false,
+    "datetimeIndex": "4",
+    "canKill": false,
+    "datetimeRateLimitExpires": "",
+    "bindingId": "restart_httpd",
+    "queued": false,
+    "queuedForGroup": "",
+    "justification": "",
+    "arguments": [
+      {
+        "name": "server",
+        "value": "server1"
+      }
+    ]
+  }
 }
 }
 ----
 ----
+
+[#api-deprecated-byget]
+== Deprecated: StartActionByGet and StartActionByGetAndWait
+
+`StartActionByGet` and `StartActionByGetAndWait` are deprecated, and will be removed in a future version of OliveTin. They only accept an `actionId` (no arguments), and despite their names they no longer support `GET` requests - the OliveTin 2k style of putting the action ID in the URL returns `404`.
+
+When either method is called, OliveTin shows a warning on the xref:advanced_configuration/diagnostics.adoc[Diagnostics] page (code `deprecated_api_call`), so you can find integrations that still need updating. The warning is cleared when OliveTin restarts.
+
+[%header]
+|===
+| Deprecated method | Use instead | Change
+
+| xref:api/method_StartActionByGet.adoc[StartActionByGet]
+| xref:api/method_StartAction.adoc[StartAction]
+| Send `{"bindingId": "..."}` instead of `{"actionId": "..."}`
+
+| xref:api/method_StartActionByGetAndWait.adoc[StartActionByGetAndWait]
+| xref:api/method_StartActionAndWait.adoc[StartActionAndWait]
+| Change the method name only - the request is the same
+|===

+ 2 - 0
docs/modules/ROOT/pages/index.adoc

@@ -47,3 +47,5 @@ The OliveTin documentation site supports the llm.txt and llm-full.txt files, ava
 
 
 * link:https://docs.olivetin.app/llm.txt[llm.txt]
 * link:https://docs.olivetin.app/llm.txt[llm.txt]
 * link:https://docs.olivetin.app/llm-full.txt[llm-full.txt]
 * link:https://docs.olivetin.app/llm-full.txt[llm-full.txt]
+
+The OliveTin API is completely machine readable. It is defined in Protobuf (gRPC / Connect RPC), and the definitions are the source of truth for every method and message - see link:https://github.com/OliveTin/OliveTin/tree/main/proto[`proto/` on GitHub] and xref:api/intro.adoc#machine-readable-api[the API overview].

+ 10 - 0
docs/modules/ROOT/pages/install/podmandocker.adoc

@@ -29,4 +29,14 @@ OliveTin does not need to be run as a root, and does not need any special capabi
 
 
 To change the xref:advanced_configuration/timezones.adoc[changing the timezone requires a bound-mount] from the host. Olivetin ignores the TZ variable as it is non-standard.
 To change the xref:advanced_configuration/timezones.adoc[changing the timezone requires a bound-mount] from the host. Olivetin ignores the TZ variable as it is non-standard.
 
 
+[#container-healthcheck]
+== Container healthcheck
+
+The OliveTin container image includes a Docker `HEALTHCHECK` that requests `http://127.0.0.1:$PORT/readyz` inside the container, with `PORT` defaulting to `1337`. The healthcheck cannot read your `config.yaml`, so if you change the port with `listenAddressSingleHTTPFrontend`, either:
+
+* set the xref:reference/network-ports.adoc[`PORT` environment variable] to the same port (`PORT` also overrides the port in `config.yaml`, so the two always match), or
+* override the healthcheck in your deployment (eg: `--health-cmd` with `docker run`, or `healthcheck:` in Docker Compose).
+
+Otherwise the container is reported as `unhealthy`, even though OliveTin is running normally.
+
 include::partial$install/post_container.adoc[]
 include::partial$install/post_container.adoc[]

+ 14 - 4
docs/modules/ROOT/pages/integrations/homeassistant.adoc

@@ -19,9 +19,17 @@ actions:
     shell: ssh user@server "sudo systemctl suspend"
     shell: ssh user@server "sudo systemctl suspend"
 ----
 ----
 
 
-You then need to know the URL to call to trigger this action. This URL is the OliveTin API URL, with the action ID appended to it. For example, if your OliveTin is running at `http://yourserver:1337`, the URL to call to trigger the action above would be `http://yourserver:1337/api/StartActionAndWait/server_sleep`.
+You then need to know the URL to call to trigger this action. This is the OliveTin API URL plus the method name, and the action is selected in a JSON body. For example, if your OliveTin is running at `http://yourserver:1337`, you would `POST` `{"bindingId": "server_sleep"}` to `http://yourserver:1337/api/StartAction`. (For a normal action, the `bindingId` is the same as the action `id`.)
 
 
-You can learn more about starting actions via the OliveTin API by reading the link xref:api/start_action.adoc[Starting Actions via the API] page, but the method "StartActionAndWait" is the one you will want to use for Home Assistant.
+You can learn more about starting actions via the OliveTin API by reading the link xref:api/start_action.adoc[Starting Actions via the API] page, but the method "StartAction" is the one you will want to use for Home Assistant. It returns as soon as the action has started.
+
+[NOTE]
+====
+Avoid `StartActionAndWait` for Home Assistant unless you need the action's output:
+
+* It only responds once the action has finished, and Home Assistant's `rest_command` gives up after 10 seconds by default, so longer actions are reported as failed even though they ran.
+* It checks that the caller can view the action's logs *after* the action has run. Without the `logs` permission, Home Assistant gets an error even though the action ran, and retrying runs it again.
+====
 
 
 == Add the REST API call to Home Assistant
 == Add the REST API call to Home Assistant
 
 
@@ -45,8 +53,10 @@ From the file editor now in your sidebar, browse the filesystem to the configura
 ----
 ----
 rest_command:
 rest_command:
   olivetin_sleep_mindstorm:
   olivetin_sleep_mindstorm:
-    url: http://olivetin.webapps.teratan.lan/api/StartActionByGetAndWait/server_sleep
-    method: get
+    url: http://olivetin.example.com:1337/api/StartAction
+    method: post
+    content_type: "application/json"
+    payload: '{"bindingId": "server_sleep"}'
 ----
 ----
 
 
 You save the file, and restart Home Assistant to pick up the changes.
 You save the file, and restart Home Assistant to pick up the changes.

+ 2 - 0
docs/modules/ROOT/pages/reference/network-ports.adoc

@@ -57,6 +57,8 @@ If the `PORT` environment variable is set at startup, OliveTin uses it as the li
 
 
 When `PORT` is not set, OliveTin uses `listenAddressSingleHTTPFrontend` from the config, or `0.0.0.0:1337` if that setting is omitted.
 When `PORT` is not set, OliveTin uses `listenAddressSingleHTTPFrontend` from the config, or `0.0.0.0:1337` if that setting is omitted.
 
 
+The container image's healthcheck also uses `PORT` (default `1337`), so if you run the container on a different port, see xref:install/podmandocker.adoc#container-healthcheck[Container healthcheck].
+
 == See also
 == See also
 
 
 * xref:reference/multiple_instances[Running Multiple instances of OliveTin on the same server]
 * xref:reference/multiple_instances[Running Multiple instances of OliveTin on the same server]

+ 91 - 9
docs/modules/ROOT/pages/security/oauth2_pocketid.adoc

@@ -1,14 +1,83 @@
 [#oauth2-pocketid]
 [#oauth2-pocketid]
 = OAuth2 - Pocket ID
 = OAuth2 - Pocket ID
 
 
-OliveTin has been tested with Pocket ID. This documentation page describes how to configure Pocket ID for use with OliveTin and assumes you already have Pocket ID installed and running.
+OliveTin has been tested with Pocket ID. This documentation page describes how to configure Pocket ID for use with OliveTin and assumes you already have Pocket ID installed and running. It allows guests access to some actions, and admins access to all actions.
 
 
-== Configuration
+This example uses Pocket ID v2.19.2. Please check the Pocket ID docs if you are running a newer version.
+
+== Pocket ID Configuration:
+
+==== Create a new user group:
+Assign users to this group that will have access to all olivetin actions. We will add a custom claim that is sent to Olivetin at login. +
+
+-> Administration -> User Groups -> Add Group +
+
+[cols="1,2"]
+|===
+
+| Name | `olivetin_admins`
+
+| Friendly Name | `Olivetin Admins`
+|===
+
+-> Custom Claims
+
+[cols="1,2", options="header"]
+|===
+
+| Key | Value
+
+| olivetin_group
+| olivetin_admin
+
+|===
+
+==== Create a new client:
+
+-> Administration -> OIDC Clients -> Add OIDC Client +
+
+[cols="1,2"]
+|===
+
+| Name | `Olivetin`
+
+| Client Type | Confidential Client
+|===
+
+-> Callback URLs -> + Add callback URL +
+
+[cols="1,1"]
+|===
+
+| Callback URLs | `\https://olivetin.example.com/oauth/callback`
+|===
+
+-> Create
+
+-> Access Tab: +
+
+Allowed User Groups -> Selected user groups +
+-> `olivetin_admins` -> Save +
+
+Copy the generated Client ID and Client Secret from the General tab into olivetin's config.yaml (example below). The secret will only be displayed once.
+
+== Olivetin Configuration
 
 
 .config.yaml
 .config.yaml
 [source,yaml]
 [source,yaml]
 ----
 ----
-authRequireGuestsToLogin: true
+authRequireGuestsToLogin: false
+
+defaultPermissions:
+  view: false
+  exec: false
+  logs: false
+
+# showDiagnostics and showLogList default to true for everyone (including
+# guests), so turn them off here and enable them for admins only below.
+defaultPolicy:
+  showDiagnostics: false
+  showLogList: false
 
 
 accessControlLists:
 accessControlLists:
   - name: admin
   - name: admin
@@ -16,13 +85,21 @@ accessControlLists:
       view: true
       view: true
       exec: true
       exec: true
       logs: true
       logs: true
+    policy:
+      showDiagnostics: true
+      showLogList: true
     matchUsergroups:
     matchUsergroups:
-      # Since you can't map properties in userinfo response from Pocket ID I am kind of cheating here:
-      # only I will be able to log in and so I return the "preferred_username" as the group, and I configure my
-      # own username as the "Usergroup" to mean "admin"
-      - myusername
+      - olivetin_admin
     addToEveryAction: true
     addToEveryAction: true
 
 
+  - name: "guests"
+    permissions:
+      view: true
+      exec: true
+      logs: false
+    matchUsernames: [ "guest" ]
+    addToEveryAction: false
+
 authLocalUsers:
 authLocalUsers:
   enabled: false
   enabled: false
 
 
@@ -43,12 +120,17 @@ authOAuth2Providers:
       - profile
       - profile
       - email
       - email
     usernameField: preferred_username
     usernameField: preferred_username
-    userGroupField: preferred_username
+    userGroupField: olivetin_group
     insecureSkipVerify: true
     insecureSkipVerify: true
 
 
 actions:
 actions:
   - title: "Hello world!"
   - title: "Hello world!"
-    shell: echo 'Hello World!'
+    shell: echo 'Hello World! I have guest access'
+    acls:
+      - "guests"
+  - title: "Admins only"
+    shell: echo 'Hello World! I am an admin'
+
 ----
 ----
 
 
 == Pocket ID config
 == Pocket ID config

+ 1 - 1
docs/modules/ROOT/pages/solutions/on-git-push/index.adoc

@@ -70,7 +70,7 @@ function run_olivetin_action() {
 
 
         OLIVETIN_REQUEST="$(cat <<EOF
         OLIVETIN_REQUEST="$(cat <<EOF
 {
 {
-  "actionId": "$ACTION_NAME",
+  "bindingId": "$ACTION_NAME",
   "arguments": [
   "arguments": [
         {
         {
                 "name": "commit",
                 "name": "commit",

+ 3 - 3
docs/modules/ROOT/pages/troubleshooting/advanced.adoc

@@ -9,15 +9,15 @@ All these configuration options are `false` by default, and should be deleted fr
 
 
 [#dump-server-diagnostics]
 [#dump-server-diagnostics]
 == Dump server diagnostics
 == Dump server diagnostics
-`insecureAllowDumpServerDiagnostics: true` - will allow dumping xref:troubleshooting/server-diagnostics.adoc[server diagnostics] as plain text when visiting `http://server:1337/api/ServerDiagnostics`
+`insecureAllowDumpServerDiagnostics: true` - will allow dumping xref:troubleshooting/server-diagnostics.adoc[server diagnostics] via the `ServerDiagnostics` API method, eg: `curl http://server:1337/api/ServerDiagnostics --json '{}'`
 
 
 [#dump-action-map]
 [#dump-action-map]
 == Dump Action Map
 == Dump Action Map
-`InsecureAllowDumpActionMap: true` - will allow dumping all the actions (and those generated with entities) and their public IDs, eg: `http://server:1337/api/DumpActionMap`
+`insecureAllowDumpActionMap: true` - will allow dumping all the actions (and those generated with entities) and their public IDs, eg: `curl http://server:1337/api/DumpPublicIdActionMap --json '{}'`
 
 
 [#dump-vars]
 [#dump-vars]
 == Dump Vars
 == Dump Vars
-`InsecureAllowDumpVars: true` - will allow dumping all the "string variables" from a map that is mainly used for entities, eg: `http://server:1337/api/DumpVars`
+`insecureAllowDumpVars: true` - will allow dumping all the "string variables" from a map that is mainly used for entities, eg: `curl http://server:1337/api/DumpVars --json '{}'`. The variables are written to the OliveTin process standard output (ie: the OliveTin logs); the API response only contains an alert.
 
 
 [#dump-jwt]
 [#dump-jwt]
 == Dump JWT Claims
 == Dump JWT Claims

+ 16 - 6
docs/modules/ROOT/pages/troubleshooting/server-diagnostics.adoc

@@ -33,15 +33,18 @@ You can then copy and paste this text into a GitHub issue, discussion, Discord c
 
 
 == How do I generate server diagnostics?
 == How do I generate server diagnostics?
 
 
-OliveTin needs to be able to start and its API needs to be functional. Once OliveTin is started, open this URL in a browser (replace the host and port with yours):
+OliveTin needs to be able to start and its API needs to be functional. Once OliveTin is started, call the `ServerDiagnostics` API method (replace the host and port with yours):
 
 
-`http://myserver:1337/api/ServerDiagnostics`
+[source,bash]
+----
+curl http://myserver:1337/api/ServerDiagnostics --json '{}'
+----
 
 
-The `/api/ServerDiagnostics` path is the HTTP endpoint OliveTin uses for server diagnostics.
+Like all OliveTin API methods, `/api/ServerDiagnostics` must be called with `POST` - simply opening the URL in a browser sends a `GET`, which returns `405 Method Not Allowed`.
 
 
-== Optional: Allow insecure (but easy) dumping to the browser
+== Optional: Allow insecure (but easy) dumping in the API response
 
 
-There is a configuration option you can set in your `config.yaml` that allows you to easily dump server diagnostics to your browser when visiting the API. This is turned off by default, as you should not allow anybody to request diagnostics at any time they like, but you can enable this option temporarily to easily get access to the text in your browser.
+There is a configuration option you can set in your `config.yaml` that allows the API to return server diagnostics directly in the response, instead of only writing them to the logs. This is turned off by default, as you should not allow anybody to request diagnostics at any time they like, but you can enable this option temporarily to easily get access to the text.
 
 
 [source,yaml]
 [source,yaml]
 .`config.yaml`
 .`config.yaml`
@@ -49,6 +52,13 @@ There is a configuration option you can set in your `config.yaml` that allows yo
 insecureAllowDumpServerDiagnostics: true
 insecureAllowDumpServerDiagnostics: true
 ----
 ----
 
 
+With this enabled, the diagnostics text is returned in the `alert` field of the JSON response, with its newlines escaped. Use `jq -r` to print it as readable text you can copy:
+
+[source,bash]
+----
+curl -sS http://myserver:1337/api/ServerDiagnostics --json '{}' | jq -r .alert
+----
+
 == Default: Diagnostics dump to logs
 == Default: Diagnostics dump to logs
 
 
 You should get a simple JSON message saying something like;
 You should get a simple JSON message saying something like;
@@ -59,7 +69,7 @@ alert: "Your Server Diagnostics have been logged to OliveTin logs."
 
 
 The exact wording may match your OliveTin version.
 The exact wording may match your OliveTin version.
 
 
-If you see this, great! The actual contents of the server diagnostics are not returned to your browser for security reasons (guests could get info about your installation, etc).
+If you see this, great! The actual contents of the server diagnostics are not returned in the API response for security reasons (guests could get info about your installation, etc).
 
 
 To find the diagnostics text depends on how you are running OliveTin. If you are running in a container, then try `docker logs olivetin` (where `olivetin` is your container name). If you are running using systemd, then try `journalctl -eu OliveTin`.
 To find the diagnostics text depends on how you are running OliveTin. If you are running in a container, then try `docker logs olivetin` (where `olivetin` is your container name). If you are running using systemd, then try `journalctl -eu OliveTin`.
 
 

+ 12 - 18
docs/modules/ROOT/partials/api/start_action_methods.adoc

@@ -1,30 +1,24 @@
-There are 4 main methods to start an action in OliveTin, which can be used in scripts or other applications. These methods allow you to trigger actions either immediately or wait for them to complete, and they can be accessed via HTTP requests.
+There are 2 methods to start an action in OliveTin, which can be used in scripts or other applications. These methods allow you to trigger actions either immediately or wait for them to complete. Like every OliveTin API method, they are called with an HTTP `POST` and a JSON request body (eg: `curl --json '{...}'`).
 
 
 .Webhook/API reference table
 .Webhook/API reference table
 [%header]
 [%header]
 |======================================================
 |======================================================
 | Function                     | HTTP Method | Request Type (How to select an action)             | Response Type
 | Function                     | HTTP Method | Request Type (How to select an action)             | Response Type
 
 
-| xref:api/method_StartAction.adoc[StartAction]                
-| POST        
-| xref:api/start_action.adoc#api-request-obj[OliveTin request object]        
+| xref:api/method_StartAction.adoc[StartAction]
+| POST
+| xref:api/start_action.adoc#api-request-startaction[`bindingId`, arguments, tracking ID, justification]
 | xref:api/start_action.adoc#api-response-trackingid[Execution Tracking ID]
 | xref:api/start_action.adoc#api-response-trackingid[Execution Tracking ID]
 
 
-| xref:api/method_StartActionByGet.adoc[StartActionByGet]           
-| GET         
-| xref:api/start_action.adoc#api-request-idurl[Action ID in the URL]
-| xref:api/start_action.adoc#api-response-trackingid[Execution Tracking ID]
-
-| xref:api/method_StartActionAndWait.adoc[StartActionAndWait]         
-| POST        
-| xref:api/start_action.adoc#api-request-obj[OliveTin request object]
-| xref:api/start_action.adoc#api-response-logentry[Log Entry (waits for the action to finish)]
-
-| xref:api/method_StartActionByGetAndWait.adoc[StartActionByGetAndWait]    
-| GET         
-| xref:api/start_action.adoc#api-request-idurl[Action ID in the URL]         
+| xref:api/method_StartActionAndWait.adoc[StartActionAndWait]
+| POST
+| xref:api/start_action.adoc#api-request-startactionandwait[`actionId`, arguments, justification]
 | xref:api/start_action.adoc#api-response-logentry[Log Entry (waits for the action to finish)]
 | xref:api/start_action.adoc#api-response-logentry[Log Entry (waits for the action to finish)]
 
 
 |======================================================
 |======================================================
 
 
-You can also browse these methods in the link:htts://docs.olivetin.app/api/swagger/[OpenAPI/Swagger] docs if you prefer.
+NOTE: `StartActionAndWait` checks that the caller is allowed to view the action's logs *after* the action has finished. A caller that can start an action but cannot view its logs receives a permission error, even though the action has already run - so retrying on that error can run the action again. Use `StartAction` when you do not need the log entry.
+
+WARNING: `StartActionByGet` and `StartActionByGetAndWait` are xref:api/start_action.adoc#api-deprecated-byget[deprecated] and will be removed in a future version. Use `StartAction` or `StartActionAndWait` instead.
+
+You can also browse these methods in the link:https://docs.olivetin.app/api/swagger/3k/[OpenAPI/Swagger] docs if you prefer. The link:https://github.com/OliveTin/OliveTin/blob/main/proto/olivetin/api/v1/olivetin.proto[Protobuf definition] is the source of truth for all request and response fields.

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 279 - 317
frontend/package-lock.json


+ 14 - 14
frontend/package.json

@@ -6,12 +6,12 @@
 	"type": "module",
 	"type": "module",
 	"devDependencies": {
 	"devDependencies": {
 		"@vitejs/plugin-vue": "^6.0.10",
 		"@vitejs/plugin-vue": "^6.0.10",
-		"eslint": "^10.13.0",
-		"eslint-plugin-vue": "^10.11.1",
+		"eslint": "^10.14.0",
+		"eslint-plugin-vue": "^10.12.1",
 		"neostandard": "^0.14.1",
 		"neostandard": "^0.14.1",
-		"stylelint": "^17.16.0",
-		"stylelint-config-standard": "^40.0.0",
-		"vite": "^8.3.5"
+		"stylelint": "^18.0.0",
+		"stylelint-config-standard": "^41.0.0",
+		"vite": "^8.3.8"
 	},
 	},
 	"scripts": {
 	"scripts": {
 		"build": "vite build",
 		"build": "vite build",
@@ -23,20 +23,20 @@
 	},
 	},
 	"license": "AGPL-3.0-only",
 	"license": "AGPL-3.0-only",
 	"dependencies": {
 	"dependencies": {
-		"@connectrpc/connect": "^2.2.0",
-		"@connectrpc/connect-web": "^2.2.0",
-		"@hugeicons/core-free-icons": "^4.3.5",
+		"@connectrpc/connect": "^2.3.0",
+		"@connectrpc/connect-web": "^2.3.0",
+		"@hugeicons/core-free-icons": "^4.4.0",
 		"@hugeicons/vue": "^1.0.8",
 		"@hugeicons/vue": "^1.0.8",
 		"@xterm/addon-fit": "^0.11.0",
 		"@xterm/addon-fit": "^0.11.0",
 		"@xterm/addon-web-links": "^0.12.0",
 		"@xterm/addon-web-links": "^0.12.0",
 		"@xterm/xterm": "^6.0.0",
 		"@xterm/xterm": "^6.0.0",
-		"iconify-icon": "^3.0.4",
-		"picocrank": "^1.37.0",
-		"vue": "^3.5.44",
-		"vue-i18n": "^11.4.16",
-		"vue-router": "^5.4.0"
+		"iconify-icon": "^3.0.5",
+		"picocrank": "^1.41.0",
+		"vue": "^3.5.45",
+		"vue-i18n": "^11.4.18",
+		"vue-router": "^5.5.0"
 	},
 	},
 	"engines": {
 	"engines": {
-		"node": "^22.13.0 || >=24"
+		"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
 	}
 	}
 }
 }

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

@@ -1,4 +1,4 @@
-// @generated by protoc-gen-es v2.16.0
+// @generated by protoc-gen-es v2.17.0
 // @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3)
 // @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3)
 /* eslint-disable */
 /* eslint-disable */
 
 
@@ -2288,7 +2288,10 @@ export declare const OliveTinApiService: GenService<{
     output: typeof StartActionAndWaitResponseSchema;
     output: typeof StartActionAndWaitResponseSchema;
   },
   },
   /**
   /**
+   * Use StartAction instead; this method will be removed. See https://github.com/OliveTin/OliveTin/issues/1028
+   *
    * @generated from rpc olivetin.api.v1.OliveTinApiService.StartActionByGet
    * @generated from rpc olivetin.api.v1.OliveTinApiService.StartActionByGet
+   * @deprecated
    */
    */
   startActionByGet: {
   startActionByGet: {
     methodKind: "unary";
     methodKind: "unary";
@@ -2296,7 +2299,10 @@ export declare const OliveTinApiService: GenService<{
     output: typeof StartActionByGetResponseSchema;
     output: typeof StartActionByGetResponseSchema;
   },
   },
   /**
   /**
+   * Use StartActionAndWait instead; this method will be removed. See https://github.com/OliveTin/OliveTin/issues/1028
+   *
    * @generated from rpc olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait
    * @generated from rpc olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait
+   * @deprecated
    */
    */
   startActionByGetAndWait: {
   startActionByGetAndWait: {
     methodKind: "unary";
     methodKind: "unary";

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 1 - 1
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 177 - 165
frontend/resources/vue/App.vue

@@ -1,38 +1,33 @@
 <template>
 <template>
-  <div class="layout-fixed-header">
-    <Header
-      :title="pageTitle"
-      :logo-url="logoUrl"
-      :breadcrumbs="false"
-      :sidebar-enabled="sidebarEnabled"
-      :top-bar-enabled="topbarEnabled"
-      :navigation="navigation"
-      :username="headerUsername"
-      :login-route="headerLoginRoute"
-      @toggle-sidebar="toggleSidebar"
-      @user-click="goToUserControlPanel"
-    >
-      <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"
-          :style="bannerCss"
-        >
-          <p>{{ bannerMessage }}</p>
-        </div>
-        <ConnectionBanner />
-      </template>
-    </Header>
-
-    <div id="layout">
+  <AppShell class="layout-fixed-header">
+    <template #header>
+      <Header
+        :title="pageTitle"
+        :logo-url="logoUrl"
+        :breadcrumbs="false"
+        :sidebar-enabled="sidebarEnabled"
+        :top-bar-enabled="topbarEnabled"
+        :navigation="navigation"
+        :top-bar-navigation="navigation"
+        :username="headerUsername"
+        :login-route="headerLoginRoute"
+        @toggle-sidebar="toggleSidebar"
+        @user-click="goToUserControlPanel"
+      >
+        <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…"
+          />
+        </template>
+      </Header>
+    </template>
+
+    <template #sidebar>
       <Navigation ref="navigation">
       <Navigation ref="navigation">
         <Sidebar
         <Sidebar
           v-if="sidebarEnabled && showNavigation"
           v-if="sidebarEnabled && showNavigation"
@@ -40,140 +35,145 @@
           ref="sidebar"
           ref="sidebar"
         />
         />
       </Navigation>
       </Navigation>
-
-      <div
-        id="content"
-        :initial-martial-complete="hasLoaded"
-      >
-        <main title="Main content">
-          <router-view :key="$route.fullPath" />
-        </main>
-
-        <footer
-          v-if="showFooter"
-          title="footer"
+    </template>
+
+    <template #banners>
+      <NotificationBlock
+        v-if="bannerMessage"
+        id="banner"
+        type="info"
+        tabindex="0"
+        :message="bannerMessage"
+        :style="bannerCss"
+      />
+      <ConnectionBanner />
+    </template>
+
+    <router-view :key="$route.fullPath" />
+
+    <template
+      v-if="showFooter"
+      #footer
+    >
+      <p>
+        <img
+          title="application icon"
+          :src="logoUrl"
+          alt="OliveTin logo"
+          style="height: 1em;"
+          class="logo"
         >
         >
-          <p>
-            <img
-              title="application icon"
-              :src="logoUrl"
-              alt="OliveTin logo"
-              style="height: 1em;"
-              class="logo"
-            >
-            OliveTin <span v-if="showVersionNumber">{{ currentVersion }}</span>
-          </p>
-          <p>
-            <span>
-              <a
-                href="https://docs.olivetin.app"
-                target="_new"
-              >{{ t('docs') }}</a>
-            </span>
-
-            <span>
-              <a
-                href="https://github.com/OliveTin/OliveTin/issues/new/choose"
-                target="_new"
-              >{{ t('raise-issue') }}</a>
-            </span>
-
-            <span>
-              <a
-                href="#"
-                @click.prevent="openLanguageDialog"
-              >{{ currentLanguageName }}</a>
-            </span>
-
-            <span v-if="availableThemes.length > 1">
-              <a
-                href="#"
-                @click.prevent="openThemeDialog"
-              >{{ currentThemeName }}</a>
-            </span>
-          </p>
-          <p v-if="showVersionNumber">
-            <a
-              id="available-version"
-              href="http://olivetin.app"
-              target="_blank"
-              hidden
-            >?</a>
-          </p>
-        </footer>
-      </div>
-    </div>
-
-    <dialog
-      ref="languageDialog"
-      class="language-dialog"
-      @click="handleLanguageDialogClick"
+        OliveTin <span v-if="showVersionNumber">{{ currentVersion }}</span>
+      </p>
+      <p>
+        <span>
+          <a
+            href="https://docs.olivetin.app"
+            target="_new"
+          >{{ t('docs') }}</a>
+        </span>
+
+        <span>
+          <a
+            href="https://github.com/OliveTin/OliveTin/issues/new/choose"
+            target="_new"
+          >{{ t('raise-issue') }}</a>
+        </span>
+
+        <span>
+          <a
+            href="#"
+            @click.prevent="openLanguageDialog"
+          >{{ currentLanguageName }}</a>
+        </span>
+
+        <span v-if="availableThemes.length > 1">
+          <a
+            href="#"
+            @click.prevent="openThemeDialog"
+          >{{ currentThemeName }}</a>
+        </span>
+      </p>
+      <p v-if="showVersionNumber">
+        <a
+          id="available-version"
+          href="http://olivetin.app"
+          target="_blank"
+          hidden
+        >?</a>
+      </p>
+    </template>
+  </AppShell>
+
+  <dialog
+    ref="languageDialog"
+    class="language-dialog"
+    @click="handleLanguageDialogClick"
+  >
+    <div
+      class="dialog-content"
+      @click.stop
     >
     >
-      <div
-        class="dialog-content"
-        @click.stop
+      <h2>{{ t('language-dialog.title') }}</h2>
+      <select
+        v-model="selectedLanguage"
+        class="language-select"
+        @change="changeLanguage"
       >
       >
-        <h2>{{ t('language-dialog.title') }}</h2>
-        <select
-          v-model="selectedLanguage"
-          class="language-select"
-          @change="changeLanguage"
+        <option
+          v-for="(name, code) in availableLanguages"
+          :key="code"
+          :value="code"
         >
         >
-          <option
-            v-for="(name, code) in availableLanguages"
-            :key="code"
-            :value="code"
-          >
-            {{ code === 'auto' ? name : `${name} (${code})` }}
-          </option>
-        </select>
-        <p class="browser-languages">
-          {{ t('language-dialog.browser-languages') }}:
-          <span v-if="browserLanguages.length > 0">{{ browserLanguages.join(', ') }}</span>
-          <span v-else>{{ t('language-dialog.not-available') }}</span>
-        </p>
-        <div class="dialog-buttons">
-          <button @click="closeLanguageDialog">
-            {{ t('language-dialog.close') }}
-          </button>
-        </div>
+          {{ code === 'auto' ? name : `${name} (${code})` }}
+        </option>
+      </select>
+      <p class="browser-languages">
+        {{ t('language-dialog.browser-languages') }}:
+        <span v-if="browserLanguages.length > 0">{{ browserLanguages.join(', ') }}</span>
+        <span v-else>{{ t('language-dialog.not-available') }}</span>
+      </p>
+      <div class="dialog-buttons">
+        <button @click="closeLanguageDialog">
+          {{ t('language-dialog.close') }}
+        </button>
       </div>
       </div>
-    </dialog>
-
-    <dialog
-      ref="themeDialog"
-      class="theme-dialog"
-      @click="handleThemeDialogClick"
+    </div>
+  </dialog>
+
+  <dialog
+    ref="themeDialog"
+    class="theme-dialog"
+    @click="handleThemeDialogClick"
+  >
+    <div
+      class="dialog-content"
+      @click.stop
     >
     >
-      <div
-        class="dialog-content"
-        @click.stop
+      <h2>{{ t('theme-dialog.title') }}</h2>
+      <select
+        v-model="selectedTheme"
+        class="language-select"
+        @change="changeTheme"
       >
       >
-        <h2>{{ t('theme-dialog.title') }}</h2>
-        <select
-          v-model="selectedTheme"
-          class="language-select"
-          @change="changeTheme"
+        <option value="">
+          {{ t('theme-dialog.default') }}
+        </option>
+        <option
+          v-for="theme in availableThemes"
+          :key="theme"
+          :value="theme"
         >
         >
-          <option value="">
-            {{ t('theme-dialog.default') }}
-          </option>
-          <option
-            v-for="theme in availableThemes"
-            :key="theme"
-            :value="theme"
-          >
-            {{ theme }}
-          </option>
-        </select>
-        <div class="dialog-buttons">
-          <button @click="closeThemeDialog">
-            {{ t('theme-dialog.close') }}
-          </button>
-        </div>
+          {{ theme }}
+        </option>
+      </select>
+      <div class="dialog-buttons">
+        <button @click="closeThemeDialog">
+          {{ t('theme-dialog.close') }}
+        </button>
       </div>
       </div>
-    </dialog>
-  </div>
+    </div>
+  </dialog>
 </template>
 </template>
 
 
 <script setup>
 <script setup>
@@ -182,9 +182,11 @@ import { useRouter } from 'vue-router'
 import Sidebar from 'picocrank/vue/components/Sidebar.vue'
 import Sidebar from 'picocrank/vue/components/Sidebar.vue'
 import Navigation from 'picocrank/vue/components/Navigation.vue'
 import Navigation from 'picocrank/vue/components/Navigation.vue'
 import Header from 'picocrank/vue/components/Header.vue'
 import Header from 'picocrank/vue/components/Header.vue'
+import AppShell from 'picocrank/vue/components/AppShell.vue'
+import NotificationBlock from 'picocrank/vue/components/NotificationBlock.vue'
 import QuickSearch from 'picocrank/vue/components/QuickSearch.vue'
 import QuickSearch from 'picocrank/vue/components/QuickSearch.vue'
 import ConnectionBanner from './components/ConnectionBanner.vue'
 import ConnectionBanner from './components/ConnectionBanner.vue'
-import { connectEventStreamIfNeeded } from '../../js/websocket.js'
+import { connectEventStreamIfNeeded, stopEventStream } from '../../js/websocket.js'
 import { DashboardSquare01Icon } from '@hugeicons/core-free-icons'
 import { DashboardSquare01Icon } from '@hugeicons/core-free-icons'
 import logoUrl from '../../OliveTinLogo.png'
 import logoUrl from '../../OliveTinLogo.png'
 import { useI18n } from 'vue-i18n'
 import { useI18n } from 'vue-i18n'
@@ -203,7 +205,6 @@ const currentVersion = ref('?')
 const pageTitle = ref('OliveTin')
 const pageTitle = ref('OliveTin')
 const bannerMessage = ref('')
 const bannerMessage = ref('')
 const bannerCss = ref('')
 const bannerCss = ref('')
-const hasLoaded = ref(false)
 const showFooter = ref(true)
 const showFooter = ref(true)
 const showNavigation = ref(true)
 const showNavigation = ref(true)
 const showLogs = ref(true)
 const showLogs = ref(true)
@@ -258,7 +259,7 @@ const currentThemeName = computed(() => {
 
 
 // Computed properties for navigation style
 // Computed properties for navigation style
 const topbarEnabled = computed(() => {
 const topbarEnabled = computed(() => {
-  return sectionNavigationStyle.value === 'topbar'
+  return sectionNavigationStyle.value === 'topbar' && showNavigation.value
 })
 })
 
 
 const sidebarEnabled = computed(() => {
 const sidebarEnabled = computed(() => {
@@ -295,6 +296,11 @@ async function updateHeaderFromInit () {
   loginRequired.value = !!window.initResponse.loginRequired
   loginRequired.value = !!window.initResponse.loginRequired
   headerSearchEnabled.value = !!window.initResponse.features?.headerSearch
   headerSearchEnabled.value = !!window.initResponse.features?.headerSearch
 
 
+  // The server filters stream events by the identity the stream connected with.
+  if (username.value !== authenticatedUser) {
+    stopEventStream()
+  }
+
   username.value = authenticatedUser
   username.value = authenticatedUser
   isLoggedIn.value = authenticatedUser !== '' && authenticatedUser !== 'guest'
   isLoggedIn.value = authenticatedUser !== '' && authenticatedUser !== 'guest'
   currentVersion.value = window.initResponse.currentVersion
   currentVersion.value = window.initResponse.currentVersion
@@ -302,7 +308,7 @@ async function updateHeaderFromInit () {
   bannerMessage.value = window.initResponse.bannerMessage || ''
   bannerMessage.value = window.initResponse.bannerMessage || ''
   bannerCss.value = window.initResponse.bannerCss || ''
   bannerCss.value = window.initResponse.bannerCss || ''
   showFooter.value = window.initResponse.showFooter
   showFooter.value = window.initResponse.showFooter
-  showNavigation.value = window.initResponse.showNavigation
+  showNavigation.value = !!window.initResponse.showNavigation && !loginRequired.value
   showLogs.value = window.initResponse.showLogList
   showLogs.value = window.initResponse.showLogList
   showDiagnostics.value = window.initResponse.showDiagnostics
   showDiagnostics.value = window.initResponse.showDiagnostics
   showVersionNumber.value = window.initResponse.effectivePolicy?.showVersionNumber ?? true
   showVersionNumber.value = window.initResponse.effectivePolicy?.showVersionNumber ?? true
@@ -315,8 +321,6 @@ async function updateHeaderFromInit () {
 
 
   applyStyleMods()
   applyStyleMods()
   loadCustomJsIfEnabled()
   loadCustomJsIfEnabled()
-
-  renderNavigation()
   applyTheme().catch((err) => {
   applyTheme().catch((err) => {
     console.warn('Failed to load theme CSS:', err)
     console.warn('Failed to load theme CSS:', err)
   })
   })
@@ -327,6 +331,8 @@ async function updateHeaderFromInit () {
     return
     return
   }
   }
 
 
+  renderNavigation()
+
   if (headerSearchEnabled.value) {
   if (headerSearchEnabled.value) {
     indexSearchHints(window.initResponse.searchHints)
     indexSearchHints(window.initResponse.searchHints)
     indexRootDashboardEntries(getRootDashboardEntries())
     indexRootDashboardEntries(getRootDashboardEntries())
@@ -630,6 +636,12 @@ onMounted(async () => {
 </script>
 </script>
 
 
 <style scoped>
 <style scoped>
+/* The banner area is sticky, so a long configured message must not fill a short viewport. */
+#banner {
+    max-height: 25vh;
+    overflow-y: auto;
+}
+
 .language-dialog,
 .language-dialog,
 .theme-dialog {
 .theme-dialog {
     border: 1px solid var(--border-color, #ccc);
     border: 1px solid var(--border-color, #ccc);

+ 7 - 11
frontend/resources/vue/components/ConnectionBanner.vue

@@ -1,13 +1,12 @@
 <template>
 <template>
-  <span
+  <NotificationBlock
     v-if="connectionState.showDisconnectedBanner"
     v-if="connectionState.showDisconnectedBanner"
     id="connection-banner"
     id="connection-banner"
-    class="inline-notification critical user-info-connection"
+    type="severe"
+    role="status"
+    class="user-info-connection"
   >
   >
-    <span
-      class="connection-banner-sr-only"
-      role="status"
-    >{{ staticAnnouncement }}</span>
+    <span class="connection-banner-sr-only">{{ staticAnnouncement }}</span>
     <span aria-hidden="true">
     <span aria-hidden="true">
       <a
       <a
         :href="websocketDocsUrl"
         :href="websocketDocsUrl"
@@ -16,12 +15,13 @@
         class="connection-banner-link"
         class="connection-banner-link"
       >{{ linkText }}</a>{{ bannerSuffix }}
       >{{ linkText }}</a>{{ bannerSuffix }}
     </span>
     </span>
-  </span>
+  </NotificationBlock>
 </template>
 </template>
 
 
 <script setup>
 <script setup>
 import { ref, computed, watch, onUnmounted } from 'vue'
 import { ref, computed, watch, onUnmounted } from 'vue'
 import { useI18n } from 'vue-i18n'
 import { useI18n } from 'vue-i18n'
+import NotificationBlock from 'picocrank/vue/components/NotificationBlock.vue'
 import { connectionState } from '../stores/connectionState.js'
 import { connectionState } from '../stores/connectionState.js'
 
 
 const { t } = useI18n()
 const { t } = useI18n()
@@ -84,10 +84,6 @@ const bannerSuffix = computed(() => {
 #connection-banner.user-info-connection {
 #connection-banner.user-info-connection {
     font-weight: 500;
     font-weight: 500;
 }
 }
-.inline-notification {
-    border: 0;
-    margin: 0;
-}
 .connection-banner-link {
 .connection-banner-link {
     color: inherit;
     color: inherit;
     text-decoration: underline;
     text-decoration: underline;

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

@@ -9,6 +9,28 @@ export function selectBrowserLocale (availableLocales, browserLanguages) {
 
 
     const parts = lowerCandidate.split('-')
     const parts = lowerCandidate.split('-')
 
 
+    if (parts[0] === 'zh') {
+      let script
+      try {
+        script = new Intl.Locale(candidate).maximize().script
+      } catch (error) {
+        if (error instanceof RangeError) {
+          continue
+        }
+        throw error
+      }
+      const match = availableLocales.find(locale =>
+        locale.toLowerCase().startsWith('zh-') &&
+        new Intl.Locale(locale).maximize().script === script
+      )
+
+      if (match) {
+        return match
+      }
+
+      continue
+    }
+
     for (let length = parts.length - 1; length > 0; length--) {
     for (let length = parts.length - 1; length > 0; length--) {
       const prefix = `${parts.slice(0, length).join('-')}-`
       const prefix = `${parts.slice(0, length).join('-')}-`
       const match = availableLocales.find(locale => locale.toLowerCase().startsWith(prefix))
       const match = availableLocales.find(locale => locale.toLowerCase().startsWith(prefix))

+ 23 - 0
frontend/resources/vue/utils/localeSelection.test.mjs

@@ -17,6 +17,29 @@ test('selectBrowserLocale preserves script subtags during fallback', () => {
   assert.equal(selectBrowserLocale(availableLocales, ['zh-Hant-HK']), 'zh-Hant-TW')
   assert.equal(selectBrowserLocale(availableLocales, ['zh-Hant-HK']), 'zh-Hant-TW')
 })
 })
 
 
+for (const language of ['zh-TW', 'zh-HK', 'zh-MO', 'zh-Hant', 'zh-Hant-CN', 'ZH-tw']) {
+  test(`selectBrowserLocale selects Traditional Chinese for ${language}`, () => {
+    assert.equal(selectBrowserLocale(availableLocales, [language]), 'zh-Hant-TW')
+  })
+}
+
+for (const language of ['zh-CN', 'zh-SG', 'zh-Hans', 'zh-Hans-TW', 'zh']) {
+  test(`selectBrowserLocale selects Simplified Chinese for ${language} regardless of locale order`, () => {
+    assert.equal(selectBrowserLocale([...availableLocales].reverse(), [language]), 'zh-Hans-CN')
+  })
+}
+
+test('selectBrowserLocale skips unavailable Chinese scripts and tries the next browser language', () => {
+  assert.equal(selectBrowserLocale(['en', 'de-DE', 'zh-Hans-CN'], ['zh-TW', 'de-AT']), 'de-DE')
+  assert.equal(selectBrowserLocale(['en', 'zh-Hant-TW'], ['zh-CN']), 'en')
+})
+
 test('selectBrowserLocale uses English when no locale matches', () => {
 test('selectBrowserLocale uses English when no locale matches', () => {
   assert.equal(selectBrowserLocale(availableLocales, ['fr-FR']), 'en')
   assert.equal(selectBrowserLocale(availableLocales, ['fr-FR']), 'en')
 })
 })
+
+test('selectBrowserLocale skips malformed Chinese candidates', () => {
+  assert.equal(selectBrowserLocale(availableLocales, ['zh-', 'zh-TW']), 'zh-Hant-TW')
+  assert.equal(selectBrowserLocale(availableLocales, ['zh-Hant-!', 'de-AT']), 'de-DE')
+  assert.equal(selectBrowserLocale(availableLocales, ['zh-']), 'en')
+})

+ 8 - 0
integration-tests/mochaSetup.mjs

@@ -6,6 +6,14 @@ export async function mochaGlobalSetup () {
   const options = new Options()
   const options = new Options()
   options.addArguments('--headless', '--disable-gpu', '--disable-dev-shm-usage')
   options.addArguments('--headless', '--disable-gpu', '--disable-dev-shm-usage')
 
 
+  // Logging in with a test password can raise Chrome's save-password or
+  // breached-password prompt, which swallows clicks for every later test.
+  options.setUserPreferences({
+    credentials_enable_service: false,
+    'profile.password_manager_enabled': false,
+    'profile.password_manager_leak_detection': false,
+  })
+
   global.webdriver = await new Builder().forBrowser(Browser.CHROME).setChromeOptions(options).build()
   global.webdriver = await new Builder().forBrowser(Browser.CHROME).setChromeOptions(options).build()
   await global.webdriver.manage().setTimeouts({ implicit: 0, pageLoad: 30000, script: 30000 })
   await global.webdriver.manage().setTimeouts({ implicit: 0, pageLoad: 30000, script: 30000 })
 
 

+ 24 - 24
integration-tests/package-lock.json

@@ -14,10 +14,10 @@
       "devDependencies": {
       "devDependencies": {
         "@eslint/js": "^10.0.1",
         "@eslint/js": "^10.0.1",
         "chai": "^6.3.0",
         "chai": "^6.3.0",
-        "eslint": "^10.13.0",
-        "globals": "^17.13.0",
+        "eslint": "^10.14.0",
+        "globals": "^17.14.0",
         "mocha": "^12.0.3",
         "mocha": "^12.0.3",
-        "selenium-webdriver": "^4.51.0"
+        "selenium-webdriver": "^4.52.0"
       }
       }
     },
     },
     "node_modules/@bazel/runfiles": {
     "node_modules/@bazel/runfiles": {
@@ -664,9 +664,9 @@
       }
       }
     },
     },
     "node_modules/eslint": {
     "node_modules/eslint": {
-      "version": "10.13.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.13.0.tgz",
-      "integrity": "sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==",
+      "version": "10.14.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.14.0.tgz",
+      "integrity": "sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "workspaces": [
       "workspaces": [
@@ -679,7 +679,7 @@
         "@eslint/config-helpers": "^0.7.0",
         "@eslint/config-helpers": "^0.7.0",
         "@eslint/core": "^1.2.1",
         "@eslint/core": "^1.2.1",
         "@eslint/plugin-kit": "^0.7.3",
         "@eslint/plugin-kit": "^0.7.3",
-        "@humanfs/node": "^0.16.6",
+        "@humanfs/node": "^0.16.8",
         "@humanwhocodes/module-importer": "^1.0.1",
         "@humanwhocodes/module-importer": "^1.0.1",
         "@humanwhocodes/retry": "^0.4.2",
         "@humanwhocodes/retry": "^0.4.2",
         "@types/estree": "^1.0.6",
         "@types/estree": "^1.0.6",
@@ -700,7 +700,7 @@
         "imurmurhash": "^0.1.4",
         "imurmurhash": "^0.1.4",
         "is-glob": "^4.0.0",
         "is-glob": "^4.0.0",
         "json-stable-stringify-without-jsonify": "^1.0.1",
         "json-stable-stringify-without-jsonify": "^1.0.1",
-        "minimatch": "^10.2.5",
+        "minimatch": "^10.2.6",
         "natural-compare": "^1.4.0",
         "natural-compare": "^1.4.0",
         "optionator": "^0.9.3"
         "optionator": "^0.9.3"
       },
       },
@@ -899,9 +899,9 @@
       "license": "ISC"
       "license": "ISC"
     },
     },
     "node_modules/follow-redirects": {
     "node_modules/follow-redirects": {
-      "version": "1.16.1",
-      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
-      "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
+      "version": "1.16.3",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.3.tgz",
+      "integrity": "sha512-toOSoqN6d51Wjf1aOFJYcoVv1aND9rj/CwjLU0Vz40oDuSpWDayCeKe4JpL5NL0WdFvoOOCI3BRo69HahjbR1A==",
       "funding": [
       "funding": [
         {
         {
           "type": "individual",
           "type": "individual",
@@ -981,9 +981,9 @@
       }
       }
     },
     },
     "node_modules/glob": {
     "node_modules/glob": {
-      "version": "13.0.6",
-      "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
-      "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
+      "version": "13.0.7",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.7.tgz",
+      "integrity": "sha512-wfsFgXQ8A3SEI9XGf7Nl3Y9l7ffSrGh7SUCT7vU/2CzYPep9kjxAn1S40F9OPKxAMGCVl7HtNlJhrCJUeUq83g==",
       "dev": true,
       "dev": true,
       "license": "BlueOak-1.0.0",
       "license": "BlueOak-1.0.0",
       "dependencies": {
       "dependencies": {
@@ -1012,9 +1012,9 @@
       }
       }
     },
     },
     "node_modules/globals": {
     "node_modules/globals": {
-      "version": "17.13.0",
-      "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz",
-      "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==",
+      "version": "17.14.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-17.14.0.tgz",
+      "integrity": "sha512-UtdkTVyAJePQv0J2M9Xe4L0ruNSDV314XiIJsADI2Zf40bcdAonE/D/OhAU8AT99oij9mhLByb+NvA3QWZYgCg==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "engines": {
       "engines": {
@@ -1216,9 +1216,9 @@
       "license": "ISC"
       "license": "ISC"
     },
     },
     "node_modules/joi": {
     "node_modules/joi": {
-      "version": "18.3.0",
-      "resolved": "https://registry.npmjs.org/joi/-/joi-18.3.0.tgz",
-      "integrity": "sha512-/RwAK4BAfzcdbdFg073tzq4/xhY6Q7lMd5AmjqTL6pu0QFJdEmtswgm8BvgwXvOKuPNYQ8XrdQrrW/gF65LiNw==",
+      "version": "18.3.2",
+      "resolved": "https://registry.npmjs.org/joi/-/joi-18.3.2.tgz",
+      "integrity": "sha512-iUivApKYw5GpZmq2Sy6El99j7noo8QSUFZlyQtr3HCkosC/b5wKF6/Y3teiHIlaM8WI38sAT3P+51rB0lhAhJA==",
       "license": "BSD-3-Clause",
       "license": "BSD-3-Clause",
       "dependencies": {
       "dependencies": {
         "@hapi/address": "^5.1.1",
         "@hapi/address": "^5.1.1",
@@ -1653,9 +1653,9 @@
       "license": "MIT"
       "license": "MIT"
     },
     },
     "node_modules/selenium-webdriver": {
     "node_modules/selenium-webdriver": {
-      "version": "4.51.0",
-      "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.51.0.tgz",
-      "integrity": "sha512-2FY/x+THHk9uWj+WLBib0r+53D1G0pidox796H5AfbRHhJdPc7CVQPNJi31ip1Tb9duBYRw0zX0wJovnbwSDHg==",
+      "version": "4.52.0",
+      "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.52.0.tgz",
+      "integrity": "sha512-+7AJZ85CUiSK4J8HDEKFAcZ+fIPB2krOWncOq0Stg/Ywc7phb85FkhWRdX/WXZtqOCSzvrF/Q34rWX6zFEnGOw==",
       "dev": true,
       "dev": true,
       "funding": [
       "funding": [
         {
         {
@@ -1675,7 +1675,7 @@
         "ws": "^8.22.0"
         "ws": "^8.22.0"
       },
       },
       "engines": {
       "engines": {
-        "node": ">= 22.0.0"
+        "node": "^22.23.4 || ^24.22.1 || >= 26.12.1"
       }
       }
     },
     },
     "node_modules/serialize-javascript": {
     "node_modules/serialize-javascript": {

+ 3 - 3
integration-tests/package.json

@@ -15,10 +15,10 @@
   "devDependencies": {
   "devDependencies": {
     "@eslint/js": "^10.0.1",
     "@eslint/js": "^10.0.1",
     "chai": "^6.3.0",
     "chai": "^6.3.0",
-    "eslint": "^10.13.0",
-    "globals": "^17.13.0",
+    "eslint": "^10.14.0",
+    "globals": "^17.14.0",
     "mocha": "^12.0.3",
     "mocha": "^12.0.3",
-    "selenium-webdriver": "^4.51.0"
+    "selenium-webdriver": "^4.52.0"
   },
   },
   "dependencies": {
   "dependencies": {
     "wait-on": "^9.5.1"
     "wait-on": "^9.5.1"

+ 51 - 0
integration-tests/tests/authChangeEventStream/authChangeEventStream.mjs

@@ -0,0 +1,51 @@
+import { By, until } from 'selenium-webdriver'
+import {
+  getRootAndWait,
+  takeScreenshotOnFailure,
+} from '../../lib/elements.js'
+
+const UI_WAIT_MS = 5000
+
+function actionButtonLocator (title) {
+  return By.xpath(`//*[contains(@class, 'action-button')]//button[contains(., '${title}')]`)
+}
+
+async function clickActionAndWaitForSuccess (title) {
+  const button = await webdriver.wait(until.elementLocated(actionButtonLocator(title)), UI_WAIT_MS)
+  await button.click()
+
+  await webdriver.wait(async () => {
+    const classes = await button.getAttribute('class')
+    return classes.includes('action-success')
+  }, UI_WAIT_MS)
+}
+
+describe('config: authChangeEventStream', function () {
+  before(async function () {
+    await runner.start('authChangeEventStream')
+  })
+
+  after(async () => {
+    await webdriver.manage().deleteAllCookies()
+    await runner.stop()
+  })
+
+  afterEach(function () {
+    takeScreenshotOnFailure(this.currentTest, webdriver)
+  })
+
+  it('delivers execution events for the new user after logging in without a page refresh', async () => {
+    await getRootAndWait()
+    await clickActionAndWaitForSuccess('Guest action')
+
+    const loginLink = await webdriver.wait(until.elementLocated(By.css('a.login-link')), UI_WAIT_MS)
+    await loginLink.click()
+
+    const username = await webdriver.wait(until.elementLocated(By.id('username')), UI_WAIT_MS)
+    await username.sendKeys('alice')
+    await webdriver.findElement(By.id('password')).sendKeys('password')
+    await webdriver.findElement(By.css('button.login-button')).click()
+
+    await clickActionAndWaitForSuccess('Admin action')
+  })
+})

+ 46 - 0
integration-tests/tests/authChangeEventStream/config.yaml

@@ -0,0 +1,46 @@
+#
+# Integration Test Config: Event stream identity follows login
+#
+
+listenAddressSingleHTTPFrontend: 0.0.0.0:1337
+
+logLevel: "DEBUG"
+checkForUpdates: false
+authRequireGuestsToLogin: false
+
+authLocalUsers:
+  enabled: true
+  users:
+    - username: "alice"
+      usergroup: "admin"
+      password: "$argon2id$v=19$m=65536,t=4,p=6$ORxyZZGW6E3FWZnbQmHJ9Q$BzIOWeXry/BZ6+JV1T4UASBnebVLB9QJ4f5TmUPXsg4" # notsecret: password
+
+defaultPermissions:
+  view: false
+  exec: false
+  logs: false
+
+accessControlLists:
+  - name: admin
+    addToEveryAction: true
+    matchUsergroups: ["admin"]
+    permissions:
+      view: true
+      exec: true
+      logs: true
+
+  - name: guests
+    matchUsernames: ["guest"]
+    permissions:
+      view: true
+      exec: true
+      logs: true
+
+actions:
+  - title: Guest action
+    shell: echo guest
+    acls:
+      - guests
+
+  - title: Admin action
+    shell: echo admin

+ 27 - 0
integration-tests/tests/authRequireGuestsToLogin/authRequireGuestsToLogin.mjs

@@ -38,6 +38,33 @@ describe('config: authRequireGuestsToLogin', function () {
     expect(loginElements.length).to.be.greaterThan(0)
     expect(loginElements.length).to.be.greaterThan(0)
 
 
     console.log('✓ Login page loaded correctly')
     console.log('✓ Login page loaded correctly')
+  })
+
+  it('Guest does not receive navigation or dashboards before login', async function () {
+    await webdriver.get(runner.baseUrl())
+
+    await webdriver.wait(async () => {
+      return await webdriver.executeScript(
+        'return !!(window.initResponse && window.initResponse.loginRequired)'
+      )
+    }, 15000, 'Init should report loginRequired')
+
+    const initState = await webdriver.executeScript(`
+      const init = window.initResponse || {}
+      return {
+        showNavigation: !!init.showNavigation,
+        rootDashboards: init.rootDashboards || [],
+        rootDashboardEntries: init.rootDashboardEntries || []
+      }
+    `)
+
+    expect(initState.showNavigation).to.equal(false)
+    expect(initState.rootDashboards).to.deep.equal([])
+    expect(initState.rootDashboardEntries).to.deep.equal([])
+
+    const sidebars = await webdriver.findElements(By.css('#mainnav, #picocrank-sidebar'))
+    expect(sidebars.length).to.equal(0)
 
 
+    console.log('✓ Navigation and dashboards hidden while login is required')
   })
   })
 })
 })

+ 5 - 0
integration-tests/tests/authRequireGuestsToLogin/config.yaml

@@ -51,3 +51,8 @@ actions:
   shell: sleep 2
   shell: sleep 2
   icon: "&#x1F971"
   icon: "&#x1F971"
 
 
+dashboards:
+- title: Secret Board
+  contents:
+  - title: Ping Google.com
+

+ 22 - 0
integration-tests/tests/topbarNavigation/config.yaml

@@ -0,0 +1,22 @@
+#
+# Integration Test Config: Topbar navigation
+#
+
+listenAddressSingleHTTPFrontend: 0.0.0.0:1337
+
+checkForUpdates: false
+sectionNavigationStyle: topbar
+
+actions:
+  - title: Ping example.com
+    shell: ping example.com -c 1
+    icon: ping
+
+dashboards:
+  - title: Alpha
+    contents:
+      - title: Ping example.com
+
+  - title: Beta
+    contents:
+      - title: Ping example.com

+ 38 - 0
integration-tests/tests/topbarNavigation/topbarNavigation.mjs

@@ -0,0 +1,38 @@
+import { By, until } from 'selenium-webdriver'
+import { expect } from 'chai'
+import {
+  getRootAndWait,
+  takeScreenshotOnFailure,
+} from '../../lib/elements.js'
+
+describe('config: sectionNavigationStyle topbar', function () {
+  let originalWindowRect
+
+  before(async function () {
+    originalWindowRect = await webdriver.manage().window().getRect()
+    await runner.start('topbarNavigation')
+  })
+
+  after(async () => {
+    await webdriver.manage().window().setRect(originalWindowRect)
+    await runner.stop()
+  })
+
+  afterEach(function () {
+    takeScreenshotOnFailure(this.currentTest, webdriver)
+  })
+
+  it('shows dashboard links in the top bar', async () => {
+    await webdriver.manage().window().setRect({ width: 1600, height: 900 })
+    await getRootAndWait()
+
+    const betaLink = await webdriver.wait(
+      until.elementLocated(By.xpath("//nav[contains(@class, 'topbar')]//a[contains(., 'Beta')]")),
+      5000
+    )
+    await betaLink.click()
+
+    await webdriver.wait(until.urlContains('Beta'), 5000)
+    expect(await webdriver.getCurrentUrl()).to.contain('Beta')
+  })
+})

+ 2 - 2
lang/go.mod

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

+ 4 - 4
lang/go.sum

@@ -1,13 +1,13 @@
-github.com/jamesread/golure v0.0.0-20260927082702-900b761e432f h1:9eoL7vPEKO2JXRJjfLCmONS5jGpMXMsQzHwjTIOpN98=
-github.com/jamesread/golure v0.0.0-20260927082702-900b761e432f/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE=
+github.com/jamesread/golure v0.0.0-20261111213547-dc225fc15a30 h1:OH3LW5inwllequ0Saiv/y7mvfGWSmEE0rLROIEW5IBs=
+github.com/jamesread/golure v0.0.0-20261111213547-dc225fc15a30/go.mod h1:eanAcCk4sl2dM5bax4jeSCexnVYRF2IhhtQ4B+Wx8DU=
 github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
 github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
 github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
 github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
 github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
 github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
 github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
 github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
 go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
 go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
 go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
 go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
-golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
-golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/sys v0.50.0 h1:dkKZEFTEKaLr1sjhs0WiKqoi8Jj5nHbS3Njmx1UoMJs=
+golang.org/x/sys v0.50.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

+ 9 - 3
proto/olivetin/api/v1/olivetin.proto

@@ -524,9 +524,15 @@ service OliveTinApiService {
 
 
 	rpc StartActionAndWait(StartActionAndWaitRequest) returns (StartActionAndWaitResponse) {}
 	rpc StartActionAndWait(StartActionAndWaitRequest) returns (StartActionAndWaitResponse) {}
 
 
-	rpc StartActionByGet(StartActionByGetRequest) returns (StartActionByGetResponse) {}
-
-	rpc StartActionByGetAndWait(StartActionByGetAndWaitRequest) returns (StartActionByGetAndWaitResponse) {}
+	// Use StartAction instead; this method will be removed. See https://github.com/OliveTin/OliveTin/issues/1028
+	rpc StartActionByGet(StartActionByGetRequest) returns (StartActionByGetResponse) {
+		option deprecated = true;
+	}
+
+	// Use StartActionAndWait instead; this method will be removed. See https://github.com/OliveTin/OliveTin/issues/1028
+	rpc StartActionByGetAndWait(StartActionByGetAndWaitRequest) returns (StartActionByGetAndWaitResponse) {
+		option deprecated = true;
+	}
 
 
     rpc RestartAction(RestartActionRequest) returns (StartActionResponse) {}
     rpc RestartAction(RestartActionRequest) returns (StartActionResponse) {}
 
 

+ 1 - 1
service/.golangci.yml

@@ -2,7 +2,7 @@
 #
 #
 # This config replaces the former standalone tools:
 # This config replaces the former standalone tools:
 #   go vet ./...              -> govet (enable-all)
 #   go vet ./...              -> govet (enable-all)
-#   gocyclo -over 4           -> gocyclo min-complexity: 5
+#   gocyclo -over 5           -> gocyclo min-complexity: 5
 #   gocritic check ./...      -> gocritic
 #   gocritic check ./...      -> gocritic
 #   errcheck, staticcheck, ...-> same-named golangci-lint linters
 #   errcheck, staticcheck, ...-> same-named golangci-lint linters
 #
 #

+ 16 - 0
service/gen/olivetin/api/v1/apiv1connect/olivetin.connect.go

@@ -117,7 +117,13 @@ type OliveTinApiServiceClient interface {
 	GetDashboard(context.Context, *connect.Request[v1.GetDashboardRequest]) (*connect.Response[v1.GetDashboardResponse], error)
 	GetDashboard(context.Context, *connect.Request[v1.GetDashboardRequest]) (*connect.Response[v1.GetDashboardResponse], error)
 	StartAction(context.Context, *connect.Request[v1.StartActionRequest]) (*connect.Response[v1.StartActionResponse], error)
 	StartAction(context.Context, *connect.Request[v1.StartActionRequest]) (*connect.Response[v1.StartActionResponse], error)
 	StartActionAndWait(context.Context, *connect.Request[v1.StartActionAndWaitRequest]) (*connect.Response[v1.StartActionAndWaitResponse], error)
 	StartActionAndWait(context.Context, *connect.Request[v1.StartActionAndWaitRequest]) (*connect.Response[v1.StartActionAndWaitResponse], error)
+	// Use StartAction instead; this method will be removed. See https://github.com/OliveTin/OliveTin/issues/1028
+	//
+	// Deprecated: do not use.
 	StartActionByGet(context.Context, *connect.Request[v1.StartActionByGetRequest]) (*connect.Response[v1.StartActionByGetResponse], error)
 	StartActionByGet(context.Context, *connect.Request[v1.StartActionByGetRequest]) (*connect.Response[v1.StartActionByGetResponse], error)
+	// Use StartActionAndWait instead; this method will be removed. See https://github.com/OliveTin/OliveTin/issues/1028
+	//
+	// Deprecated: do not use.
 	StartActionByGetAndWait(context.Context, *connect.Request[v1.StartActionByGetAndWaitRequest]) (*connect.Response[v1.StartActionByGetAndWaitResponse], error)
 	StartActionByGetAndWait(context.Context, *connect.Request[v1.StartActionByGetAndWaitRequest]) (*connect.Response[v1.StartActionByGetAndWaitResponse], error)
 	RestartAction(context.Context, *connect.Request[v1.RestartActionRequest]) (*connect.Response[v1.StartActionResponse], error)
 	RestartAction(context.Context, *connect.Request[v1.RestartActionRequest]) (*connect.Response[v1.StartActionResponse], error)
 	KillAction(context.Context, *connect.Request[v1.KillActionRequest]) (*connect.Response[v1.KillActionResponse], error)
 	KillAction(context.Context, *connect.Request[v1.KillActionRequest]) (*connect.Response[v1.KillActionResponse], error)
@@ -358,11 +364,15 @@ func (c *oliveTinApiServiceClient) StartActionAndWait(ctx context.Context, req *
 }
 }
 
 
 // StartActionByGet calls olivetin.api.v1.OliveTinApiService.StartActionByGet.
 // StartActionByGet calls olivetin.api.v1.OliveTinApiService.StartActionByGet.
+//
+// Deprecated: do not use.
 func (c *oliveTinApiServiceClient) StartActionByGet(ctx context.Context, req *connect.Request[v1.StartActionByGetRequest]) (*connect.Response[v1.StartActionByGetResponse], error) {
 func (c *oliveTinApiServiceClient) StartActionByGet(ctx context.Context, req *connect.Request[v1.StartActionByGetRequest]) (*connect.Response[v1.StartActionByGetResponse], error) {
 	return c.startActionByGet.CallUnary(ctx, req)
 	return c.startActionByGet.CallUnary(ctx, req)
 }
 }
 
 
 // StartActionByGetAndWait calls olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait.
 // StartActionByGetAndWait calls olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait.
+//
+// Deprecated: do not use.
 func (c *oliveTinApiServiceClient) StartActionByGetAndWait(ctx context.Context, req *connect.Request[v1.StartActionByGetAndWaitRequest]) (*connect.Response[v1.StartActionByGetAndWaitResponse], error) {
 func (c *oliveTinApiServiceClient) StartActionByGetAndWait(ctx context.Context, req *connect.Request[v1.StartActionByGetAndWaitRequest]) (*connect.Response[v1.StartActionByGetAndWaitResponse], error) {
 	return c.startActionByGetAndWait.CallUnary(ctx, req)
 	return c.startActionByGetAndWait.CallUnary(ctx, req)
 }
 }
@@ -477,7 +487,13 @@ type OliveTinApiServiceHandler interface {
 	GetDashboard(context.Context, *connect.Request[v1.GetDashboardRequest]) (*connect.Response[v1.GetDashboardResponse], error)
 	GetDashboard(context.Context, *connect.Request[v1.GetDashboardRequest]) (*connect.Response[v1.GetDashboardResponse], error)
 	StartAction(context.Context, *connect.Request[v1.StartActionRequest]) (*connect.Response[v1.StartActionResponse], error)
 	StartAction(context.Context, *connect.Request[v1.StartActionRequest]) (*connect.Response[v1.StartActionResponse], error)
 	StartActionAndWait(context.Context, *connect.Request[v1.StartActionAndWaitRequest]) (*connect.Response[v1.StartActionAndWaitResponse], error)
 	StartActionAndWait(context.Context, *connect.Request[v1.StartActionAndWaitRequest]) (*connect.Response[v1.StartActionAndWaitResponse], error)
+	// Use StartAction instead; this method will be removed. See https://github.com/OliveTin/OliveTin/issues/1028
+	//
+	// Deprecated: do not use.
 	StartActionByGet(context.Context, *connect.Request[v1.StartActionByGetRequest]) (*connect.Response[v1.StartActionByGetResponse], error)
 	StartActionByGet(context.Context, *connect.Request[v1.StartActionByGetRequest]) (*connect.Response[v1.StartActionByGetResponse], error)
+	// Use StartActionAndWait instead; this method will be removed. See https://github.com/OliveTin/OliveTin/issues/1028
+	//
+	// Deprecated: do not use.
 	StartActionByGetAndWait(context.Context, *connect.Request[v1.StartActionByGetAndWaitRequest]) (*connect.Response[v1.StartActionByGetAndWaitResponse], error)
 	StartActionByGetAndWait(context.Context, *connect.Request[v1.StartActionByGetAndWaitRequest]) (*connect.Response[v1.StartActionByGetAndWaitResponse], error)
 	RestartAction(context.Context, *connect.Request[v1.RestartActionRequest]) (*connect.Response[v1.StartActionResponse], error)
 	RestartAction(context.Context, *connect.Request[v1.RestartActionRequest]) (*connect.Response[v1.StartActionResponse], error)
 	KillAction(context.Context, *connect.Request[v1.KillActionRequest]) (*connect.Response[v1.KillActionResponse], error)
 	KillAction(context.Context, *connect.Request[v1.KillActionRequest]) (*connect.Response[v1.KillActionResponse], error)

+ 4 - 4
service/gen/olivetin/api/v1/olivetin.pb.go

@@ -5557,13 +5557,13 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"unique_key\x18\x01 \x01(\tR\tuniqueKey\x12\x12\n" +
 	"unique_key\x18\x01 \x01(\tR\tuniqueKey\x12\x12\n" +
 	"\x04type\x18\x02 \x01(\tR\x04type\"J\n" +
 	"\x04type\x18\x02 \x01(\tR\x04type\"J\n" +
 	"\x14RestartActionRequest\x122\n" +
 	"\x14RestartActionRequest\x122\n" +
-	"\x15execution_tracking_id\x18\x01 \x01(\tR\x13executionTrackingId2\xee\x13\n" +
+	"\x15execution_tracking_id\x18\x01 \x01(\tR\x13executionTrackingId2\xf5\x13\n" +
 	"\x12OliveTinApiService\x12]\n" +
 	"\x12OliveTinApiService\x12]\n" +
 	"\fGetDashboard\x12$.olivetin.api.v1.GetDashboardRequest\x1a%.olivetin.api.v1.GetDashboardResponse\"\x00\x12Z\n" +
 	"\fGetDashboard\x12$.olivetin.api.v1.GetDashboardRequest\x1a%.olivetin.api.v1.GetDashboardResponse\"\x00\x12Z\n" +
 	"\vStartAction\x12#.olivetin.api.v1.StartActionRequest\x1a$.olivetin.api.v1.StartActionResponse\"\x00\x12o\n" +
 	"\vStartAction\x12#.olivetin.api.v1.StartActionRequest\x1a$.olivetin.api.v1.StartActionResponse\"\x00\x12o\n" +
-	"\x12StartActionAndWait\x12*.olivetin.api.v1.StartActionAndWaitRequest\x1a+.olivetin.api.v1.StartActionAndWaitResponse\"\x00\x12i\n" +
-	"\x10StartActionByGet\x12(.olivetin.api.v1.StartActionByGetRequest\x1a).olivetin.api.v1.StartActionByGetResponse\"\x00\x12~\n" +
-	"\x17StartActionByGetAndWait\x12/.olivetin.api.v1.StartActionByGetAndWaitRequest\x1a0.olivetin.api.v1.StartActionByGetAndWaitResponse\"\x00\x12^\n" +
+	"\x12StartActionAndWait\x12*.olivetin.api.v1.StartActionAndWaitRequest\x1a+.olivetin.api.v1.StartActionAndWaitResponse\"\x00\x12l\n" +
+	"\x10StartActionByGet\x12(.olivetin.api.v1.StartActionByGetRequest\x1a).olivetin.api.v1.StartActionByGetResponse\"\x03\x88\x02\x01\x12\x81\x01\n" +
+	"\x17StartActionByGetAndWait\x12/.olivetin.api.v1.StartActionByGetAndWaitRequest\x1a0.olivetin.api.v1.StartActionByGetAndWaitResponse\"\x03\x88\x02\x01\x12^\n" +
 	"\rRestartAction\x12%.olivetin.api.v1.RestartActionRequest\x1a$.olivetin.api.v1.StartActionResponse\"\x00\x12W\n" +
 	"\rRestartAction\x12%.olivetin.api.v1.RestartActionRequest\x1a$.olivetin.api.v1.StartActionResponse\"\x00\x12W\n" +
 	"\n" +
 	"\n" +
 	"KillAction\x12\".olivetin.api.v1.KillActionRequest\x1a#.olivetin.api.v1.KillActionResponse\"\x00\x12f\n" +
 	"KillAction\x12\".olivetin.api.v1.KillActionRequest\x1a#.olivetin.api.v1.KillActionResponse\"\x00\x12f\n" +

+ 51 - 50
service/go.mod

@@ -10,7 +10,7 @@ exclude github.com/alecthomas/go-check-sumtype v0.5.0
 require (
 require (
 	connectrpc.com/connect v1.21.1
 	connectrpc.com/connect v1.21.1
 	github.com/Masterminds/semver v1.5.0
 	github.com/Masterminds/semver v1.5.0
-	github.com/MicahParks/keyfunc/v3 v3.8.2
+	github.com/MicahParks/keyfunc/v3 v3.9.1
 	github.com/PaesslerAG/jsonpath v0.1.1
 	github.com/PaesslerAG/jsonpath v0.1.1
 	github.com/alexedwards/argon2id v1.0.0
 	github.com/alexedwards/argon2id v1.0.0
 	github.com/bufbuild/buf v1.75.0
 	github.com/bufbuild/buf v1.75.0
@@ -20,7 +20,7 @@ require (
 	github.com/golang-jwt/jwt/v5 v5.3.1
 	github.com/golang-jwt/jwt/v5 v5.3.1
 	github.com/golangci/golangci-lint/v2 v2.15.0
 	github.com/golangci/golangci-lint/v2 v2.15.0
 	github.com/google/uuid v1.6.0
 	github.com/google/uuid v1.6.0
-	github.com/jamesread/golure v0.0.0-20260927082702-900b761e432f
+	github.com/jamesread/golure v0.0.0-20261111213547-dc225fc15a30
 	github.com/knadh/koanf/parsers/yaml v1.1.1
 	github.com/knadh/koanf/parsers/yaml v1.1.1
 	github.com/knadh/koanf/providers/env v1.1.0
 	github.com/knadh/koanf/providers/env v1.1.0
 	github.com/knadh/koanf/providers/file v1.2.1
 	github.com/knadh/koanf/providers/file v1.2.1
@@ -31,9 +31,9 @@ require (
 	github.com/sirupsen/logrus v1.10.2
 	github.com/sirupsen/logrus v1.10.2
 	github.com/stretchr/testify v1.12.1
 	github.com/stretchr/testify v1.12.1
 	go.akshayshah.org/connectproto v0.6.0
 	go.akshayshah.org/connectproto v0.6.0
-	golang.org/x/oauth2 v0.37.0
-	golang.org/x/sys v0.49.0
-	google.golang.org/protobuf v1.36.13-0.20261016172727-e9ee2a76ab8e
+	golang.org/x/oauth2 v0.38.0
+	golang.org/x/sys v0.50.0
+	google.golang.org/protobuf v1.37.0-rc.1
 	gopkg.in/yaml.v3 v3.0.1
 	gopkg.in/yaml.v3 v3.0.1
 )
 )
 
 
@@ -43,27 +43,27 @@ require (
 	buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20261002170247-538130002972.2 // indirect
 	buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20261002170247-538130002972.2 // indirect
 	buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20261012172039-ff528c23443f.2 // indirect
 	buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20261012172039-ff528c23443f.2 // indirect
 	buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect
 	buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect
-	buf.build/gen/go/bufbuild/registry/connectrpc/go/v2 v2.0.1-20261021182822-0b1ad74125ef.1 // indirect
-	buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20261021182822-0b1ad74125ef.2 // indirect
+	buf.build/gen/go/bufbuild/registry/connectrpc/go/v2 v2.0.2-20261111181020-2b3683116fcf.1 // indirect
+	buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20261111181020-2b3683116fcf.2 // indirect
 	buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.12-20241007202033-cf42259fcbfc.2 // indirect
 	buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.12-20241007202033-cf42259fcbfc.2 // indirect
 	buf.build/go/app v0.3.0 // indirect
 	buf.build/go/app v0.3.0 // indirect
 	buf.build/go/bufplugin v0.11.1 // indirect
 	buf.build/go/bufplugin v0.11.1 // indirect
 	buf.build/go/bufprivateusage v0.1.0 // indirect
 	buf.build/go/bufprivateusage v0.1.0 // indirect
 	buf.build/go/interrupt v1.1.0 // indirect
 	buf.build/go/interrupt v1.1.0 // indirect
 	buf.build/go/protovalidate v1.4.0 // indirect
 	buf.build/go/protovalidate v1.4.0 // indirect
-	buf.build/go/protoyaml v0.7.0 // indirect
+	buf.build/go/protoyaml v0.8.0 // indirect
 	buf.build/go/spdx v0.3.0 // indirect
 	buf.build/go/spdx v0.3.0 // indirect
 	buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect
 	buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect
-	cel.dev/cel-go v0.32.0 // indirect
-	cel.dev/expr v0.25.3 // indirect
-	charm.land/lipgloss/v2 v2.0.6 // indirect
+	cel.dev/cel-go v0.33.2 // indirect
+	cel.dev/expr v0.26.0 // indirect
+	charm.land/lipgloss/v2 v2.1.0 // indirect
 	codeberg.org/chavacava/garif v0.2.1 // indirect
 	codeberg.org/chavacava/garif v0.2.1 // indirect
-	codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
+	codeberg.org/polyfloyd/go-errorlint v1.9.1 // indirect
 	connectrpc.com/connect/v2 v2.0.2 // indirect
 	connectrpc.com/connect/v2 v2.0.2 // indirect
 	connectrpc.com/otelconnect v0.12.0 // indirect
 	connectrpc.com/otelconnect v0.12.0 // indirect
 	dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
 	dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
 	dev.gaijin.team/go/exhaustruct/v5 v5.2.0 // indirect
 	dev.gaijin.team/go/exhaustruct/v5 v5.2.0 // indirect
-	dev.gaijin.team/go/golib v0.8.1 // indirect
+	dev.gaijin.team/go/golib v0.9.0 // indirect
 	github.com/4meepo/tagalign v1.4.4 // indirect
 	github.com/4meepo/tagalign v1.4.4 // indirect
 	github.com/Abirdcfly/dupword v0.1.8 // indirect
 	github.com/Abirdcfly/dupword v0.1.8 // indirect
 	github.com/AdminBenni/iota-mixing v1.0.0 // indirect
 	github.com/AdminBenni/iota-mixing v1.0.0 // indirect
@@ -75,10 +75,10 @@ require (
 	github.com/ClickHouse/clickhouse-go-linter v1.2.1 // indirect
 	github.com/ClickHouse/clickhouse-go-linter v1.2.1 // indirect
 	github.com/Djarvur/go-err113 v0.1.1 // indirect
 	github.com/Djarvur/go-err113 v0.1.1 // indirect
 	github.com/Masterminds/semver/v3 v3.5.0 // indirect
 	github.com/Masterminds/semver/v3 v3.5.0 // indirect
-	github.com/MicahParks/jwkset v0.11.3 // indirect
+	github.com/MicahParks/jwkset v0.11.4 // indirect
 	github.com/Microsoft/go-winio v0.6.4 // indirect
 	github.com/Microsoft/go-winio v0.6.4 // indirect
 	github.com/MirrexOne/unqueryvet v1.5.4 // indirect
 	github.com/MirrexOne/unqueryvet v1.5.4 // indirect
-	github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
+	github.com/OpenPeeDeeP/depguard/v2 v2.3.0 // indirect
 	github.com/PaesslerAG/gval v1.2.4 // indirect
 	github.com/PaesslerAG/gval v1.2.4 // indirect
 	github.com/alecthomas/chroma/v2 v2.27.0 // indirect
 	github.com/alecthomas/chroma/v2 v2.27.0 // indirect
 	github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // indirect
 	github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // indirect
@@ -106,7 +106,7 @@ require (
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/charithe/durationcheck v0.0.11 // indirect
 	github.com/charithe/durationcheck v0.0.11 // indirect
 	github.com/charmbracelet/colorprofile v0.4.3 // indirect
 	github.com/charmbracelet/colorprofile v0.4.3 // indirect
-	github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
+	github.com/charmbracelet/ultraviolet v0.0.0-20261023060817-a45b7c86333f // indirect
 	github.com/charmbracelet/x/ansi v0.11.9 // indirect
 	github.com/charmbracelet/x/ansi v0.11.9 // indirect
 	github.com/charmbracelet/x/term v0.2.2 // indirect
 	github.com/charmbracelet/x/term v0.2.2 // indirect
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
@@ -123,9 +123,9 @@ require (
 	github.com/dave/dst v0.28.0 // indirect
 	github.com/dave/dst v0.28.0 // indirect
 	github.com/denis-tingaikin/go-header v1.0.0 // indirect
 	github.com/denis-tingaikin/go-header v1.0.0 // indirect
 	github.com/distribution/reference v0.6.0 // indirect
 	github.com/distribution/reference v0.6.0 // indirect
-	github.com/dlclark/regexp2/v2 v2.9.1 // indirect
-	github.com/docker/cli v29.9.2+incompatible // indirect
-	github.com/docker/docker-credential-helpers v0.9.12 // indirect
+	github.com/dlclark/regexp2/v2 v2.13.0 // indirect
+	github.com/docker/cli v29.9.3+incompatible // indirect
+	github.com/docker/docker-credential-helpers v0.9.13 // indirect
 	github.com/docker/go-connections v0.8.2 // indirect
 	github.com/docker/go-connections v0.8.2 // indirect
 	github.com/docker/go-units v0.5.0 // indirect
 	github.com/docker/go-units v0.5.0 // indirect
 	github.com/ettle/strcase v0.2.0 // indirect
 	github.com/ettle/strcase v0.2.0 // indirect
@@ -134,9 +134,9 @@ require (
 	github.com/felixge/httpsnoop v1.1.0 // indirect
 	github.com/felixge/httpsnoop v1.1.0 // indirect
 	github.com/firefart/nonamedreturns v1.0.8 // indirect
 	github.com/firefart/nonamedreturns v1.0.8 // indirect
 	github.com/fzipp/gocyclo v0.6.0 // indirect
 	github.com/fzipp/gocyclo v0.6.0 // indirect
-	github.com/ghostiam/protogetter v1.0.1 // indirect
+	github.com/ghostiam/protogetter v1.0.2 // indirect
 	github.com/go-critic/go-critic v0.15.0 // indirect
 	github.com/go-critic/go-critic v0.15.0 // indirect
-	github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect
+	github.com/go-json-experiment/json v1.27.2 // indirect
 	github.com/go-logr/logr v1.4.4 // indirect
 	github.com/go-logr/logr v1.4.4 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/go-toolsmith/astcast v1.1.0 // indirect
 	github.com/go-toolsmith/astcast v1.1.0 // indirect
@@ -147,19 +147,19 @@ require (
 	github.com/go-toolsmith/strparse v1.1.0 // indirect
 	github.com/go-toolsmith/strparse v1.1.0 // indirect
 	github.com/go-toolsmith/typep v1.1.0 // indirect
 	github.com/go-toolsmith/typep v1.1.0 // indirect
 	github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
 	github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
-	github.com/gobwas/glob v1.0.0 // indirect
+	github.com/gobwas/glob v1.1.0 // indirect
 	github.com/godoc-lint/godoc-lint v0.11.4 // indirect
 	github.com/godoc-lint/godoc-lint v0.11.4 // indirect
-	github.com/gofrs/flock v0.13.1 // indirect
+	github.com/gofrs/flock v0.13.2 // indirect
 	github.com/golangci/asciicheck v0.5.0 // indirect
 	github.com/golangci/asciicheck v0.5.0 // indirect
 	github.com/golangci/canonicalheader v0.0.0-20260928145534-e18e5c5a078f // indirect
 	github.com/golangci/canonicalheader v0.0.0-20260928145534-e18e5c5a078f // indirect
 	github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
 	github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
 	github.com/golangci/go-printf-func-name v0.1.1 // indirect
 	github.com/golangci/go-printf-func-name v0.1.1 // indirect
 	github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 // indirect
 	github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 // indirect
-	github.com/golangci/golines v0.16.0 // indirect
+	github.com/golangci/golines v0.16.1 // indirect
 	github.com/golangci/misspell v0.8.0 // indirect
 	github.com/golangci/misspell v0.8.0 // indirect
 	github.com/golangci/plugin-module-register v0.1.2 // indirect
 	github.com/golangci/plugin-module-register v0.1.2 // indirect
 	github.com/golangci/revgrep v0.8.0 // indirect
 	github.com/golangci/revgrep v0.8.0 // indirect
-	github.com/golangci/rowserrcheck v0.0.0-20261005233414-e0740d52dee8 // indirect
+	github.com/golangci/rowserrcheck v0.0.0-20261101210309-7f680fea35ce // indirect
 	github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
 	github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
 	github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
 	github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
 	github.com/google/go-cmp v0.7.0 // indirect
 	github.com/google/go-cmp v0.7.0 // indirect
@@ -181,7 +181,7 @@ require (
 	github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
 	github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
 	github.com/kisielk/errcheck v1.30.0 // indirect
 	github.com/kisielk/errcheck v1.30.0 // indirect
 	github.com/kkHAIKE/contextcheck v1.1.6 // indirect
 	github.com/kkHAIKE/contextcheck v1.1.6 // indirect
-	github.com/klauspost/compress v1.20.3 // indirect
+	github.com/klauspost/compress v1.20.4 // indirect
 	github.com/klauspost/pgzip v1.3.0 // indirect
 	github.com/klauspost/pgzip v1.3.0 // indirect
 	github.com/knadh/koanf/maps v0.1.3 // indirect
 	github.com/knadh/koanf/maps v0.1.3 // indirect
 	github.com/kulti/thelper v0.7.1 // indirect
 	github.com/kulti/thelper v0.7.1 // indirect
@@ -189,7 +189,7 @@ require (
 	github.com/ldez/exptostd v0.4.5 // indirect
 	github.com/ldez/exptostd v0.4.5 // indirect
 	github.com/ldez/gomoddirectives v0.10.0 // indirect
 	github.com/ldez/gomoddirectives v0.10.0 // indirect
 	github.com/ldez/grignotin v0.10.1 // indirect
 	github.com/ldez/grignotin v0.10.1 // indirect
-	github.com/ldez/structtags v0.6.1 // indirect
+	github.com/ldez/structtags v0.6.2 // indirect
 	github.com/ldez/tagliatelle v0.8.0 // indirect
 	github.com/ldez/tagliatelle v0.8.0 // indirect
 	github.com/ldez/usetesting v0.5.0 // indirect
 	github.com/ldez/usetesting v0.5.0 // indirect
 	github.com/leonklingele/grouper v1.1.2 // indirect
 	github.com/leonklingele/grouper v1.1.2 // indirect
@@ -214,25 +214,26 @@ require (
 	github.com/muesli/cancelreader v0.2.2 // indirect
 	github.com/muesli/cancelreader v0.2.2 // indirect
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
 	github.com/nakabonne/nestif v0.3.1 // indirect
 	github.com/nakabonne/nestif v0.3.1 // indirect
-	github.com/nishanths/exhaustive v0.16.1 // indirect
+	github.com/nishanths/exhaustive v0.17.1 // indirect
 	github.com/nishanths/predeclared v0.3.1 // indirect
 	github.com/nishanths/predeclared v0.3.1 // indirect
 	github.com/nunnatsa/ginkgolinter v0.24.0 // indirect
 	github.com/nunnatsa/ginkgolinter v0.24.0 // indirect
 	github.com/opencontainers/go-digest v1.0.0 // indirect
 	github.com/opencontainers/go-digest v1.0.0 // indirect
 	github.com/opencontainers/image-spec v1.1.1 // indirect
 	github.com/opencontainers/image-spec v1.1.1 // indirect
+	github.com/pandatix/go-abnf v0.5.5 // indirect
 	github.com/pelletier/go-toml/v2 v2.4.3 // indirect
 	github.com/pelletier/go-toml/v2 v2.4.3 // indirect
-	github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b // indirect
+	github.com/petermattis/goid v0.0.0-20261109124353-7cfa343ad0ab // indirect
 	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
 	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
 	github.com/prometheus/client_model v0.6.3 // indirect
 	github.com/prometheus/client_model v0.6.3 // indirect
-	github.com/prometheus/common v0.72.1 // indirect
-	github.com/prometheus/procfs v0.22.0 // indirect
+	github.com/prometheus/common v0.73.1 // indirect
+	github.com/prometheus/procfs v0.23.0 // indirect
 	github.com/quasilyte/go-ruleguard v0.4.5 // indirect
 	github.com/quasilyte/go-ruleguard v0.4.5 // indirect
 	github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect
 	github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect
 	github.com/quasilyte/gogrep v0.5.0 // indirect
 	github.com/quasilyte/gogrep v0.5.0 // indirect
 	github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
 	github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
 	github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
 	github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
 	github.com/quic-go/qpack v0.6.0 // indirect
 	github.com/quic-go/qpack v0.6.0 // indirect
-	github.com/quic-go/quic-go v0.64.1 // indirect
-	github.com/raeperd/recvcheck v0.3.1 // indirect
+	github.com/quic-go/quic-go v0.65.0 // indirect
+	github.com/raeperd/recvcheck v0.3.2 // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
 	github.com/rogpeppe/go-internal v1.16.0 // indirect
 	github.com/rogpeppe/go-internal v1.16.0 // indirect
 	github.com/rs/cors v1.11.1 // indirect
 	github.com/rs/cors v1.11.1 // indirect
@@ -245,7 +246,7 @@ require (
 	github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
 	github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
 	github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
 	github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
 	github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
 	github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
-	github.com/securego/gosec/v2 v2.29.0 // indirect
+	github.com/securego/gosec/v2 v2.30.0 // indirect
 	github.com/shopspring/decimal v1.6.0 // indirect
 	github.com/shopspring/decimal v1.6.0 // indirect
 	github.com/sivchari/containedctx v1.0.3 // indirect
 	github.com/sivchari/containedctx v1.0.3 // indirect
 	github.com/sonatard/noctx v0.5.1 // indirect
 	github.com/sonatard/noctx v0.5.1 // indirect
@@ -253,7 +254,7 @@ require (
 	github.com/spf13/afero v1.15.0 // indirect
 	github.com/spf13/afero v1.15.0 // indirect
 	github.com/spf13/cast v1.10.0 // indirect
 	github.com/spf13/cast v1.10.0 // indirect
 	github.com/spf13/cobra v1.10.2 // indirect
 	github.com/spf13/cobra v1.10.2 // indirect
-	github.com/spf13/pflag v1.0.10 // indirect
+	github.com/spf13/pflag v1.1.0 // indirect
 	github.com/spf13/viper v1.21.0 // indirect
 	github.com/spf13/viper v1.21.0 // indirect
 	github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
 	github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
 	github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect
 	github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect
@@ -262,7 +263,7 @@ require (
 	github.com/tetafro/godot v1.5.6 // indirect
 	github.com/tetafro/godot v1.5.6 // indirect
 	github.com/tetratelabs/wazero v1.12.0 // indirect
 	github.com/tetratelabs/wazero v1.12.0 // indirect
 	github.com/tidwall/btree v1.8.2 // indirect
 	github.com/tidwall/btree v1.8.2 // indirect
-	github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 // indirect
+	github.com/timakin/bodyclose v0.0.0-20261107224203-8bb60b19bea4 // indirect
 	github.com/timonwong/loggercheck v0.13.1 // indirect
 	github.com/timonwong/loggercheck v0.13.1 // indirect
 	github.com/tomarrell/wrapcheck/v2 v2.13.0 // indirect
 	github.com/tomarrell/wrapcheck/v2 v2.13.0 // indirect
 	github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
 	github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
@@ -271,7 +272,7 @@ require (
 	github.com/uudashr/gocognit v1.3.0 // indirect
 	github.com/uudashr/gocognit v1.3.0 // indirect
 	github.com/uudashr/iface v1.5.3 // indirect
 	github.com/uudashr/iface v1.5.3 // indirect
 	github.com/xen0n/gosmopolitan v1.3.0 // indirect
 	github.com/xen0n/gosmopolitan v1.3.0 // indirect
-	github.com/xo/terminfo v1.2.0 // indirect
+	github.com/xo/terminfo v1.3.1 // indirect
 	github.com/yagipy/maintidx v1.0.0 // indirect
 	github.com/yagipy/maintidx v1.0.0 // indirect
 	github.com/yeya24/promlinter v0.3.0 // indirect
 	github.com/yeya24/promlinter v0.3.0 // indirect
 	github.com/ykadowak/zerologlint v0.1.5 // indirect
 	github.com/ykadowak/zerologlint v0.1.5 // indirect
@@ -292,22 +293,22 @@ require (
 	go.uber.org/mock v0.6.0 // indirect
 	go.uber.org/mock v0.6.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
 	go.uber.org/zap v1.28.0 // indirect
 	go.uber.org/zap v1.28.0 // indirect
-	go.yaml.in/yaml/v3 v3.0.5 // indirect
-	golang.org/x/crypto v0.58.0 // indirect
-	golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect
-	golang.org/x/exp/typeparams v0.0.0-20261014143641-f0251eb4aa3e // indirect
-	golang.org/x/mod v0.42.0 // indirect
-	golang.org/x/net v0.61.0 // indirect
-	golang.org/x/sync v0.24.0 // indirect
-	golang.org/x/term v0.47.0 // indirect
-	golang.org/x/text v0.43.0 // indirect
+	go.yaml.in/yaml/v3 v3.0.6 // indirect
+	golang.org/x/crypto v0.59.0 // indirect
+	golang.org/x/exp v0.0.0-20261109185158-a52e3aef5920 // indirect
+	golang.org/x/exp/typeparams v0.0.0-20261109185158-a52e3aef5920 // indirect
+	golang.org/x/mod v0.43.0 // indirect
+	golang.org/x/net v0.62.0 // indirect
+	golang.org/x/sync v0.25.0 // indirect
+	golang.org/x/term v0.48.0 // indirect
+	golang.org/x/text v0.44.0 // indirect
 	golang.org/x/time v0.16.0 // indirect
 	golang.org/x/time v0.16.0 // indirect
-	golang.org/x/tools v0.52.0 // indirect
-	google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b // indirect
+	golang.org/x/tools v0.52.1-0.20261106145633-d407941a339a // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20261110000943-09e21b237f2c // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20261110000943-09e21b237f2c // indirect
 	honnef.co/go/tools v0.8.2 // indirect
 	honnef.co/go/tools v0.8.2 // indirect
 	mvdan.cc/gofumpt v0.12.0 // indirect
 	mvdan.cc/gofumpt v0.12.0 // indirect
-	mvdan.cc/unparam v0.0.0-20261011093844-7a2c2c0b7aa6 // indirect
+	mvdan.cc/unparam v0.0.0-20261102141913-394cd0a56cd5 // indirect
 	mvdan.cc/xurls/v2 v2.6.0 // indirect
 	mvdan.cc/xurls/v2 v2.6.0 // indirect
 	pluginrpc.com/pluginrpc v0.6.0 // indirect
 	pluginrpc.com/pluginrpc v0.6.0 // indirect
 )
 )

+ 108 - 106
service/go.sum

@@ -8,10 +8,10 @@ buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-2026101217
 buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20261012172039-ff528c23443f.2/go.mod h1:Gjxz0o7qPHokNsmlX8D+/aKd29NRLRr9CCUkPeMAzJ0=
 buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20261012172039-ff528c23443f.2/go.mod h1:Gjxz0o7qPHokNsmlX8D+/aKd29NRLRr9CCUkPeMAzJ0=
 buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 h1:NbnlmV26O7oZ1iM5tsCI+GEx+3ZSdrhvnKQ/eWSrLiY=
 buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 h1:NbnlmV26O7oZ1iM5tsCI+GEx+3ZSdrhvnKQ/eWSrLiY=
 buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw=
 buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw=
-buf.build/gen/go/bufbuild/registry/connectrpc/go/v2 v2.0.1-20261021182822-0b1ad74125ef.1 h1:zXGHYEI4xdb/zY5+nEnWUCIf7WJkZab7CrC2r+oCXjI=
-buf.build/gen/go/bufbuild/registry/connectrpc/go/v2 v2.0.1-20261021182822-0b1ad74125ef.1/go.mod h1:wYEKexzRZ5Ug0wm6A+PMH04FJMWs2uzaB+rCUR6vmK0=
-buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20261021182822-0b1ad74125ef.2 h1:LSu/wfp0AFF8/byeR+qPAiP35gusp68CDgpN90PGJdw=
-buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20261021182822-0b1ad74125ef.2/go.mod h1:v7IaNcBKy5G3G8gpG2m92P0R3HK2qtMGclWzf+1Aut4=
+buf.build/gen/go/bufbuild/registry/connectrpc/go/v2 v2.0.2-20261111181020-2b3683116fcf.1 h1:2KjaYggBuhWtRfQwPc2rkC5hu5kdDwr6XOVxnRagV1g=
+buf.build/gen/go/bufbuild/registry/connectrpc/go/v2 v2.0.2-20261111181020-2b3683116fcf.1/go.mod h1:RYRvWMu8OTgnzCsdwIgIfz+pTfJqnW1fezYRQRFs+os=
+buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20261111181020-2b3683116fcf.2 h1:pQ3IsqzJY0HQCSpdlXolBvbsJQSPCIGmmxY2dIUhMdM=
+buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20261111181020-2b3683116fcf.2/go.mod h1:v7IaNcBKy5G3G8gpG2m92P0R3HK2qtMGclWzf+1Aut4=
 buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.12-20241007202033-cf42259fcbfc.2 h1:9xw8g8Q6zHLC7CO/jtpl2UK0kQticxIG+mI6N86f2/M=
 buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.12-20241007202033-cf42259fcbfc.2 h1:9xw8g8Q6zHLC7CO/jtpl2UK0kQticxIG+mI6N86f2/M=
 buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.12-20241007202033-cf42259fcbfc.2/go.mod h1:+0bw+k8FY5MU3tojFh33HeA5xMfGWjAUQpr+maW5Z3o=
 buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.12-20241007202033-cf42259fcbfc.2/go.mod h1:+0bw+k8FY5MU3tojFh33HeA5xMfGWjAUQpr+maW5Z3o=
 buf.build/go/app v0.3.0 h1:XGdGg3iI/465w+PPipShXqGwuT43/Omk+5EtHYYly9Q=
 buf.build/go/app v0.3.0 h1:XGdGg3iI/465w+PPipShXqGwuT43/Omk+5EtHYYly9Q=
@@ -24,22 +24,22 @@ buf.build/go/interrupt v1.1.0 h1:olBuhgv9Sav4/9pkSLoxgiOsZDgM5VhRhvRpn3DL0lE=
 buf.build/go/interrupt v1.1.0/go.mod h1:ql56nXPG1oHlvZa6efNC7SKAQ/tUjS6z0mhJl0gyeRM=
 buf.build/go/interrupt v1.1.0/go.mod h1:ql56nXPG1oHlvZa6efNC7SKAQ/tUjS6z0mhJl0gyeRM=
 buf.build/go/protovalidate v1.4.0 h1:UjLrYbt5VX7+TMOs2+pG5FhZhIG1mSfK4EIopbb4LcM=
 buf.build/go/protovalidate v1.4.0 h1:UjLrYbt5VX7+TMOs2+pG5FhZhIG1mSfK4EIopbb4LcM=
 buf.build/go/protovalidate v1.4.0/go.mod h1:8vJfzNT6NIG2qm3uFsJDXMlRmG+bQJzbcIn1Aa0vPGs=
 buf.build/go/protovalidate v1.4.0/go.mod h1:8vJfzNT6NIG2qm3uFsJDXMlRmG+bQJzbcIn1Aa0vPGs=
-buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38=
-buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0=
+buf.build/go/protoyaml v0.8.0 h1:n6q7ygZTZo4kLzKXsymkiLharUgHfd507Ymi4t5xhY8=
+buf.build/go/protoyaml v0.8.0/go.mod h1:3XRN/7BQCQNlhIgwzE46QTHLuUScH2d76lAFLCQEFWo=
 buf.build/go/spdx v0.3.0 h1:vVxy1W0x1n3sqhZmJ7VZ/UtzNwK3xm0QowGGtwric4Y=
 buf.build/go/spdx v0.3.0 h1:vVxy1W0x1n3sqhZmJ7VZ/UtzNwK3xm0QowGGtwric4Y=
 buf.build/go/spdx v0.3.0/go.mod h1:bXdwQFem9Si3nsbNy8aJKGPoaPi5DKwdeEp5/ArZ6w8=
 buf.build/go/spdx v0.3.0/go.mod h1:bXdwQFem9Si3nsbNy8aJKGPoaPi5DKwdeEp5/ArZ6w8=
 buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 h1:njYKSWoLiq2i5O7y2bPPU2Yzp7iAU0Wk9KJ2OoAhNiU=
 buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 h1:njYKSWoLiq2i5O7y2bPPU2Yzp7iAU0Wk9KJ2OoAhNiU=
 buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5/go.mod h1:DQmodNT9EHX94WzUaWiZK+/4EaFa/xZTc1gzfCxZVXU=
 buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5/go.mod h1:DQmodNT9EHX94WzUaWiZK+/4EaFa/xZTc1gzfCxZVXU=
-cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk=
-cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU=
-cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0=
-cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
-charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
-charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
+cel.dev/cel-go v0.33.2 h1:eAcEdBguc6AFrGbPOyE0g168Uey4kKFW9J6qsBLSeMU=
+cel.dev/cel-go v0.33.2/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU=
+cel.dev/expr v0.26.0 h1:oLXvOF0jiYA5A/tmzU91pLEWZkKynhLJlC9Gw7PrKvs=
+cel.dev/expr v0.26.0/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
+charm.land/lipgloss/v2 v2.1.0 h1:RLCNLhSQST+NPOmvUSbRWZ3UFOXsf3ZXFmJ5E/2tHEU=
+charm.land/lipgloss/v2 v2.1.0/go.mod h1:wwfOgrNqUA9yjL8nmG6BIO0qQnmkEgJ8ISptbme83gI=
 codeberg.org/chavacava/garif v0.2.1 h1:K9oYxSlvlXrHXyW26Z4q61bTpJDo1wbvXcYKar/F/LM=
 codeberg.org/chavacava/garif v0.2.1 h1:K9oYxSlvlXrHXyW26Z4q61bTpJDo1wbvXcYKar/F/LM=
 codeberg.org/chavacava/garif v0.2.1/go.mod h1:oHnDSmc0f9K1MeE+MQD/yjkiIB5Xsn5y3S9Dg96Xk84=
 codeberg.org/chavacava/garif v0.2.1/go.mod h1:oHnDSmc0f9K1MeE+MQD/yjkiIB5Xsn5y3S9Dg96Xk84=
-codeberg.org/polyfloyd/go-errorlint v1.9.0 h1:VkdEEmA1VBpH6ecQoMR4LdphVI3fA4RrCh2an7YmodI=
-codeberg.org/polyfloyd/go-errorlint v1.9.0/go.mod h1:GPRRu2LzVijNn4YkrZYJfatQIdS+TrcK8rL5Xs24qw8=
+codeberg.org/polyfloyd/go-errorlint v1.9.1 h1:4/UVu3tmmRFvO0biLkgU/YanLVLTfOgRRBq3zziOj2o=
+codeberg.org/polyfloyd/go-errorlint v1.9.1/go.mod h1:7GV7tU/cIZ9osimAFcQ8Gjq1E96aB53dWg9OskOsV4s=
 connectrpc.com/connect v1.21.1 h1:h2kacdlzXpZU2OQFSv98xkjYXNUSr/ptxmGVbS/+CRM=
 connectrpc.com/connect v1.21.1 h1:h2kacdlzXpZU2OQFSv98xkjYXNUSr/ptxmGVbS/+CRM=
 connectrpc.com/connect v1.21.1/go.mod h1:fhMcVJ5w6r/yTYhnkEbW6+sIF7OZ5Ay7KYgmfebh2ek=
 connectrpc.com/connect v1.21.1/go.mod h1:fhMcVJ5w6r/yTYhnkEbW6+sIF7OZ5Ay7KYgmfebh2ek=
 connectrpc.com/connect/v2 v2.0.2 h1:PoPDmOlhc46elsuCdKCKTxjNBb3HJlKF+s/sP2oucPw=
 connectrpc.com/connect/v2 v2.0.2 h1:PoPDmOlhc46elsuCdKCKTxjNBb3HJlKF+s/sP2oucPw=
@@ -52,8 +52,8 @@ dev.gaijin.team/go/exhaustruct/v4 v4.0.0 h1:873r7aNneqoBB3IaFIzhvt2RFYTuHgmMjoKf
 dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88YdiB0Ai4fXOzPI=
 dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88YdiB0Ai4fXOzPI=
 dev.gaijin.team/go/exhaustruct/v5 v5.2.0 h1:HZB49Fshn8qHJCi9VzYtrcnR1jbLteRHXfnQz8Adams=
 dev.gaijin.team/go/exhaustruct/v5 v5.2.0 h1:HZB49Fshn8qHJCi9VzYtrcnR1jbLteRHXfnQz8Adams=
 dev.gaijin.team/go/exhaustruct/v5 v5.2.0/go.mod h1:5IshpqhgGdKIrF8lBmi3M6YKmI7T3hMzAL9nrflko88=
 dev.gaijin.team/go/exhaustruct/v5 v5.2.0/go.mod h1:5IshpqhgGdKIrF8lBmi3M6YKmI7T3hMzAL9nrflko88=
-dev.gaijin.team/go/golib v0.8.1 h1:JYju4x9BSo+QD/AYeHULVDcvEhiFg8wOi6pT0IaZF5E=
-dev.gaijin.team/go/golib v0.8.1/go.mod h1:c5fu7t1RSGMxSQgcUYO1sODbzsYnOCXJLmHeNG1Eb+0=
+dev.gaijin.team/go/golib v0.9.0 h1:6cA78yIjdgimPQgaf0W9oHhaFDHxeOyhf2wmdRoDJTc=
+dev.gaijin.team/go/golib v0.9.0/go.mod h1:J6xujTcJ4o8QcgApNjGTxpz2tg9uNjrwFJQczoM0VYE=
 github.com/4meepo/tagalign v1.4.4 h1:052JDGj2pBORc6NkD30dDXpsUpoYszDeUwr9/5YZuVA=
 github.com/4meepo/tagalign v1.4.4 h1:052JDGj2pBORc6NkD30dDXpsUpoYszDeUwr9/5YZuVA=
 github.com/4meepo/tagalign v1.4.4/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c=
 github.com/4meepo/tagalign v1.4.4/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c=
 github.com/Abirdcfly/dupword v0.1.8 h1:SrhcUuGsROBuChFxHALRYzyyPODWn9zwghmzPvD9Cd8=
 github.com/Abirdcfly/dupword v0.1.8 h1:SrhcUuGsROBuChFxHALRYzyyPODWn9zwghmzPvD9Cd8=
@@ -78,16 +78,16 @@ github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3Q
 github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
 github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
 github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
 github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
 github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
 github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
-github.com/MicahParks/jwkset v0.11.3 h1:Phli4RdTDdIdLXZpuO7abkwZyzIk0RDTUPVVBHPRdkQ=
-github.com/MicahParks/jwkset v0.11.3/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
-github.com/MicahParks/keyfunc/v3 v3.8.2 h1:eydEwk/pBAVrDIpmFfB/gkCcrp++xQ7YYXirrI2zlWE=
-github.com/MicahParks/keyfunc/v3 v3.8.2/go.mod h1:T4snFPe26GwMg45bBAdM5P6qWQyLxZHLwBhxR/9PnCs=
+github.com/MicahParks/jwkset v0.11.4 h1:sm6pqlEpa1YDu82Hoxho/O+Y291lPcksNImVIsV74gg=
+github.com/MicahParks/jwkset v0.11.4/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
+github.com/MicahParks/keyfunc/v3 v3.9.1 h1:10dlh2kS3kRtfj5x2bokCxLhTpmIpf3WtsvBdJ2TfOo=
+github.com/MicahParks/keyfunc/v3 v3.9.1/go.mod h1:h8DMhdulFa3yjG1q74FqaehNDekB18HrVCXPl9Raa4I=
 github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
 github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
 github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
 github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
 github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgyLJgsQ=
 github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgyLJgsQ=
 github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
 github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
-github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
-github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
+github.com/OpenPeeDeeP/depguard/v2 v2.3.0 h1:AzcyrJSVScO5DztG8NB5XSKNeuxqgFsLQf+sTXEbF8w=
+github.com/OpenPeeDeeP/depguard/v2 v2.3.0/go.mod h1:s0C87mbhDHS0xxt0K9kkxb+w+W1mdvgUyajfFs4AcfI=
 github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I=
 github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I=
 github.com/PaesslerAG/gval v1.2.4 h1:rhX7MpjJlcxYwL2eTTYIOBUyEKZ+A96T9vQySWkVUiU=
 github.com/PaesslerAG/gval v1.2.4 h1:rhX7MpjJlcxYwL2eTTYIOBUyEKZ+A96T9vQySWkVUiU=
 github.com/PaesslerAG/gval v1.2.4/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac=
 github.com/PaesslerAG/gval v1.2.4/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac=
@@ -158,8 +158,8 @@ github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1Di
 github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4=
 github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4=
 github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
 github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
 github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
 github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
-github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw=
-github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
+github.com/charmbracelet/ultraviolet v0.0.0-20261023060817-a45b7c86333f h1:D2JpB65eehsnkEwEG401dMBSNYtTDgvKXZZSl7ZCxWI=
+github.com/charmbracelet/ultraviolet v0.0.0-20261023060817-a45b7c86333f/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
 github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
 github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
 github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
 github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
 github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
 github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -199,12 +199,12 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
 github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
 github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
 github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
 github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
 github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
 github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
-github.com/dlclark/regexp2/v2 v2.9.1 h1:q6bZW/NN9VodqMjYXpuic2lZxdHPfFTzGS+MJxIJmKQ=
-github.com/dlclark/regexp2/v2 v2.9.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
-github.com/docker/cli v29.9.2+incompatible h1:trup/vUxx5uUo2t+87Cd9AmITR+DvKUe4H2tTtZN8FY=
-github.com/docker/cli v29.9.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
-github.com/docker/docker-credential-helpers v0.9.12 h1:hAgjJcMuC17DPf0Kl/HQSZQ7cxVo+wEA8Oo1SzkmqFg=
-github.com/docker/docker-credential-helpers v0.9.12/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
+github.com/dlclark/regexp2/v2 v2.13.0 h1:sGPfGE1Bx7H3F9vcCdLrKUFvnau7DGiQZxHPQ6rMMs4=
+github.com/dlclark/regexp2/v2 v2.13.0/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
+github.com/docker/cli v29.9.3+incompatible h1:SBaMp7ecohadEHMDrUJg22t9I3awqsAVhnJ7GOcWpfE=
+github.com/docker/cli v29.9.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/docker-credential-helpers v0.9.13 h1:yLE5jCyKJraJaBACIGqgpLX0UheWfZUwBkIeCu6WPFU=
+github.com/docker/docker-credential-helpers v0.9.13/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
 github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc=
 github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc=
 github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
 github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
 github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
 github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
@@ -227,12 +227,12 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5
 github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
 github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
 github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
 github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
 github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
 github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
-github.com/ghostiam/protogetter v1.0.1 h1:q4LLTx2KJHJpxrfO3BniUDWoFkoqWD/UlHhClBlUlLM=
-github.com/ghostiam/protogetter v1.0.1/go.mod h1:50Fh5yZfs6SFGrd1FlmT04NcguyEOxyjNK5zGD6/EvM=
+github.com/ghostiam/protogetter v1.0.2 h1:LODUypum0pUnmBE/PD3hll7vkX5OzSDy/ZMoofsDrU8=
+github.com/ghostiam/protogetter v1.0.2/go.mod h1:m/nMpC841v/KHpw7J3+R6h4aEStoeOPsGyU/HeNoPYY=
 github.com/go-critic/go-critic v0.15.0 h1:MDUZIQKytIl+vPEc0Igw51m4uLTdi1ZGz2jatEqNs1g=
 github.com/go-critic/go-critic v0.15.0 h1:MDUZIQKytIl+vPEc0Igw51m4uLTdi1ZGz2jatEqNs1g=
 github.com/go-critic/go-critic v0.15.0/go.mod h1:3BPrw8nHATGm3o2n37cssOmJzpySGKCdY8JWkCzxgKQ=
 github.com/go-critic/go-critic v0.15.0/go.mod h1:3BPrw8nHATGm3o2n37cssOmJzpySGKCdY8JWkCzxgKQ=
-github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE=
-github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
+github.com/go-json-experiment/json v1.27.2 h1:rZIyqYh+Qq+nEa+Y5iwohr8h7G/MfLJqu2CD4/ZONqM=
+github.com/go-json-experiment/json v1.27.2/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
 github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
 github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
 github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
 github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -265,12 +265,12 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
 github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
 github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
 github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
 github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
 github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
 github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
-github.com/gobwas/glob v1.0.0 h1:p+FKbLEIsK1yZ39/OINwFvqNb5oyPY4H8xcy6uYu8dg=
-github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5rkc=
+github.com/gobwas/glob v1.1.0 h1:CS8tIr7+5fu50N+tKsQy3osOU5jmhjHv0/ildcKAn2Q=
+github.com/gobwas/glob v1.1.0/go.mod h1:5OYLTTNYHcww+D/XwWAjGpmIE8sD41kWneKyzWkK7/k=
 github.com/godoc-lint/godoc-lint v0.11.4 h1:N9zUYVJ/OkznBbrBsU/zODvua64EUplMBLQWStO3+1M=
 github.com/godoc-lint/godoc-lint v0.11.4 h1:N9zUYVJ/OkznBbrBsU/zODvua64EUplMBLQWStO3+1M=
 github.com/godoc-lint/godoc-lint v0.11.4/go.mod h1:7usTXoxS0QYIi5usTSCPwR0w/6xLjcz450shXjLn3t4=
 github.com/godoc-lint/godoc-lint v0.11.4/go.mod h1:7usTXoxS0QYIi5usTSCPwR0w/6xLjcz450shXjLn3t4=
-github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo=
-github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE=
+github.com/gofrs/flock v0.13.2 h1:9o62TArG4T0f8L46kSf8sLtNUGvJEfiCK9md7/ilko0=
+github.com/gofrs/flock v0.13.2/go.mod h1:OEjpF6MC2Plt1fLXTTgXwnZohWbs/Alxf2i1qTne/Vg=
 github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
 github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
 github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
 github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
 github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0=
 github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0=
@@ -285,16 +285,16 @@ github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 h1:WL8YKrt3UbOBqSRU
 github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792/go.mod h1:te5hX0dW4C5r6YbXs+6ysNr8Q5UTmdIqGbb+mlFiYmA=
 github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792/go.mod h1:te5hX0dW4C5r6YbXs+6ysNr8Q5UTmdIqGbb+mlFiYmA=
 github.com/golangci/golangci-lint/v2 v2.15.0 h1:w94zf8HAYI7hm3164kwKsvZRuqXvY+YxeFoTbu1OLg8=
 github.com/golangci/golangci-lint/v2 v2.15.0 h1:w94zf8HAYI7hm3164kwKsvZRuqXvY+YxeFoTbu1OLg8=
 github.com/golangci/golangci-lint/v2 v2.15.0/go.mod h1:/FRI304IxqDVhX2o3ZZ0qp8Vvz8egJdzCY5A4GjvmTU=
 github.com/golangci/golangci-lint/v2 v2.15.0/go.mod h1:/FRI304IxqDVhX2o3ZZ0qp8Vvz8egJdzCY5A4GjvmTU=
-github.com/golangci/golines v0.16.0 h1:xKdwzRvwzLd7V131B/dv90vUiEoTA0vQybdeqperPOg=
-github.com/golangci/golines v0.16.0/go.mod h1:mCMgRQHiCvuALJOeaNtylu09zbzvS9Ka01dfvLLJios=
+github.com/golangci/golines v0.16.1 h1:LjI+R8vBMh7XLQ42qF3lxf0bh7GZd/0ExD5biGp9yHM=
+github.com/golangci/golines v0.16.1/go.mod h1:I0JN16r8kpa1/U5N327nV8Rx434LGeltyNxiH1q7g1o=
 github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg=
 github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg=
 github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
 github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
 github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg=
 github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg=
 github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw=
 github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw=
 github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s=
 github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s=
 github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k=
 github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k=
-github.com/golangci/rowserrcheck v0.0.0-20261005233414-e0740d52dee8 h1:oN042y1yEcpldrqY81puyNA4ZjQZJzt0U/t7HyYrMic=
-github.com/golangci/rowserrcheck v0.0.0-20261005233414-e0740d52dee8/go.mod h1:T1Bg58Sk3N352aBbPLJPpHykFWxEEwV3o4/Enn+PPU8=
+github.com/golangci/rowserrcheck v0.0.0-20261101210309-7f680fea35ce h1:UUPBjacsOTgtbb9EFS2iXYD/QheQNRmMOigT47EjRqM=
+github.com/golangci/rowserrcheck v0.0.0-20261101210309-7f680fea35ce/go.mod h1:04KEEg+ulF0lAeUVstBe8kNccsLZ1DF1eDgNnfUaEik=
 github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM=
 github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM=
 github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s=
 github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s=
 github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM=
 github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM=
@@ -306,8 +306,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 github.com/google/go-containerregistry v0.22.1 h1:RZuuSYhTvlDvtsK+NkutoCZ//C0X2ebLK8X8l3ULs84=
 github.com/google/go-containerregistry v0.22.1 h1:RZuuSYhTvlDvtsK+NkutoCZ//C0X2ebLK8X8l3ULs84=
 github.com/google/go-containerregistry v0.22.1/go.mod h1:bJR35SK8XgisYmhg/FMQ/5RK0S/XrOAqLBV5/LR2XE0=
 github.com/google/go-containerregistry v0.22.1/go.mod h1:bJR35SK8XgisYmhg/FMQ/5RK0S/XrOAqLBV5/LR2XE0=
-github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
-github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
+github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
+github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs=
 github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs=
@@ -337,8 +337,8 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/jamesread/golure v0.0.0-20260927082702-900b761e432f h1:9eoL7vPEKO2JXRJjfLCmONS5jGpMXMsQzHwjTIOpN98=
-github.com/jamesread/golure v0.0.0-20260927082702-900b761e432f/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE=
+github.com/jamesread/golure v0.0.0-20261111213547-dc225fc15a30 h1:OH3LW5inwllequ0Saiv/y7mvfGWSmEE0rLROIEW5IBs=
+github.com/jamesread/golure v0.0.0-20261111213547-dc225fc15a30/go.mod h1:eanAcCk4sl2dM5bax4jeSCexnVYRF2IhhtQ4B+Wx8DU=
 github.com/jdx/go-netrc v1.0.0 h1:QbLMLyCZGj0NA8glAhxUpf1zDg6cxnWgMBbjq40W0gQ=
 github.com/jdx/go-netrc v1.0.0 h1:QbLMLyCZGj0NA8glAhxUpf1zDg6cxnWgMBbjq40W0gQ=
 github.com/jdx/go-netrc v1.0.0/go.mod h1:Gh9eFQJnoTNIRHXl2j5bJXA1u84hQWJWgGh569zF3v8=
 github.com/jdx/go-netrc v1.0.0/go.mod h1:Gh9eFQJnoTNIRHXl2j5bJXA1u84hQWJWgGh569zF3v8=
 github.com/jgautheron/goconst v1.11.0 h1:KgN90z5qXt5f0Uzf3cWXev3hfMMFUyNeKdpkSBRvLDk=
 github.com/jgautheron/goconst v1.11.0 h1:KgN90z5qXt5f0Uzf3cWXev3hfMMFUyNeKdpkSBRvLDk=
@@ -355,8 +355,8 @@ github.com/kisielk/errcheck v1.30.0 h1:ZIxNDAfAd4larXHTceoZP/dYPAB90dHg1OYhA2sVh
 github.com/kisielk/errcheck v1.30.0/go.mod h1:uiCAInmFuyOje4VDdbQSP9mgqf5gORYSXrDf19XilsI=
 github.com/kisielk/errcheck v1.30.0/go.mod h1:uiCAInmFuyOje4VDdbQSP9mgqf5gORYSXrDf19XilsI=
 github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
 github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
 github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
 github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
-github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
-github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
+github.com/klauspost/compress v1.20.4 h1:dD0v/bvxtjCBqQu6sx1TNWkalEY5lNTIe+HY9w8TeNI=
+github.com/klauspost/compress v1.20.4/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
 github.com/klauspost/pgzip v1.3.0 h1:8t+KjjzD/0n/8vNlJGGZmTeK4njuwffOQvJm8tOSc8o=
 github.com/klauspost/pgzip v1.3.0 h1:8t+KjjzD/0n/8vNlJGGZmTeK4njuwffOQvJm8tOSc8o=
 github.com/klauspost/pgzip v1.3.0/go.mod h1:Xx7tAv5DH8ObLHUDQKXoE/nd8i/FYdNDTVMvi3SLTKI=
 github.com/klauspost/pgzip v1.3.0/go.mod h1:Xx7tAv5DH8ObLHUDQKXoE/nd8i/FYdNDTVMvi3SLTKI=
 github.com/knadh/koanf/maps v0.1.3 h1:P1z7EvTqdFBrPYbzSvorvrpib+sjkUMxf0FVvA5NKK4=
 github.com/knadh/koanf/maps v0.1.3 h1:P1z7EvTqdFBrPYbzSvorvrpib+sjkUMxf0FVvA5NKK4=
@@ -387,8 +387,8 @@ github.com/ldez/gomoddirectives v0.10.0 h1:Zb23rfMMkG21B6pZ9OfESzemByP5Qr7YvWcXQ
 github.com/ldez/gomoddirectives v0.10.0/go.mod h1:W9uh0721Vc7aaVoiMXTpKIveLmtHC9uaOSiHM4OK5a8=
 github.com/ldez/gomoddirectives v0.10.0/go.mod h1:W9uh0721Vc7aaVoiMXTpKIveLmtHC9uaOSiHM4OK5a8=
 github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o=
 github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o=
 github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas=
 github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas=
-github.com/ldez/structtags v0.6.1 h1:bUooFLbXx41tW8SvkfwfFkkjPYvFFs59AAMgVg6DUBk=
-github.com/ldez/structtags v0.6.1/go.mod h1:YDxVSgDy/MON6ariaxLF2X09bh19qL7MtGBN5MrvbdY=
+github.com/ldez/structtags v0.6.2 h1:1KJCfTjNMFTD6IICI5U8VIur6Fs1nm43wAGYL4zqJEw=
+github.com/ldez/structtags v0.6.2/go.mod h1:arTakDbLbIbYBEKq+0ds2UB8OBd2zMeYLwI1cH5Yv78=
 github.com/ldez/tagliatelle v0.8.0 h1:QczqVPa22JdT40p3m5JTTVmPMv5wDORqdomJn6/Pnn0=
 github.com/ldez/tagliatelle v0.8.0 h1:QczqVPa22JdT40p3m5JTTVmPMv5wDORqdomJn6/Pnn0=
 github.com/ldez/tagliatelle v0.8.0/go.mod h1:K9n83Wv0wyVq3JYF9WI28BX8xXhDBVP3thHJ0ENuiEs=
 github.com/ldez/tagliatelle v0.8.0/go.mod h1:K9n83Wv0wyVq3JYF9WI28BX8xXhDBVP3thHJ0ENuiEs=
 github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc=
 github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc=
@@ -439,16 +439,16 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
 github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=
 github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=
 github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
 github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
-github.com/nishanths/exhaustive v0.16.1 h1:5la0VKPN6CY8WYJq+JfyP970R3IBbJHZiTUY+Fl0yuA=
-github.com/nishanths/exhaustive v0.16.1/go.mod h1:/nBwAMRn2QmCjWQ3PiJ939yRSwvWLKSa21L2C6Tp84E=
+github.com/nishanths/exhaustive v0.17.1 h1:zlwR7GV/UqKrm3AJdeXqldUTSsbyfdqS8f/m0oMYLD0=
+github.com/nishanths/exhaustive v0.17.1/go.mod h1:/nBwAMRn2QmCjWQ3PiJ939yRSwvWLKSa21L2C6Tp84E=
 github.com/nishanths/predeclared v0.3.1 h1:qb/HpkPb0bF0drRBFkTyKY1ZRWcilc6gtxaBZGgn8H8=
 github.com/nishanths/predeclared v0.3.1 h1:qb/HpkPb0bF0drRBFkTyKY1ZRWcilc6gtxaBZGgn8H8=
 github.com/nishanths/predeclared v0.3.1/go.mod h1:Tsd/DNUrOSU4nGhDSYOXexypmCMpi2nXII8oiDVEzmU=
 github.com/nishanths/predeclared v0.3.1/go.mod h1:Tsd/DNUrOSU4nGhDSYOXexypmCMpi2nXII8oiDVEzmU=
 github.com/nunnatsa/ginkgolinter v0.24.0 h1:Mp0EagluLFP98JatP6nqp/gGEoljNG97uf9AcxcBVy8=
 github.com/nunnatsa/ginkgolinter v0.24.0 h1:Mp0EagluLFP98JatP6nqp/gGEoljNG97uf9AcxcBVy8=
 github.com/nunnatsa/ginkgolinter v0.24.0/go.mod h1:2ZMRuzX6+3XXyY6UZOwb6n+MCocVGbkIsDBC4vuWz5c=
 github.com/nunnatsa/ginkgolinter v0.24.0/go.mod h1:2ZMRuzX6+3XXyY6UZOwb6n+MCocVGbkIsDBC4vuWz5c=
-github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw=
-github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
-github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
-github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
+github.com/onsi/ginkgo/v2 v2.33.1 h1:62XfWJbEIjPSCSDYrsCPP/SHcmH7O0M8ne8zfqQ2iz4=
+github.com/onsi/ginkgo/v2 v2.33.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
+github.com/onsi/gomega v1.44.0 h1:eAiGl3Pw5jz5GQdDff0BcxYpAX1JxW8xD7mFUuwNfZQ=
+github.com/onsi/gomega v1.44.0/go.mod h1:e/C2HwaZ1DhvjzXXuFhcR7hY7Sh9pl7MmoWKEjzwcdA=
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
 github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
 github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -460,10 +460,12 @@ github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJ
 github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
 github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
 github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
 github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
 github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
 github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
+github.com/pandatix/go-abnf v0.5.5 h1:EXex5KkRQx4Dm6GnpAcQnIMBs9KiK0uf8p/wq+4t9PM=
+github.com/pandatix/go-abnf v0.5.5/go.mod h1:AE0KD3/VNoCjGzc1gqetRKshbI4SYErXQ/8kPenR5mc=
 github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
 github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
 github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
 github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
-github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b h1:OzNsuVdSWGwXvWKTtChx9ve89k4cFJ6NxYM4Aw4/f+E=
-github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
+github.com/petermattis/goid v0.0.0-20261109124353-7cfa343ad0ab h1:Hn5vlRa0Br9/K866ZafCFIHz6M+xTnbV3sqMrPsT6pE=
+github.com/petermattis/goid v0.0.0-20261109124353-7cfa343ad0ab/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -471,10 +473,10 @@ github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKq
 github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk=
 github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk=
 github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
 github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
 github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
 github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
-github.com/prometheus/common v0.72.1 h1:ZAiQed/nk8IIcsdzb2TlJu23NEUDyDLQ9TXTN2WRK6Y=
-github.com/prometheus/common v0.72.1/go.mod h1:1IoBY4IRaJnhs2ESjNzjilP1cJ/P8pimGoomq7zRXrA=
-github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
-github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
+github.com/prometheus/common v0.73.1 h1:SZWh/j4AvhCdhX8jvYSnxvPxhWlC9QZkjJ+tOrEr7kU=
+github.com/prometheus/common v0.73.1/go.mod h1:I9aA/Ukz9s1olOobh8KtCkU7B1mQPQsB9UvQPsUQ1Dk=
+github.com/prometheus/procfs v0.23.0 h1:ad+S6FTkzwaMgacKLM5h6bNKExS+DMH0YiUwXItH/o4=
+github.com/prometheus/procfs v0.23.0/go.mod h1:3AVXO0FqLafyYFU/V/IQRZlC8Rgqz8Dm/x6pKLK+RIk=
 github.com/protocolbuffers/protoscope v0.0.0-20221109213918-8e7a6aafa2c9 h1:arwj11zP0yJIxIRiDn22E0H8PxfF7TsTrc2wIPFIsf4=
 github.com/protocolbuffers/protoscope v0.0.0-20221109213918-8e7a6aafa2c9 h1:arwj11zP0yJIxIRiDn22E0H8PxfF7TsTrc2wIPFIsf4=
 github.com/protocolbuffers/protoscope v0.0.0-20221109213918-8e7a6aafa2c9/go.mod h1:SKZx6stCn03JN3BOWTwvVIO2ajMkb/zQdTceXYhKw/4=
 github.com/protocolbuffers/protoscope v0.0.0-20221109213918-8e7a6aafa2c9/go.mod h1:SKZx6stCn03JN3BOWTwvVIO2ajMkb/zQdTceXYhKw/4=
 github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA=
 github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA=
@@ -491,10 +493,10 @@ github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4
 github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
 github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
 github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
 github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
 github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
 github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
-github.com/quic-go/quic-go v0.64.1 h1:BCNTP+6x6++OPKrGST2FR3f1tycx8O3QOA3QQv09g70=
-github.com/quic-go/quic-go v0.64.1/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
-github.com/raeperd/recvcheck v0.3.1 h1:p7nZMxiCWkdUtY2i3o+pv1h4fJCR5gpvqAXr9326hEY=
-github.com/raeperd/recvcheck v0.3.1/go.mod h1:CbE7iRocQ4uc71JwpW/CPSh4BShnkre4A9Gb508RypU=
+github.com/quic-go/quic-go v0.65.0 h1:BJQSjdTvLPLmWtaCupUphRbgxgpZhdO/GMpr9Eoe3B4=
+github.com/quic-go/quic-go v0.65.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
+github.com/raeperd/recvcheck v0.3.2 h1:uEL/XDkUASomQfB/KbepaVsVnyKqNSeqNHHX0au9VAk=
+github.com/raeperd/recvcheck v0.3.2/go.mod h1:1GYvabo+pT440BrQ5iT7Vf1JZl15rCYslKq6W+NV7HI=
 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
 github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
@@ -523,8 +525,8 @@ github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tM
 github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ=
 github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ=
 github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ=
 github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ=
 github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
 github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
-github.com/securego/gosec/v2 v2.29.0 h1:pF2HSLcnY5voqpxQumEe0O5YAAbvF4Syu4qeIewpLyc=
-github.com/securego/gosec/v2 v2.29.0/go.mod h1:47C7ej+L2bC1NOxteU2aCGnCi4cZaoKzRt5y9plIvuY=
+github.com/securego/gosec/v2 v2.30.0 h1:67aF6LHa7SAL6QEc4es857GrXTT50gUqmrl0XPhtDx0=
+github.com/securego/gosec/v2 v2.30.0/go.mod h1:qQn/kd9spDGy4yCVezLnzPkUuHPhm6jiIcBANad01Sw=
 github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
 github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
 github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
 github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
 github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
@@ -546,8 +548,8 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
 github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
 github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
-github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.1.0 h1:6W6MbVUrnMb8PEhjkqwsVLY7ryuzRfFHF4mQd7L94ag=
+github.com/spf13/pflag v1.1.0/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
 github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
 github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
 github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
 github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0=
 github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0=
@@ -573,8 +575,8 @@ github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZ
 github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
 github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
 github.com/tidwall/btree v1.8.2 h1:mTY5iM4UTh2e80TG7RHDQQmnAY3xGNQA/DbU0olkhkA=
 github.com/tidwall/btree v1.8.2 h1:mTY5iM4UTh2e80TG7RHDQQmnAY3xGNQA/DbU0olkhkA=
 github.com/tidwall/btree v1.8.2/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
 github.com/tidwall/btree v1.8.2/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
-github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 h1:HECVafB2AKntWWRfsiQ4gdunLx5WOYQzFdCsxqOrzp8=
-github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2/go.mod h1:JLoPVtROtNgJ99yXTIxuGNttvmJa85tkJAO6lr05qBA=
+github.com/timakin/bodyclose v0.0.0-20261107224203-8bb60b19bea4 h1:kAmyvrO1ePXQxa1gD3Mq27AtapMDmOJdDd5WW/gCY6s=
+github.com/timakin/bodyclose v0.0.0-20261107224203-8bb60b19bea4/go.mod h1:JLoPVtROtNgJ99yXTIxuGNttvmJa85tkJAO6lr05qBA=
 github.com/timonwong/loggercheck v0.13.1 h1:JlOsAuKcfWLM/8wplNNzQ7l9h6HL4ZeAgMhFaSXL2dc=
 github.com/timonwong/loggercheck v0.13.1 h1:JlOsAuKcfWLM/8wplNNzQ7l9h6HL4ZeAgMhFaSXL2dc=
 github.com/timonwong/loggercheck v0.13.1/go.mod h1:7HxbAwCq9vULYfq9Upvmq8jX00w2/Z7BEfiVE78Jji8=
 github.com/timonwong/loggercheck v0.13.1/go.mod h1:7HxbAwCq9vULYfq9Upvmq8jX00w2/Z7BEfiVE78Jji8=
 github.com/tomarrell/wrapcheck/v2 v2.13.0 h1:jYbLNs8PDkzGCY6d5LUzJdY7Bq//CTlb2HxZGV5z0JY=
 github.com/tomarrell/wrapcheck/v2 v2.13.0 h1:jYbLNs8PDkzGCY6d5LUzJdY7Bq//CTlb2HxZGV5z0JY=
@@ -591,8 +593,8 @@ github.com/uudashr/iface v1.5.3 h1:N61bzr11L7BHP6Rct+C6zQePHjSTMeyVBq+s03uB/Ds=
 github.com/uudashr/iface v1.5.3/go.mod h1:5UWoT6SvTdTww/KToRj6clO+n4Kg3/NrAQvgOB+5Ggw=
 github.com/uudashr/iface v1.5.3/go.mod h1:5UWoT6SvTdTww/KToRj6clO+n4Kg3/NrAQvgOB+5Ggw=
 github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM=
 github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM=
 github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4=
 github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4=
-github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4=
-github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
+github.com/xo/terminfo v1.3.1 h1:4ZwPCuu4PEFw0PkNp3Hv/x3gqvgLCzYhurszsj101cM=
+github.com/xo/terminfo v1.3.1/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
 github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM=
 github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM=
 github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk=
 github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk=
 github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs=
 github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs=
@@ -653,29 +655,29 @@ go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
 go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
 go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
 go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
 go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
-go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
+go.yaml.in/yaml/v3 v3.0.6 h1:zcDfvjXEqTQmpjniUBLc91xYJMB2c4QOFxEF/LCEckM=
+go.yaml.in/yaml/v3 v3.0.6/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
 golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
-golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
-golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
-golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A=
-golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw=
+golang.org/x/crypto v0.59.0 h1:xS2jhqYbzNrpvkWP2YTDf4TJwBy3ykR03i8vFDxEc7c=
+golang.org/x/crypto v0.59.0/go.mod h1:veDyUwATGs3c+vN3VfTbzViMGXeTBk3WyhBOkn+F/4U=
+golang.org/x/exp v0.0.0-20261109185158-a52e3aef5920 h1:qr+tk4P56rtbVVAJDyAr5LAOiaD/XhZCoRTr4SfLxAk=
+golang.org/x/exp v0.0.0-20261109185158-a52e3aef5920/go.mod h1:J3U+E8RzytcGvwz+uJuAP4zULJ2TkAQBnmPZMexpdXs=
 golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
 golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
 golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
 golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
-golang.org/x/exp/typeparams v0.0.0-20261014143641-f0251eb4aa3e h1:SBQ1wB3Zgy/s4PeK7ScBk1sly3f7wfGDOgscRCf7EaE=
-golang.org/x/exp/typeparams v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:qkMnvgP8C7+xkci04txL2SJFc/0PGqnkf6gs5JUC43E=
+golang.org/x/exp/typeparams v0.0.0-20261109185158-a52e3aef5920 h1:qOQF+X4EUzJzIRkFJTVBqWc+QH3EbeCGFCWKVmzbuEk=
+golang.org/x/exp/typeparams v0.0.0-20261109185158-a52e3aef5920/go.mod h1:qkMnvgP8C7+xkci04txL2SJFc/0PGqnkf6gs5JUC43E=
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
 golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
-golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
+golang.org/x/mod v0.43.0 h1:MFuq5J6s35W9D3IjkwyWc9vHh0nENOrt5K8Iof4ofmg=
+golang.org/x/mod v0.43.0/go.mod h1:4ec1JnuyBz5+FIVhg86UsA39YG8BpnrZdJYSNHmGzHY=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -686,10 +688,10 @@ golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qx
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
-golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
-golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
-golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
+golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
+golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
+golang.org/x/oauth2 v0.38.0 h1:uW7XQbcu1eoiQS7uMJvLOjPMl04bTbmY1VeRPxvbspI=
+golang.org/x/oauth2 v0.38.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -697,8 +699,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
-golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
+golang.org/x/sync v0.25.0 h1:dJnifr0nMV8/SBnBvdo4eSGGnrc3O4FFx6IqvtLcmRs=
+golang.org/x/sync v0.25.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -714,15 +716,15 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
-golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/sys v0.50.0 h1:dkKZEFTEKaLr1sjhs0WiKqoi8Jj5nHbS3Njmx1UoMJs=
+golang.org/x/sys v0.50.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
 golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
 golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
 golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
-golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
-golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
+golang.org/x/term v0.48.0 h1:G6UU1eiSB8nl3DzeUGt7Mg+2SctI8tPS6Z5/mBt6cOk=
+golang.org/x/term v0.48.0/go.mod h1:BRDLHCZr2SUjIeBHHpRyQeC9xzSAVbzY2rvKgafnHf4=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -731,8 +733,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
-golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
-golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
+golang.org/x/text v0.44.0 h1:YItdp/mQNGnsmrgpcdA5yQ3bnRxCfo/Tj/iByGW9GGo=
+golang.org/x/text v0.44.0/go.mod h1:S4FR1waQ9LbjQTls97SyRjDaUUVkaM+cXwwuG/G+MwY=
 golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
 golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
 golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
 golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -744,8 +746,8 @@ golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0t
 golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
 golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
-golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
+golang.org/x/tools v0.52.1-0.20261106145633-d407941a339a h1:Yqozs0FNPRoPGS4hNGlrNwRENWr5H5nVG9sN/f+5640=
+golang.org/x/tools v0.52.1-0.20261106145633-d407941a339a/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
 golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
 golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
 golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
 golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
 golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
 golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -754,12 +756,12 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b h1:nB3spRIaSPtxJpxb0k41yljROvm2Jrwfs2pSNdKS5y8=
-google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b/go.mod h1:9N4vtjEwvgQKEdlFVQO0M44ErhecQ9DCSQfGoZ51M6w=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b h1:6WNWHZk91dp4gZ25642OALkuG1UVNZbwENJD19DnB8k=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
-google.golang.org/protobuf v1.36.13-0.20261016172727-e9ee2a76ab8e h1:TU4XCsANCrpOpeS6NA+LhCA6H12DfxF2gIcYBrWcdwc=
-google.golang.org/protobuf v1.36.13-0.20261016172727-e9ee2a76ab8e/go.mod h1:X7eSFOSeyjM8zqq7vd+xpgLluojDTuH3nwq1zr6OCKg=
+google.golang.org/genproto/googleapis/api v0.0.0-20261110000943-09e21b237f2c h1:ofk4SEqvpf6M5VWh88xRp42IYgdVOh7gffebnDj3OqU=
+google.golang.org/genproto/googleapis/api v0.0.0-20261110000943-09e21b237f2c/go.mod h1:OzYcNQzaMRKcOFRMtyLzG/cfW1SmS2eh+7UmOLLFblk=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20261110000943-09e21b237f2c h1:XXChExL+Tm7Ubp0x0VXUEncPLHrkXIgoVWqnxmKNbAA=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20261110000943-09e21b237f2c/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
+google.golang.org/protobuf v1.37.0-rc.1 h1:RQUh2I7V8DjoPJlj2K1BXNUKIOpK2ZdPRfG0HK766Yg=
+google.golang.org/protobuf v1.37.0-rc.1/go.mod h1:X7eSFOSeyjM8zqq7vd+xpgLluojDTuH3nwq1zr6OCKg=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -772,8 +774,8 @@ honnef.co/go/tools v0.8.2 h1:+jPbCzuYxYrcoOvCecYUFDj+XJQRmzIhJmlbpps21Zk=
 honnef.co/go/tools v0.8.2/go.mod h1:xG1Q4n+mg3bKZrO6RWFA9jFlkqplflmDRHGeuexiLDs=
 honnef.co/go/tools v0.8.2/go.mod h1:xG1Q4n+mg3bKZrO6RWFA9jFlkqplflmDRHGeuexiLDs=
 mvdan.cc/gofumpt v0.12.0 h1:1Lbudkz2kpM9Cjz2pL4M19u7q+GaEhCTNf7N9mfpcho=
 mvdan.cc/gofumpt v0.12.0 h1:1Lbudkz2kpM9Cjz2pL4M19u7q+GaEhCTNf7N9mfpcho=
 mvdan.cc/gofumpt v0.12.0/go.mod h1:SmBHHrljiZu/uoypeKup3rFzP6eoC9UwCp2iH5E3jZA=
 mvdan.cc/gofumpt v0.12.0/go.mod h1:SmBHHrljiZu/uoypeKup3rFzP6eoC9UwCp2iH5E3jZA=
-mvdan.cc/unparam v0.0.0-20261011093844-7a2c2c0b7aa6 h1:BPYbCqDxFpG6SoLzbiU5hVn8kHW89plTWGddjT3OD7I=
-mvdan.cc/unparam v0.0.0-20261011093844-7a2c2c0b7aa6/go.mod h1:BEImwwhtsPZ0eyZhBtvrirFOvbQ7HmEnmfdFl3tz9M0=
+mvdan.cc/unparam v0.0.0-20261102141913-394cd0a56cd5 h1:+w/oD0+NrvT39TwXLC5ElQte8LwPcFuTWdbJ+98LeAE=
+mvdan.cc/unparam v0.0.0-20261102141913-394cd0a56cd5/go.mod h1:BEImwwhtsPZ0eyZhBtvrirFOvbQ7HmEnmfdFl3tz9M0=
 mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
 mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
 mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
 mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
 pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
 pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=

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

@@ -336,6 +336,8 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request
 }
 }
 
 
 func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetRequest]) (*connect.Response[apiv1.StartActionByGetResponse], error) {
 func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetRequest]) (*connect.Response[apiv1.StartActionByGetResponse], error) {
+	reportDeprecatedAPICall("StartActionByGet", "StartAction")
+
 	binding := api.executor.FindBindingByID(req.Msg.ActionId)
 	binding := api.executor.FindBindingByID(req.Msg.ActionId)
 	if binding == nil || binding.Action == nil {
 	if binding == nil || binding.Action == nil {
 		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
 		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
@@ -387,6 +389,8 @@ func (api *oliveTinAPI) startActionByGetAndWaitLogEntry(binding *executor.Action
 }
 }
 
 
 func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetAndWaitRequest]) (*connect.Response[apiv1.StartActionByGetAndWaitResponse], error) {
 func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetAndWaitRequest]) (*connect.Response[apiv1.StartActionByGetAndWaitResponse], error) {
+	reportDeprecatedAPICall("StartActionByGetAndWait", "StartActionAndWait")
+
 	binding := api.executor.FindBindingByID(req.Msg.ActionId)
 	binding := api.executor.FindBindingByID(req.Msg.ActionId)
 	if binding == nil || binding.Action == nil {
 	if binding == nil || binding.Action == nil {
 		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
 		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
@@ -582,6 +586,18 @@ func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.Logou
 	}
 	}
 	response.Header().Add("Set-Cookie", oauth2Cookie.String())
 	response.Header().Add("Set-Cookie", oauth2Cookie.String())
 
 
+	// Clear any leftover OAuth2 CSRF state cookie from an in-flight login
+	oauthStateCookie := &http.Cookie{
+		Name:     "olivetin-oauth-state",
+		Value:    "",
+		MaxAge:   -1,
+		HttpOnly: true,
+		Path:     "/",
+		Secure:   secure,
+		SameSite: http.SameSiteLaxMode,
+	}
+	response.Header().Add("Set-Cookie", oauthStateCookie.String())
+
 	return response, nil
 	return response, nil
 }
 }
 
 
@@ -1127,7 +1143,7 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
 
 
 	api.forwardEventsToClient(ctx.Done(), client, srv)
 	api.forwardEventsToClient(ctx.Done(), client, srv)
 
 
-	log.Infof("EventStream: client disconnected")
+	log.Debugf("EventStream: client disconnected")
 
 
 	return nil
 	return nil
 }
 }
@@ -1349,11 +1365,11 @@ func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitReq
 func (api *oliveTinAPI) buildInitResponse(user *authpublic.AuthenticatedUser) *apiv1.InitResponse {
 func (api *oliveTinAPI) buildInitResponse(user *authpublic.AuthenticatedUser) *apiv1.InitResponse {
 	loginRequired := user.IsGuest() && api.cfg.AuthRequireGuestsToLogin
 	loginRequired := user.IsGuest() && api.cfg.AuthRequireGuestsToLogin
 	currentVersion, availableVersion, showNewVersions := initVersionFields(user, api.cfg)
 	currentVersion, availableVersion, showNewVersions := initVersionFields(user, api.cfg)
-	rootDashboardEntries := api.buildRootDashboardEntries(user, api.cfg.Dashboards)
+	showNavigation, rootDashboardEntries := api.initNavigationFields(user, loginRequired)
 
 
 	res := &apiv1.InitResponse{
 	res := &apiv1.InitResponse{
 		ShowFooter:                api.cfg.ShowFooter,
 		ShowFooter:                api.cfg.ShowFooter,
-		ShowNavigation:            api.cfg.ShowNavigation,
+		ShowNavigation:            showNavigation,
 		ShowNewVersions:           showNewVersions,
 		ShowNewVersions:           showNewVersions,
 		AvailableVersion:          availableVersion,
 		AvailableVersion:          availableVersion,
 		CurrentVersion:            currentVersion,
 		CurrentVersion:            currentVersion,
@@ -1388,6 +1404,16 @@ func (api *oliveTinAPI) buildInitResponse(user *authpublic.AuthenticatedUser) *a
 	return res
 	return res
 }
 }
 
 
+// initNavigationFields hides sidebar navigation and dashboard titles from guests
+// that must log in before using OliveTin.
+func (api *oliveTinAPI) initNavigationFields(user *authpublic.AuthenticatedUser, loginRequired bool) (bool, []*apiv1.RootDashboard) {
+	if loginRequired {
+		return false, nil
+	}
+
+	return api.cfg.ShowNavigation, api.buildRootDashboardEntries(user, api.cfg.Dashboards)
+}
+
 func initVersionFields(user *authpublic.AuthenticatedUser, cfg *config.Config) (currentVersion string, availableVersion string, showNewVersions bool) {
 func initVersionFields(user *authpublic.AuthenticatedUser, cfg *config.Config) (currentVersion string, availableVersion string, showNewVersions bool) {
 	if !user.EffectivePolicy.ShowVersionNumber {
 	if !user.EffectivePolicy.ShowVersionNumber {
 		return "", "", false
 		return "", "", false

+ 21 - 0
service/internal/api/api_deprecated.go

@@ -0,0 +1,21 @@
+package api
+
+import (
+	"fmt"
+
+	"github.com/OliveTin/OliveTin/internal/configissues"
+)
+
+// reportDeprecatedAPICall raises a warning on the Diagnostics page (once per
+// method, until OliveTin restarts) when a deprecated API method is called, so
+// admins can find and update the integrations still using it.
+func reportDeprecatedAPICall(method, replacement string) {
+	configissues.ReportRuntime(configissues.Issue{
+		Severity: configissues.SeverityWarning,
+		Code:     configissues.CodeDeprecatedAPICall,
+		Message: fmt.Sprintf(
+			"The deprecated API method %s was called. Use %s instead, as %s will be removed in a future version. See https://docs.olivetin.app/api/start_action.html#api-deprecated-byget",
+			method, replacement, method),
+		Source: method,
+	})
+}

+ 82 - 0
service/internal/api/api_deprecated_test.go

@@ -0,0 +1,82 @@
+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/configissues"
+	"github.com/OliveTin/OliveTin/internal/executor"
+)
+
+func deprecatedAPICallSources(t *testing.T, client interface {
+	GetDiagnostics(context.Context, *connect.Request[apiv1.GetDiagnosticsRequest]) (*connect.Response[apiv1.GetDiagnosticsResponse], error)
+}) map[string]bool {
+	t.Helper()
+
+	res, err := client.GetDiagnostics(context.Background(), connect.NewRequest(&apiv1.GetDiagnosticsRequest{}))
+	require.NoError(t, err)
+
+	sources := map[string]bool{}
+	for _, issue := range res.Msg.ConfigIssues {
+		if issue.Code == configissues.CodeDeprecatedAPICall {
+			assert.Equal(t, configissues.SeverityWarning, issue.Severity)
+			sources[issue.Source] = true
+		}
+	}
+	return sources
+}
+
+func TestDeprecatedStartActionByGetRaisesDiagnosticsWarning(t *testing.T) {
+	configissues.ClearRuntime()
+	t.Cleanup(configissues.ClearRuntime)
+
+	cfg := config.DefaultConfig()
+	cfg.Actions = []*config.Action{{Title: "Ping", ID: "ping", Shell: "echo ping"}}
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	testServer, client := getNewTestServerAndClientWithExecutor(cfg, ex)
+	defer testServer.Close()
+
+	assert.Empty(t, deprecatedAPICallSources(t, client))
+
+	//nolint:staticcheck // Deliberately calling the deprecated RPC.
+	_, err := client.StartActionByGet(context.Background(), connect.NewRequest(&apiv1.StartActionByGetRequest{ActionId: "ping"}))
+	require.NoError(t, err)
+
+	//nolint:staticcheck // Deliberately calling the deprecated RPC.
+	_, err = client.StartActionByGetAndWait(context.Background(), connect.NewRequest(&apiv1.StartActionByGetAndWaitRequest{ActionId: "ping"}))
+	require.NoError(t, err)
+
+	sources := deprecatedAPICallSources(t, client)
+	assert.True(t, sources["StartActionByGet"])
+	assert.True(t, sources["StartActionByGetAndWait"])
+
+	// Config reloads and entity changes rebuild the issue list; the warnings must survive.
+	ex.RebuildActionMap()
+	sources = deprecatedAPICallSources(t, client)
+	assert.True(t, sources["StartActionByGet"])
+	assert.True(t, sources["StartActionByGetAndWait"])
+}
+
+func TestStartActionDoesNotRaiseDeprecatedWarning(t *testing.T) {
+	configissues.ClearRuntime()
+	t.Cleanup(configissues.ClearRuntime)
+
+	cfg := config.DefaultConfig()
+	cfg.Actions = []*config.Action{{Title: "Ping", ID: "ping", Shell: "echo ping"}}
+
+	testServer, client := getNewTestServerAndClient(cfg)
+	defer testServer.Close()
+
+	_, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{BindingId: "ping"}))
+	require.NoError(t, err)
+
+	assert.Empty(t, deprecatedAPICallSources(t, client))
+}

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

@@ -87,6 +87,36 @@ func TestInitOmitsSearchHintsWhenLoginRequired(t *testing.T) {
 	assert.Nil(t, resp.Msg.SearchHints)
 	assert.Nil(t, resp.Msg.SearchHints)
 }
 }
 
 
+func TestInitHidesNavigationAndDashboardsWhenLoginRequired(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.AuthRequireGuestsToLogin = true
+	cfg.ShowNavigation = true
+	cfg.Actions = []*config.Action{
+		{Title: "Ping", Shell: "echo ping"},
+	}
+	cfg.Dashboards = []*config.DashboardComponent{
+		{
+			Title: "Secret Board",
+			Contents: []*config.DashboardComponent{
+				{Title: "Ping"},
+			},
+		},
+	}
+	cfg.Sanitize()
+
+	testExecutor := executor.DefaultExecutor(cfg)
+	testExecutor.RebuildActionMap()
+	testServer, client := getNewTestServerAndClientWithExecutor(cfg, testExecutor)
+	defer testServer.Close()
+
+	resp, err := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{}))
+	require.NoError(t, err)
+	require.True(t, resp.Msg.LoginRequired)
+	assert.False(t, resp.Msg.ShowNavigation)
+	assert.Empty(t, resp.Msg.RootDashboards)
+	assert.Empty(t, resp.Msg.RootDashboardEntries)
+}
+
 func TestInitOmitsSearchHintsWhenHeaderSearchDisabled(t *testing.T) {
 func TestInitOmitsSearchHintsWhenHeaderSearchDisabled(t *testing.T) {
 	entities.ClearEntitiesOfType("server")
 	entities.ClearEntitiesOfType("server")
 	t.Cleanup(func() {
 	t.Cleanup(func() {

+ 9 - 7
service/internal/auth/otjwt/jwt.go

@@ -8,7 +8,6 @@ import (
 	"os"
 	"os"
 	"strings"
 	"strings"
 	"sync"
 	"sync"
-	"time"
 
 
 	"github.com/MicahParks/keyfunc/v3"
 	"github.com/MicahParks/keyfunc/v3"
 	authTypes "github.com/OliveTin/OliveTin/internal/auth/authpublic"
 	authTypes "github.com/OliveTin/OliveTin/internal/auth/authpublic"
@@ -83,13 +82,8 @@ var (
 func initJwks(cfg *config.Config) error {
 func initJwks(cfg *config.Config) error {
 	jwksOnce.Do(func() {
 	jwksOnce.Do(func() {
 		if cfg.AuthJwtCertsURL != "" {
 		if cfg.AuthJwtCertsURL != "" {
-			ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
-			defer cancel()
-
 			var err error
 			var err error
-			jwksVerifier, err = keyfunc.NewDefaultCtx(ctx, []string{
-				cfg.AuthJwtCertsURL,
-			})
+			jwksVerifier, err = initRemoteJwks(cfg.AuthJwtCertsURL)
 
 
 			if err != nil {
 			if err != nil {
 				log.Errorf("Init JWKS Failure: %v", err)
 				log.Errorf("Init JWKS Failure: %v", err)
@@ -100,6 +94,14 @@ func initJwks(cfg *config.Config) error {
 	return jwksInitErr
 	return jwksInitErr
 }
 }
 
 
+// initRemoteJwks creates a JWKS verifier whose background refresh outlives the
+// calling request. keyfunc.NewDefaultCtx stops refreshing when its context is
+// cancelled; a WithTimeout/defer-cancel around the initial fetch permanently
+// freezes the keyset (GHSA-gp3q-q8qg-j4xf).
+func initRemoteJwks(certsURL string) (keyfunc.Keyfunc, error) {
+	return keyfunc.NewDefaultCtx(context.Background(), []string{certsURL})
+}
+
 func loadPublicKeyFromFile(keyPath string) error {
 func loadPublicKeyFromFile(keyPath string) error {
 	keyBytes, err := os.ReadFile(keyPath)
 	keyBytes, err := os.ReadFile(keyPath)
 	if err != nil {
 	if err != nil {

+ 160 - 0
service/internal/auth/otjwt/jwt_jwks_refresh_test.go

@@ -0,0 +1,160 @@
+package otjwt
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"net/http"
+	"net/http/httptest"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/MicahParks/jwkset"
+	"github.com/MicahParks/keyfunc/v3"
+	"github.com/golang-jwt/jwt/v5"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestInitRemoteJwksKeepsBackgroundContext(t *testing.T) {
+	// Smoke-check that production helper constructs a verifier without a
+	// cancelled parent context. Behavioural refresh coverage is below.
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write([]byte(`{"keys":[]}`))
+	}))
+	t.Cleanup(srv.Close)
+
+	k, err := initRemoteJwks(srv.URL)
+	require.NoError(t, err)
+	require.NotNil(t, k)
+}
+
+func TestJwksRefreshContinuesWhenContextNotCancelled(t *testing.T) {
+	ctx := context.Background()
+	store := jwkset.NewMemoryStorage()
+
+	oldPriv, oldPub := mustGenerateRSAKey(t)
+	newPriv, newPub := mustGenerateRSAKey(t)
+
+	require.NoError(t, store.KeyWrite(ctx, mustJWK(t, "old-kid", oldPub)))
+
+	var mu sync.Mutex
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		mu.Lock()
+		defer mu.Unlock()
+		raw, err := store.JSONPublic(ctx)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		_, _ = w.Write(raw)
+	}))
+	t.Cleanup(srv.Close)
+
+	refreshCtx, cancel := context.WithCancel(context.Background())
+	t.Cleanup(cancel)
+	verifier, err := keyfunc.NewDefaultOverrideCtx(refreshCtx, []string{srv.URL}, keyfunc.Override{
+		RefreshInterval:         100 * time.Millisecond,
+		RefreshErrorHandlerFunc: silentJWKSRefreshErrorHandler,
+	})
+	require.NoError(t, err)
+
+	oldToken := mustSignJWT(t, oldPriv, "old-kid")
+	_, err = jwt.Parse(oldToken, verifier.Keyfunc)
+	require.NoError(t, err, "token signed by initial JWKS key must verify")
+
+	mu.Lock()
+	_, err = store.KeyDelete(ctx, "old-kid")
+	require.NoError(t, err)
+	require.NoError(t, store.KeyWrite(ctx, mustJWK(t, "new-kid", newPub)))
+	mu.Unlock()
+
+	require.Eventually(t, func() bool {
+		newToken := mustSignJWT(t, newPriv, "new-kid")
+		_, parseErr := jwt.Parse(newToken, verifier.Keyfunc)
+		return parseErr == nil
+	}, 3*time.Second, 100*time.Millisecond, "JWKS refresh must pick up rotated signing keys")
+
+	_, err = jwt.Parse(oldToken, verifier.Keyfunc)
+	assert.Error(t, err, "token signed by revoked key should stop verifying after refresh")
+}
+
+func TestJwksRefreshStopsWhenContextCancelled(t *testing.T) {
+	ctx := context.Background()
+	store := jwkset.NewMemoryStorage()
+
+	oldPriv, oldPub := mustGenerateRSAKey(t)
+	newPriv, newPub := mustGenerateRSAKey(t)
+
+	require.NoError(t, store.KeyWrite(ctx, mustJWK(t, "old-kid", oldPub)))
+
+	var mu sync.Mutex
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		mu.Lock()
+		defer mu.Unlock()
+		raw, err := store.JSONPublic(ctx)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		_, _ = w.Write(raw)
+	}))
+	t.Cleanup(srv.Close)
+
+	refreshCtx, cancel := context.WithCancel(context.Background())
+	verifier, err := keyfunc.NewDefaultOverrideCtx(refreshCtx, []string{srv.URL}, keyfunc.Override{
+		RefreshInterval:         100 * time.Millisecond,
+		RefreshErrorHandlerFunc: silentJWKSRefreshErrorHandler,
+	})
+	require.NoError(t, err)
+	cancel() // reproduces the pre-fix WithTimeout/defer-cancel bug
+
+	oldToken := mustSignJWT(t, oldPriv, "old-kid")
+	_, err = jwt.Parse(oldToken, verifier.Keyfunc)
+	require.NoError(t, err)
+
+	mu.Lock()
+	_, err = store.KeyDelete(ctx, "old-kid")
+	require.NoError(t, err)
+	require.NoError(t, store.KeyWrite(ctx, mustJWK(t, "new-kid", newPub)))
+	mu.Unlock()
+
+	time.Sleep(500 * time.Millisecond)
+
+	newToken := mustSignJWT(t, newPriv, "new-kid")
+	_, err = jwt.Parse(newToken, verifier.Keyfunc)
+	assert.Error(t, err, "cancelled JWKS context must not refresh to the rotated key")
+}
+
+func silentJWKSRefreshErrorHandler(string) func(context.Context, error) {
+	return func(context.Context, error) {}
+}
+
+func mustGenerateRSAKey(t *testing.T) (*rsa.PrivateKey, *rsa.PublicKey) {
+	t.Helper()
+	privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	require.NoError(t, err)
+	return privateKey, &privateKey.PublicKey
+}
+
+func mustJWK(t *testing.T, kid string, pub *rsa.PublicKey) jwkset.JWK {
+	t.Helper()
+	jwk, err := jwkset.NewJWKFromKey(pub, jwkset.JWKOptions{
+		Metadata: jwkset.JWKMetadataOptions{KID: kid},
+	})
+	require.NoError(t, err)
+	return jwk
+}
+
+func mustSignJWT(t *testing.T, privateKey *rsa.PrivateKey, kid string) string {
+	t.Helper()
+	token := jwt.New(jwt.SigningMethodRS256)
+	token.Header["kid"] = kid
+	claims := token.Claims.(jwt.MapClaims)
+	claims["sub"] = "test"
+	claims["exp"] = time.Now().Add(time.Hour).Unix()
+	signed, err := token.SignedString(privateKey)
+	require.NoError(t, err)
+	return signed
+}

+ 181 - 60
service/internal/auth/otoauth2/restapi_auth_oauth2.go

@@ -23,7 +23,8 @@ import (
 
 
 type OAuth2Handler struct {
 type OAuth2Handler struct {
 	cfg                 *config.Config
 	cfg                 *config.Config
-	registeredStates    map[string]*oauth2State
+	pendingStates       map[string]*oauth2PendingState
+	sessions            map[string]*oauth2Session
 	registeredProviders map[string]*oauth2.Config
 	registeredProviders map[string]*oauth2.Config
 	mu                  sync.RWMutex
 	mu                  sync.RWMutex
 }
 }
@@ -33,7 +34,8 @@ func NewOAuth2Handler(cfg *config.Config) *OAuth2Handler {
 		cfg: cfg,
 		cfg: cfg,
 	}
 	}
 
 
-	h.registeredStates = make(map[string]*oauth2State)
+	h.pendingStates = make(map[string]*oauth2PendingState)
+	h.sessions = make(map[string]*oauth2Session)
 	h.registeredProviders = make(map[string]*oauth2.Config)
 	h.registeredProviders = make(map[string]*oauth2.Config)
 
 
 	for providerName, providerConfig := range cfg.AuthOAuth2Providers {
 	for providerName, providerConfig := range cfg.AuthOAuth2Providers {
@@ -58,17 +60,26 @@ func NewOAuth2Handler(cfg *config.Config) *OAuth2Handler {
 	return h
 	return h
 }
 }
 
 
-type oauth2State struct {
+type oauth2PendingState struct {
 	createdAt      time.Time
 	createdAt      time.Time
 	providerConfig *oauth2.Config
 	providerConfig *oauth2.Config
 	providerName   string
 	providerName   string
-	Username       string
-	Usergroup      string
+}
+
+type oauth2Session struct {
+	createdAt    time.Time
+	Username     string
+	Usergroup    string
+	providerName string
 }
 }
 
 
 const (
 const (
-	oauthStateMaxAge     = 900 // matches olivetin-sid-oauth cookie MaxAge
-	oauthStateMaxEntries = 10000
+	oauthStateCookieName   = "olivetin-oauth-state"
+	oauthSessionCookieName = "olivetin-sid-oauth"
+	oauthStateMaxAge       = 900 // seconds; CSRF pending window
+	oauthSessionMaxAge     = 900 // seconds; authenticated session lifetime
+	oauthStateMaxEntries   = 10000
+	oauthSessionMaxEntries = 10000
 )
 )
 
 
 func assignIfEmpty(target *string, value string) {
 func assignIfEmpty(target *string, value string) {
@@ -122,11 +133,11 @@ func (h *OAuth2Handler) cookieSecure(r *http.Request) bool {
 	return useTLS || h.cfg.Security.ForceSecureCookies
 	return useTLS || h.cfg.Security.ForceSecureCookies
 }
 }
 
 
-func (h *OAuth2Handler) setOAuthCallbackCookie(w http.ResponseWriter, r *http.Request, name, value string) {
+func (h *OAuth2Handler) setOAuthCookie(w http.ResponseWriter, r *http.Request, name, value string, maxAge int) {
 	cookie := &http.Cookie{
 	cookie := &http.Cookie{
 		Name:     name,
 		Name:     name,
 		Value:    value,
 		Value:    value,
-		MaxAge:   900, // 15 minutes
+		MaxAge:   maxAge,
 		Secure:   h.cookieSecure(r),
 		Secure:   h.cookieSecure(r),
 		HttpOnly: true,
 		HttpOnly: true,
 		Path:     "/",
 		Path:     "/",
@@ -136,15 +147,33 @@ func (h *OAuth2Handler) setOAuthCallbackCookie(w http.ResponseWriter, r *http.Re
 	http.SetCookie(w, cookie)
 	http.SetCookie(w, cookie)
 }
 }
 
 
-func (h *OAuth2Handler) deleteOAuthStateLocked(state string) {
-	delete(h.registeredStates, state)
+func (h *OAuth2Handler) clearOAuthCookie(w http.ResponseWriter, r *http.Request, name string) {
+	h.setOAuthCookie(w, r, name, "", -1)
+}
+
+func (h *OAuth2Handler) deletePendingStateLocked(state string) {
+	delete(h.pendingStates, state)
+}
+
+func (h *OAuth2Handler) sweepExpiredOAuthEntriesLocked(now time.Time) {
+	h.sweepExpiredPendingStatesLocked(now)
+	h.sweepExpiredSessionsLocked(now)
+}
+
+func (h *OAuth2Handler) sweepExpiredPendingStatesLocked(now time.Time) {
+	pendingCutoff := now.Add(-oauthStateMaxAge * time.Second)
+	for state, entry := range h.pendingStates {
+		if entry.createdAt.Before(pendingCutoff) {
+			delete(h.pendingStates, state)
+		}
+	}
 }
 }
 
 
-func (h *OAuth2Handler) sweepExpiredOAuthStatesLocked(now time.Time) {
-	cutoff := now.Add(-oauthStateMaxAge * time.Second)
-	for state, entry := range h.registeredStates {
-		if entry.createdAt.Before(cutoff) {
-			delete(h.registeredStates, state)
+func (h *OAuth2Handler) sweepExpiredSessionsLocked(now time.Time) {
+	sessionCutoff := now.Add(-oauthSessionMaxAge * time.Second)
+	for sid, entry := range h.sessions {
+		if entry.createdAt.Before(sessionCutoff) {
+			delete(h.sessions, sid)
 		}
 		}
 	}
 	}
 }
 }
@@ -167,63 +196,83 @@ func (h *OAuth2Handler) HandleOAuthLogin(w http.ResponseWriter, r *http.Request)
 	}
 	}
 
 
 	h.mu.Lock()
 	h.mu.Lock()
-	h.sweepExpiredOAuthStatesLocked(time.Now())
-	if len(h.registeredStates) >= oauthStateMaxEntries {
+	h.sweepExpiredOAuthEntriesLocked(time.Now())
+	if len(h.pendingStates) >= oauthStateMaxEntries {
 		h.mu.Unlock()
 		h.mu.Unlock()
 		http.Error(w, "OAuth login temporarily unavailable", http.StatusServiceUnavailable)
 		http.Error(w, "OAuth login temporarily unavailable", http.StatusServiceUnavailable)
 		return
 		return
 	}
 	}
-	h.registeredStates[state] = &oauth2State{
+	h.pendingStates[state] = &oauth2PendingState{
 		providerConfig: provider,
 		providerConfig: provider,
 		providerName:   providerName,
 		providerName:   providerName,
-		Username:       "",
 		createdAt:      time.Now(),
 		createdAt:      time.Now(),
 	}
 	}
 	h.mu.Unlock()
 	h.mu.Unlock()
 
 
-	h.setOAuthCallbackCookie(w, r, "olivetin-sid-oauth", state)
+	// CSRF double-submit cookie only — never reuse this value as the session ID.
+	h.setOAuthCookie(w, r, oauthStateCookieName, state, oauthStateMaxAge)
 
 
-	log.Infof("OAuth2 state: %v mapped to provider %v (found: %v), now redirecting", state, providerName, provider != nil)
+	log.Infof("OAuth2 login redirect for provider %v", providerName)
 
 
 	http.Redirect(w, r, provider.AuthCodeURL(state), http.StatusFound)
 	http.Redirect(w, r, provider.AuthCodeURL(state), http.StatusFound)
 }
 }
 
 
 func (h *OAuth2Handler) validateStateMatch(queryState, cookieState string) bool {
 func (h *OAuth2Handler) validateStateMatch(queryState, cookieState string) bool {
-	return queryState == cookieState
+	return queryState != "" && queryState == cookieState
+}
+
+func pendingStateExpired(pending *oauth2PendingState, now time.Time) bool {
+	return pending.createdAt.Before(now.Add(-oauthStateMaxAge * time.Second))
+}
+
+func sessionExpired(session *oauth2Session, now time.Time) bool {
+	return session.createdAt.Before(now.Add(-oauthSessionMaxAge * time.Second))
 }
 }
 
 
-func (h *OAuth2Handler) checkOAuthCallbackCookie(w http.ResponseWriter, r *http.Request) (*oauth2State, string, bool) {
-	cookie, err := r.Cookie("olivetin-sid-oauth")
+func (h *OAuth2Handler) consumePendingOAuthState(w http.ResponseWriter, r *http.Request) (*oauth2PendingState, string, bool) {
+	cookie, err := r.Cookie(oauthStateCookieName)
 	if err != nil {
 	if err != nil {
-		log.Errorf("Failed to get state cookie: %v", err)
+		log.Errorf("Failed to get OAuth state cookie: %v", err)
 		http.Error(w, "State not found", http.StatusBadRequest)
 		http.Error(w, "State not found", http.StatusBadRequest)
 		return nil, "", false
 		return nil, "", false
 	}
 	}
 
 
 	state := cookie.Value
 	state := cookie.Value
+	queryState := r.URL.Query().Get("state")
 
 
-	if !h.validateStateMatch(r.URL.Query().Get("state"), state) {
-		log.Errorf("State mismatch: %v != %v", r.URL.Query().Get("state"), state)
-		h.mu.Lock()
-		h.deleteOAuthStateLocked(state)
-		h.mu.Unlock()
+	if !h.validateStateMatch(queryState, state) {
+		// Keep the cookie's pending state: an unrelated callback (different or
+		// missing query state) must not invalidate an in-flight login.
+		log.Errorf("OAuth2 state mismatch between query and cookie")
 		http.Error(w, "State mismatch", http.StatusBadRequest)
 		http.Error(w, "State mismatch", http.StatusBadRequest)
 		return nil, state, false
 		return nil, state, false
 	}
 	}
 
 
+	return h.loadValidPendingOAuthState(w, state)
+}
+
+func (h *OAuth2Handler) loadValidPendingOAuthState(w http.ResponseWriter, state string) (*oauth2PendingState, string, bool) {
 	h.mu.RLock()
 	h.mu.RLock()
-	registeredState, ok := h.registeredStates[state]
+	pending, ok := h.pendingStates[state]
+	expired := ok && pendingStateExpired(pending, time.Now())
 	h.mu.RUnlock()
 	h.mu.RUnlock()
+
 	if !ok {
 	if !ok {
-		log.Errorf("State not found in server: %v", state)
+		log.Errorf("OAuth2 pending state not found on server")
+		http.Error(w, "State not found in server", http.StatusBadRequest)
+		return nil, state, false
+	}
+
+	if expired {
 		h.mu.Lock()
 		h.mu.Lock()
-		h.deleteOAuthStateLocked(state)
+		h.deletePendingStateLocked(state)
 		h.mu.Unlock()
 		h.mu.Unlock()
-		http.Error(w, "State not found in server", http.StatusBadRequest)
+		log.Errorf("OAuth2 pending state expired")
+		http.Error(w, "State expired", http.StatusBadRequest)
 		return nil, state, false
 		return nil, state, false
 	}
 	}
 
 
-	return registeredState, state, true
+	return pending, state, true
 }
 }
 
 
 type HttpClientSettings struct {
 type HttpClientSettings struct {
@@ -301,39 +350,96 @@ func (h *OAuth2Handler) computeUsergroup(userinfo *UserInfo, providerConfig *con
 	return usergroup
 	return usergroup
 }
 }
 
 
+func (h *OAuth2Handler) takeValidPendingStateLocked(state string, now time.Time) (*oauth2PendingState, error) {
+	pendingEntry, ok := h.pendingStates[state]
+	if !ok {
+		return nil, fmt.Errorf("pending OAuth state no longer exists")
+	}
+	if pendingStateExpired(pendingEntry, now) {
+		h.deletePendingStateLocked(state)
+		return nil, fmt.Errorf("pending OAuth state expired")
+	}
+
+	h.deletePendingStateLocked(state)
+	return pendingEntry, nil
+}
+
+func (h *OAuth2Handler) issueOAuthSession(state, username, usergroup string) (string, error) {
+	sessionID, err := randString(16)
+	if err != nil {
+		return "", err
+	}
+
+	h.mu.Lock()
+	defer h.mu.Unlock()
+
+	h.sweepExpiredOAuthEntriesLocked(time.Now())
+	if len(h.sessions) >= oauthSessionMaxEntries {
+		return "", fmt.Errorf("OAuth session capacity exceeded")
+	}
+
+	pendingEntry, err := h.takeValidPendingStateLocked(state, time.Now())
+	if err != nil {
+		return "", err
+	}
+
+	h.sessions[sessionID] = &oauth2Session{
+		createdAt:    time.Now(),
+		Username:     username,
+		Usergroup:    usergroup,
+		providerName: pendingEntry.providerName,
+	}
+
+	return sessionID, nil
+}
+
 func (h *OAuth2Handler) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
 func (h *OAuth2Handler) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
 	log.Infof("OAuth2 Callback received")
 	log.Infof("OAuth2 Callback received")
 
 
-	registeredState, state, ok := h.checkOAuthCallbackCookie(w, r)
+	pending, state, ok := h.consumePendingOAuthState(w, r)
 	if !ok {
 	if !ok {
 		return
 		return
 	}
 	}
 
 
 	code := r.FormValue("code")
 	code := r.FormValue("code")
+	log.Debug("OAuth2 token exchange starting")
 
 
-	log.WithFields(log.Fields{
-		"state":      state,
-		"token-code": code,
-	}).Debug("OAuth2 Token Code")
-
-	providerConfig := h.cfg.AuthOAuth2Providers[registeredState.providerName]
+	providerConfig := h.cfg.AuthOAuth2Providers[pending.providerName]
 	clientSettings := getOAuth2HttpClient(providerConfig)
 	clientSettings := getOAuth2HttpClient(providerConfig)
 
 
 	ctx := context.Background()
 	ctx := context.Background()
-	tok, err := h.exchangeOAuthCode(ctx, registeredState.providerConfig, code, clientSettings)
+	tok, err := h.exchangeOAuthCode(ctx, pending.providerConfig, code, clientSettings)
 	if err != nil {
 	if err != nil {
 		log.Errorf("Failed to exchange code: %v", err)
 		log.Errorf("Failed to exchange code: %v", err)
+		h.mu.Lock()
+		h.deletePendingStateLocked(state)
+		h.mu.Unlock()
 		http.Error(w, "Failed to exchange code", http.StatusBadRequest)
 		http.Error(w, "Failed to exchange code", http.StatusBadRequest)
 		return
 		return
 	}
 	}
 
 
-	userInfoClient := h.createUserInfoClient(ctx, registeredState.providerConfig, tok, clientSettings)
+	userInfoClient := h.createUserInfoClient(ctx, pending.providerConfig, tok, clientSettings)
 	userinfo := getUserInfo(h.cfg, userInfoClient, providerConfig)
 	userinfo := getUserInfo(h.cfg, userInfoClient, providerConfig)
+	if userinfo.Username == "" {
+		h.mu.Lock()
+		h.deletePendingStateLocked(state)
+		h.mu.Unlock()
+		log.Errorf("OAuth2 user info returned empty username")
+		http.Error(w, "Failed to get user info", http.StatusBadRequest)
+		return
+	}
 
 
-	h.mu.Lock()
-	h.registeredStates[state].Username = userinfo.Username
-	h.registeredStates[state].Usergroup = h.computeUsergroup(userinfo, providerConfig)
-	h.mu.Unlock()
+	usergroup := h.computeUsergroup(userinfo, providerConfig)
+
+	sessionID, err := h.issueOAuthSession(state, userinfo.Username, usergroup)
+	if err != nil {
+		log.Errorf("Failed to issue OAuth session: %v", err)
+		http.Error(w, "Failed to create session", http.StatusInternalServerError)
+		return
+	}
+
+	h.clearOAuthCookie(w, r, oauthStateCookieName)
+	h.setOAuthCookie(w, r, oauthSessionCookieName, sessionID, oauthSessionMaxAge)
 
 
 	http.Redirect(w, r, "/", http.StatusFound)
 	http.Redirect(w, r, "/", http.StatusFound)
 }
 }
@@ -481,41 +587,56 @@ func joinGroupArray(arrayVal []any, field string, sep string) string {
 	return strings.Join(groups, sep)
 	return strings.Join(groups, sep)
 }
 }
 
 
-func (h *OAuth2Handler) lookupOAuth2UserByState(state string) (*authTypes.AuthenticatedUser, bool) {
+func (h *OAuth2Handler) lookupOAuth2UserBySession(sessionID string) (*authTypes.AuthenticatedUser, bool) {
 	h.mu.RLock()
 	h.mu.RLock()
-	serverState, found := h.registeredStates[state]
-	if !found {
+	session, found := h.sessions[sessionID]
+	if !found || session.Username == "" {
+		h.mu.RUnlock()
+		return nil, false
+	}
+	if sessionExpired(session, time.Now()) {
 		h.mu.RUnlock()
 		h.mu.RUnlock()
+		h.deleteSessionIfExpired(sessionID)
 		return nil, false
 		return nil, false
 	}
 	}
+
 	user := &authTypes.AuthenticatedUser{
 	user := &authTypes.AuthenticatedUser{
-		Username:      serverState.Username,
-		UsergroupLine: serverState.Usergroup,
+		Username:      session.Username,
+		UsergroupLine: session.Usergroup,
 		Provider:      "oauth2",
 		Provider:      "oauth2",
-		SID:           state,
+		SID:           sessionID,
 	}
 	}
 	h.mu.RUnlock()
 	h.mu.RUnlock()
 	return user, true
 	return user, true
 }
 }
 
 
+func (h *OAuth2Handler) deleteSessionIfExpired(sessionID string) {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+
+	session, found := h.sessions[sessionID]
+	if found && sessionExpired(session, time.Now()) {
+		delete(h.sessions, sessionID)
+	}
+}
+
 func (h *OAuth2Handler) RevokeSession(sid string) {
 func (h *OAuth2Handler) RevokeSession(sid string) {
 	h.mu.Lock()
 	h.mu.Lock()
 	defer h.mu.Unlock()
 	defer h.mu.Unlock()
-	delete(h.registeredStates, sid)
+	delete(h.sessions, sid)
 }
 }
 
 
 func (h *OAuth2Handler) CheckUserFromOAuth2Cookie(context *authTypes.AuthCheckingContext) *authTypes.AuthenticatedUser {
 func (h *OAuth2Handler) CheckUserFromOAuth2Cookie(context *authTypes.AuthCheckingContext) *authTypes.AuthenticatedUser {
-	cookie, err := context.Request.Cookie("olivetin-sid-oauth")
+	cookie, err := context.Request.Cookie(oauthSessionCookieName)
 	if err != nil || cookie.Value == "" {
 	if err != nil || cookie.Value == "" {
 		return nil
 		return nil
 	}
 	}
 
 
-	user, found := h.lookupOAuth2UserByState(cookie.Value)
+	user, found := h.lookupOAuth2UserBySession(cookie.Value)
 	if !found {
 	if !found {
 		log.WithFields(log.Fields{
 		log.WithFields(log.Fields{
-			"sid":      cookie.Value,
 			"provider": "oauth2",
 			"provider": "oauth2",
-		}).Warnf("Stale session")
+		}).Warnf("Stale OAuth2 session")
 		return nil
 		return nil
 	}
 	}
 	return user
 	return user

+ 252 - 12
service/internal/auth/otoauth2/restapi_auth_oauth2_test.go

@@ -3,35 +3,51 @@ package otoauth2
 import (
 import (
 	"net/http"
 	"net/http"
 	"net/http/httptest"
 	"net/http/httptest"
+	"net/url"
 	"strconv"
 	"strconv"
 	"testing"
 	"testing"
 	"time"
 	"time"
 
 
+	authTypes "github.com/OliveTin/OliveTin/internal/auth/authpublic"
 	config "github.com/OliveTin/OliveTin/internal/config"
 	config "github.com/OliveTin/OliveTin/internal/config"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 	"golang.org/x/oauth2"
 	"golang.org/x/oauth2"
 )
 )
 
 
-func TestSweepExpiredOAuthStatesLocked(t *testing.T) {
+func TestSweepExpiredOAuthEntriesLocked(t *testing.T) {
 	h := &OAuth2Handler{
 	h := &OAuth2Handler{
-		registeredStates: make(map[string]*oauth2State),
+		pendingStates: make(map[string]*oauth2PendingState),
+		sessions:      make(map[string]*oauth2Session),
 	}
 	}
 
 
-	h.registeredStates["fresh"] = &oauth2State{
+	h.pendingStates["fresh"] = &oauth2PendingState{
 		providerName: "test",
 		providerName: "test",
 		createdAt:    time.Now(),
 		createdAt:    time.Now(),
 	}
 	}
-	h.registeredStates["stale"] = &oauth2State{
+	h.pendingStates["stale"] = &oauth2PendingState{
 		providerName: "test",
 		providerName: "test",
 		createdAt:    time.Now().Add(-2 * oauthStateMaxAge * time.Second),
 		createdAt:    time.Now().Add(-2 * oauthStateMaxAge * time.Second),
 	}
 	}
+	h.sessions["fresh-session"] = &oauth2Session{
+		Username:  "alice",
+		createdAt: time.Now(),
+	}
+	h.sessions["stale-session"] = &oauth2Session{
+		Username:  "bob",
+		createdAt: time.Now().Add(-2 * oauthSessionMaxAge * time.Second),
+	}
 
 
-	h.sweepExpiredOAuthStatesLocked(time.Now())
+	h.sweepExpiredOAuthEntriesLocked(time.Now())
 
 
-	_, freshFound := h.registeredStates["fresh"]
-	_, staleFound := h.registeredStates["stale"]
-	assert.True(t, freshFound)
-	assert.False(t, staleFound)
+	_, freshPending := h.pendingStates["fresh"]
+	_, stalePending := h.pendingStates["stale"]
+	_, freshSession := h.sessions["fresh-session"]
+	_, staleSession := h.sessions["stale-session"]
+	assert.True(t, freshPending)
+	assert.False(t, stalePending)
+	assert.True(t, freshSession)
+	assert.False(t, staleSession)
 }
 }
 
 
 func TestGetGroupFieldString(t *testing.T) {
 func TestGetGroupFieldString(t *testing.T) {
@@ -140,9 +156,9 @@ func TestHandleOAuthLoginRejectsWhenStateMapFull(t *testing.T) {
 	}
 	}
 
 
 	h := NewOAuth2Handler(cfg)
 	h := NewOAuth2Handler(cfg)
-	h.registeredStates = make(map[string]*oauth2State, oauthStateMaxEntries)
+	h.pendingStates = make(map[string]*oauth2PendingState, oauthStateMaxEntries)
 	for i := range oauthStateMaxEntries {
 	for i := range oauthStateMaxEntries {
-		h.registeredStates[strconv.Itoa(i)] = &oauth2State{
+		h.pendingStates[strconv.Itoa(i)] = &oauth2PendingState{
 			providerConfig: &oauth2.Config{},
 			providerConfig: &oauth2.Config{},
 			providerName:   "test",
 			providerName:   "test",
 			createdAt:      time.Now(),
 			createdAt:      time.Now(),
@@ -155,5 +171,229 @@ func TestHandleOAuthLoginRejectsWhenStateMapFull(t *testing.T) {
 	h.HandleOAuthLogin(rec, req)
 	h.HandleOAuthLogin(rec, req)
 
 
 	assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
 	assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
-	assert.Len(t, h.registeredStates, oauthStateMaxEntries)
+	assert.Len(t, h.pendingStates, oauthStateMaxEntries)
+}
+
+func TestHandleOAuthLoginUsesSeparateStateCookie(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.AuthOAuth2Providers = map[string]*config.OAuth2Provider{
+		"test": {
+			Name:         "test",
+			ClientID:     "id",
+			ClientSecret: "secret",
+			AuthUrl:      "https://example.com/auth",
+			TokenUrl:     "https://example.com/token",
+		},
+	}
+
+	h := NewOAuth2Handler(cfg)
+	req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oauth/login?provider=test", nil)
+	rec := httptest.NewRecorder()
+
+	h.HandleOAuthLogin(rec, req)
+
+	assert.Equal(t, http.StatusFound, rec.Code)
+
+	cookies := rec.Result().Cookies()
+	var stateCookie, sessionCookie *http.Cookie
+	for _, cookie := range cookies {
+		switch cookie.Name {
+		case oauthStateCookieName:
+			stateCookie = cookie
+		case oauthSessionCookieName:
+			sessionCookie = cookie
+		}
+	}
+
+	requireCookie := func(cookie *http.Cookie, name string) string {
+		t.Helper()
+		assert.NotNil(t, cookie, "expected %s cookie", name)
+		assert.NotEmpty(t, cookie.Value)
+		return cookie.Value
+	}
+
+	stateValue := requireCookie(stateCookie, oauthStateCookieName)
+	assert.Nil(t, sessionCookie, "login must not set the session cookie yet")
+
+	location, err := url.Parse(rec.Header().Get("Location"))
+	require.NoError(t, err)
+	assert.Equal(t, stateValue, location.Query().Get("state"))
+	assert.Contains(t, h.pendingStates, stateValue)
+	assert.Empty(t, h.sessions)
+}
+
+func TestIssueOAuthSessionRotatesAwayFromCSRFState(t *testing.T) {
+	h := &OAuth2Handler{
+		pendingStates: make(map[string]*oauth2PendingState),
+		sessions:      make(map[string]*oauth2Session),
+	}
+
+	state := "csrf-state-value"
+	h.pendingStates[state] = &oauth2PendingState{
+		providerName: "test",
+		createdAt:    time.Now(),
+	}
+
+	sessionID, err := h.issueOAuthSession(state, "alice", "admins")
+	require.NoError(t, err)
+	assert.NotEmpty(t, sessionID)
+	assert.NotEqual(t, state, sessionID)
+	assert.NotContains(t, h.pendingStates, state)
+	assert.Contains(t, h.sessions, sessionID)
+
+	user, found := h.lookupOAuth2UserBySession(sessionID)
+	assert.True(t, found)
+	assert.Equal(t, "alice", user.Username)
+	assert.Equal(t, sessionID, user.SID)
+
+	_, foundAsState := h.lookupOAuth2UserBySession(state)
+	assert.False(t, foundAsState, "CSRF state must not authenticate as a session")
+}
+
+func TestCheckUserFromOAuth2CookieUsesSessionCookieOnly(t *testing.T) {
+	cfg := config.DefaultConfig()
+	h := NewOAuth2Handler(cfg)
+	h.sessions["session-id"] = &oauth2Session{
+		Username:  "alice",
+		Usergroup: "admins",
+		createdAt: time.Now(),
+	}
+	h.pendingStates["csrf-state"] = &oauth2PendingState{
+		providerName: "test",
+		createdAt:    time.Now(),
+	}
+
+	reqWithSession := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
+	reqWithSession.AddCookie(&http.Cookie{Name: oauthSessionCookieName, Value: "session-id"})
+	user := h.CheckUserFromOAuth2Cookie(&authTypes.AuthCheckingContext{Request: reqWithSession})
+	assert.NotNil(t, user)
+	assert.Equal(t, "alice", user.Username)
+
+	reqWithState := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
+	reqWithState.AddCookie(&http.Cookie{Name: oauthSessionCookieName, Value: "csrf-state"})
+	assert.Nil(t, h.CheckUserFromOAuth2Cookie(&authTypes.AuthCheckingContext{Request: reqWithState}))
+
+	reqLegacyStateCookie := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
+	reqLegacyStateCookie.AddCookie(&http.Cookie{Name: oauthStateCookieName, Value: "csrf-state"})
+	assert.Nil(t, h.CheckUserFromOAuth2Cookie(&authTypes.AuthCheckingContext{Request: reqLegacyStateCookie}))
+}
+
+func TestIssueOAuthSessionRejectsMissingPendingState(t *testing.T) {
+	h := &OAuth2Handler{
+		pendingStates: make(map[string]*oauth2PendingState),
+		sessions:      make(map[string]*oauth2Session),
+	}
+
+	sessionID, err := h.issueOAuthSession("already-consumed", "alice", "admins")
+	require.Error(t, err)
+	assert.Empty(t, sessionID)
+	assert.Empty(t, h.sessions)
+}
+
+func TestIssueOAuthSessionRejectsExpiredPendingState(t *testing.T) {
+	h := &OAuth2Handler{
+		pendingStates: make(map[string]*oauth2PendingState),
+		sessions:      make(map[string]*oauth2Session),
+	}
+
+	state := "expired-state"
+	pending := &oauth2PendingState{
+		providerName: "test",
+		createdAt:    time.Now().Add(-2 * oauthStateMaxAge * time.Second),
+	}
+	h.pendingStates[state] = pending
+
+	sessionID, err := h.issueOAuthSession(state, "alice", "admins")
+	require.Error(t, err)
+	assert.Empty(t, sessionID)
+	assert.NotContains(t, h.pendingStates, state)
+	assert.Empty(t, h.sessions)
+}
+
+func TestLookupOAuth2UserBySessionRejectsExpiredSession(t *testing.T) {
+	h := &OAuth2Handler{
+		pendingStates: make(map[string]*oauth2PendingState),
+		sessions:      make(map[string]*oauth2Session),
+	}
+
+	h.sessions["stale"] = &oauth2Session{
+		Username:  "alice",
+		createdAt: time.Now().Add(-2 * oauthSessionMaxAge * time.Second),
+	}
+
+	user, found := h.lookupOAuth2UserBySession("stale")
+	assert.False(t, found)
+	assert.Nil(t, user)
+	assert.NotContains(t, h.sessions, "stale")
+}
+
+func TestLoadValidPendingOAuthStateRejectsExpired(t *testing.T) {
+	h := &OAuth2Handler{
+		pendingStates: make(map[string]*oauth2PendingState),
+		sessions:      make(map[string]*oauth2Session),
+	}
+
+	state := "expired-pending"
+	h.pendingStates[state] = &oauth2PendingState{
+		providerName: "test",
+		createdAt:    time.Now().Add(-2 * oauthStateMaxAge * time.Second),
+	}
+
+	rec := httptest.NewRecorder()
+	pending, gotState, ok := h.loadValidPendingOAuthState(rec, state)
+	assert.False(t, ok)
+	assert.Nil(t, pending)
+	assert.Equal(t, state, gotState)
+	assert.Equal(t, http.StatusBadRequest, rec.Code)
+	assert.NotContains(t, h.pendingStates, state)
+}
+
+func TestConsumePendingOAuthStateMismatchKeepsPending(t *testing.T) {
+	h := &OAuth2Handler{
+		pendingStates: make(map[string]*oauth2PendingState),
+		sessions:      make(map[string]*oauth2Session),
+	}
+
+	state := "valid-pending"
+	h.pendingStates[state] = &oauth2PendingState{
+		providerName: "test",
+		createdAt:    time.Now(),
+	}
+
+	req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oauth/callback?state=other-state", nil)
+	req.AddCookie(&http.Cookie{Name: oauthStateCookieName, Value: state})
+	rec := httptest.NewRecorder()
+
+	pending, gotState, ok := h.consumePendingOAuthState(rec, req)
+	assert.False(t, ok)
+	assert.Nil(t, pending)
+	assert.Equal(t, state, gotState)
+	assert.Equal(t, http.StatusBadRequest, rec.Code)
+	assert.Contains(t, h.pendingStates, state)
+}
+
+func TestIssueOAuthSessionRejectsWhenSessionCapacityExceeded(t *testing.T) {
+	h := &OAuth2Handler{
+		pendingStates: make(map[string]*oauth2PendingState),
+		sessions:      make(map[string]*oauth2Session, oauthSessionMaxEntries),
+	}
+
+	for i := range oauthSessionMaxEntries {
+		h.sessions[strconv.Itoa(i)] = &oauth2Session{
+			Username:  "user",
+			createdAt: time.Now(),
+		}
+	}
+
+	state := "pending"
+	h.pendingStates[state] = &oauth2PendingState{
+		providerName: "test",
+		createdAt:    time.Now(),
+	}
+
+	sessionID, err := h.issueOAuthSession(state, "alice", "admins")
+	require.Error(t, err)
+	assert.Empty(t, sessionID)
+	assert.Contains(t, h.pendingStates, state)
+	assert.Len(t, h.sessions, oauthSessionMaxEntries)
 }
 }

+ 3 - 2
service/internal/configcheck/rebuild.go

@@ -17,16 +17,17 @@ import (
 )
 )
 
 
 // Rebuild clears and rebuilds the issue list from the current config, entity
 // Rebuild clears and rebuilds the issue list from the current config, entity
-// state, sticky load-time reports, and any extra issues provided by callers
+// state, sticky load-time reports, runtime reports, and any extra issues provided by callers
 // (for example literal argument default validation from the executor).
 // (for example literal argument default validation from the executor).
 func Rebuild(cfg *config.Config, extra ...configissues.Issue) {
 func Rebuild(cfg *config.Config, extra ...configissues.Issue) {
 	if cfg == nil {
 	if cfg == nil {
-		configissues.Replace(extra)
+		configissues.Replace(append(configissues.CopyRuntime(), extra...))
 		return
 		return
 	}
 	}
 
 
 	collected := make([]configissues.Issue, 0)
 	collected := make([]configissues.Issue, 0)
 	collected = append(collected, configissues.CopySticky()...)
 	collected = append(collected, configissues.CopySticky()...)
+	collected = append(collected, configissues.CopyRuntime()...)
 	collected = append(collected, collectActionGroupIssues(cfg)...)
 	collected = append(collected, collectActionGroupIssues(cfg)...)
 	collected = append(collected, collectAclReferenceIssues(cfg)...)
 	collected = append(collected, collectAclReferenceIssues(cfg)...)
 	collected = append(collected, collectArgumentIssues(cfg)...)
 	collected = append(collected, collectArgumentIssues(cfg)...)

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

@@ -22,6 +22,7 @@ const (
 	CodeCronEntityBinding      = "cron_entity_binding"
 	CodeCronEntityBinding      = "cron_entity_binding"
 	CodeWatcherPath            = "watcher_path"
 	CodeWatcherPath            = "watcher_path"
 	CodeAclUnknown             = "acl_unknown"
 	CodeAclUnknown             = "acl_unknown"
+	CodeDeprecatedAPICall      = "deprecated_api_call"
 )
 )
 
 
 // Issue is a configuration warning or error surfaced on Diagnostics.
 // Issue is a configuration warning or error surfaced on Diagnostics.

+ 47 - 0
service/internal/configissues/runtime.go

@@ -0,0 +1,47 @@
+package configissues
+
+import "sync"
+
+var (
+	runtimeMu     sync.Mutex
+	runtimeIssues []Issue
+)
+
+// ReportRuntime records an issue discovered while OliveTin is running, rather
+// than from the configuration (for example, a call to a deprecated API
+// method). Unlike Report, runtime issues are kept when the issue list is
+// rebuilt on config reload or entity changes. They are cleared on restart.
+func ReportRuntime(issue Issue) {
+	addRuntime(issue)
+	Report(issue)
+}
+
+func addRuntime(issue Issue) {
+	runtimeMu.Lock()
+	defer runtimeMu.Unlock()
+
+	fingerprint := issueFingerprint(issue)
+	for _, existing := range runtimeIssues {
+		if issueFingerprint(existing) == fingerprint {
+			return
+		}
+	}
+
+	runtimeIssues = append(runtimeIssues, issue)
+}
+
+// CopyRuntime returns a copy of the runtime issues.
+func CopyRuntime() []Issue {
+	runtimeMu.Lock()
+	defer runtimeMu.Unlock()
+	out := make([]Issue, len(runtimeIssues))
+	copy(out, runtimeIssues)
+	return out
+}
+
+// ClearRuntime removes all runtime issues. Intended for tests.
+func ClearRuntime() {
+	runtimeMu.Lock()
+	defer runtimeMu.Unlock()
+	runtimeIssues = nil
+}

+ 30 - 0
service/internal/configissues/runtime_test.go

@@ -0,0 +1,30 @@
+package configissues_test
+
+import (
+	"testing"
+
+	"github.com/OliveTin/OliveTin/internal/configissues"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestReportRuntimeDeduplicatesAndLists(t *testing.T) {
+	configissues.ClearRuntime()
+	configissues.Replace(nil)
+	t.Cleanup(func() {
+		configissues.ClearRuntime()
+		configissues.Replace(nil)
+	})
+
+	issue := configissues.Issue{
+		Severity: configissues.SeverityWarning,
+		Code:     configissues.CodeDeprecatedAPICall,
+		Message:  "deprecated",
+		Source:   "StartActionByGet",
+	}
+
+	configissues.ReportRuntime(issue)
+	configissues.ReportRuntime(issue)
+
+	assert.Len(t, configissues.CopyRuntime(), 1)
+	assert.Equal(t, 1, configissues.Count())
+}

+ 11 - 2
service/internal/webhooks/handler.go

@@ -41,19 +41,28 @@ func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
 
 
 	matchingActions := h.findMatchingActions(r, payload)
 	matchingActions := h.findMatchingActions(r, payload)
 	if len(matchingActions) == 0 {
 	if len(matchingActions) == 0 {
+		log.WithFields(webhookRequestFields(r)).Infof("Webhook: no matching actions")
 		h.writeOKResponse(w, "no matching webhook actions")
 		h.writeOKResponse(w, "no matching webhook actions")
 		return
 		return
 	}
 	}
 
 
 	processed := h.processMatchingActions(matchingActions, r, payload)
 	processed := h.processMatchingActions(matchingActions, r, payload)
-	log.WithFields(log.Fields{
+	log.WithFields(webhookRequestFields(r)).WithFields(log.Fields{
 		"matched":   len(matchingActions),
 		"matched":   len(matchingActions),
 		"processed": processed,
 		"processed": processed,
-	}).Infof("Webhook processed")
+	}).Infof("Webhook: matched")
 
 
 	h.writeOKResponse(w, "webhook actions")
 	h.writeOKResponse(w, "webhook actions")
 }
 }
 
 
+func webhookRequestFields(r *http.Request) log.Fields {
+	return log.Fields{
+		"method": r.Method,
+		"path":   r.URL.Path,
+		"query":  r.URL.RawQuery,
+	}
+}
+
 func (h *WebhookHandler) readPayload(r *http.Request) ([]byte, error) {
 func (h *WebhookHandler) readPayload(r *http.Request) ([]byte, error) {
 	maxSize := int64(1024 * 1024)
 	maxSize := int64(1024 * 1024)
 	payload, err := io.ReadAll(io.LimitReader(r.Body, maxSize))
 	payload, err := io.ReadAll(io.LimitReader(r.Body, maxSize))

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác