From fd9ac96566ebec8177cd9954ad6f530fff2a21c9 Mon Sep 17 00:00:00 2001 From: Petr Date: Sun, 23 Aug 2026 22:18:17 +0200 Subject: [PATCH 1/2] fix(serve,ui): keep the UI session alive across server restarts Live-observed during the PR #665 UI audit: after a kbagent serve --ui restart (new bearer token), a browser reload could serve the cached index.html without contacting the server. No request means no fresh Set-Cookie, so the stale kbagent_session cookie 401'd every /api/* call and the SPA silently rendered empty lists with no visible error. Two complementary layers: - Server: GET / and GET /index.html now answer Cache-Control: no-cache, so the browser revalidates the shell on every load -- and since the bootstrap route implements no conditional handling, revalidation is always a full 200 that re-sets the session cookie. Scoped to the shell only; hashed build assets keep their default caching. - SPA: the API client treats a 401 as a possibly-stale cookie. It re-fetches the shell once with cache: 'reload' (single-flight across concurrent 401s), retries the request, and only if the retry still 401s dispatches kbagent:session-expired -- rendered by a new SessionExpiredBanner in the shell instead of failing silently. For SESSION_EXPIRED / SESSION_NOT_FOUND the banner carries the server message, which names the on-host 'kbagent auth login' remedy. Also corrects the _install_ui docstrings: Starlette's html=True not-found fallback serves 404.html (absent from a Vite build), not index.html -- the SPA is hash-routed, so every shell load goes through GET /. Verified live: restarted serve --ui with a new token; the deployed client healed a 401 via GET / re-bootstrap and retried to 200 without a manual reload. --- docs/web-server.md | 23 +++ src/keboola_agent_cli/server/app.py | 36 +++-- tests/test_serve_ui.py | 33 ++++ web/frontend/src/api/client.test.ts | 151 ++++++++++++++++++ web/frontend/src/api/client.ts | 54 +++++++ .../src/components/SessionExpiredBanner.tsx | 62 +++++++ web/frontend/src/layout/Shell.tsx | 3 + 7 files changed, 352 insertions(+), 10 deletions(-) create mode 100644 web/frontend/src/api/client.test.ts create mode 100644 web/frontend/src/components/SessionExpiredBanner.tsx diff --git a/docs/web-server.md b/docs/web-server.md index 33bb5b4d..50af7115 100644 --- a/docs/web-server.md +++ b/docs/web-server.md @@ -315,6 +315,29 @@ stdout, and refuses any request that does not present it as `Authorization: Bearer `. Public paths: `/health/ping`, `/health/auth-info`, `/openapi.json`, `/docs`, `/redoc`. +### Session cookie in single-process UI mode + +With `kbagent serve --ui`, the browser never sees the bearer token: +`GET /` (and `GET /index.html`) answers the SPA shell with a +`Set-Cookie: kbagent_session=; HttpOnly; SameSite=Strict; Path=/` +session cookie, and the auth middleware accepts that cookie whenever no +`Authorization` header is present. Scripted callers keep using the header. + +Two layers keep that cookie from going stale across server restarts +*(since vNEXT)* — previously a restart (new token) could leave a tab that +reloaded from the browser cache silently 401-ing on every API call, with +each list rendering as empty: + +- The shell is served with `Cache-Control: no-cache`, so a reload always + revalidates against the server — and the bootstrap route always answers + a full `200` with a fresh `Set-Cookie`. +- The SPA's API client treats a `401` as "cookie may be stale": it + re-fetches `/` once with `cache: "reload"` (bypassing every cache + layer), retries the request, and only if the retry still answers `401` + shows a visible **Session expired** banner (for `SESSION_EXPIRED` / + `SESSION_NOT_FOUND` the banner carries the server message, which names + the on-host `kbagent auth login` remedy). + ### What's-new popup *(since vNEXT)* The web UI shows a curated per-version highlights modal on load, once per diff --git a/src/keboola_agent_cli/server/app.py b/src/keboola_agent_cli/server/app.py index f6b92e74..5f5b872d 100644 --- a/src/keboola_agent_cli/server/app.py +++ b/src/keboola_agent_cli/server/app.py @@ -747,19 +747,26 @@ def _install_ui(app: FastAPI, *, ui_dist: str, token: str) -> None: (auth doesn't care about path, but PUBLIC_PATHS exact-matches do). 2) **Cookie-setting** ``GET /`` and ``GET /index.html``: read the built ``index.html``, return it with a ``Set-Cookie: kbagent_session=; - HttpOnly; SameSite=Strict; Path=/`` header. Public (no auth) so the - SPA can bootstrap. The browser then attaches the cookie to every - same-origin REST + SSE request automatically. The token is HttpOnly - (no JS access -- XSS-resistant), SameSite=Strict (no cross-origin - sends -- CSRF-resistant), and lives only for the browser session. + HttpOnly; SameSite=Strict; Path=/`` header and ``Cache-Control: + no-cache`` (revalidate-always -- a cached shell served without a + request would keep a stale cookie alive across server restarts). + Public (no auth) so the SPA can bootstrap. The browser then attaches + the cookie to every same-origin REST + SSE request automatically. The + token is HttpOnly (no JS access -- XSS-resistant), SameSite=Strict + (no cross-origin sends -- CSRF-resistant), and lives only for the + browser session. This replaces the older "inject ``window.__KBAGENT_TOKEN`` into a ``