Write

# Replace gorilla/sessions with alexedwards/scs/v2

## Context

`internal/auth/auth.go` uses `github.com/gorilla/sessions` (a `CookieStore`) for
browser session cookies after login. We are replacing it with
`github.com/alexedwards/scs/v2`.

The packages differ architecturally:
- **gorilla `CookieStore`** is *stateless* — all session data is encrypted into
  the cookie itself (signed/encrypted with `SESSION_KEY`). Sessions survive
  restarts as long as the key is stable.
- **scs/v2** is *server-side* — only a random token lives in the cookie; the data
  lives in a store. The cookie needs no encryption key.

**Decisions made:**
- **Store backend: `sqlite3store`** (persist sessions in the existing SQLite DB).
  Preserves the current "stay logged in across restarts" behavior using the DB
  they already run. Adds dep `github.com/alexedwards/scs/sqlite3store`.
- **Remove `SESSION_KEY`** entirely — scs generates secure random tokens, so the
  key is dead code. Drop the env var, the fatal startup check, and config/doc refs.

**Performance note:** scs's idiomatic `LoadAndSave` middleware buffers the entire
HTTP response in memory to inject the cookie. The secured route group includes the
large GraphQL `/query` endpoint, and CLAUDE.md mandates streaming over buffering.
Therefore we will **NOT** use `LoadAndSave`. Instead we keep the existing
on-demand pattern and drive scs's lower-level API (`Load`/`Put`/`Commit`/`Destroy`)
inside the existing methods, and write the cookie ourselves to preserve the exact
current cookie semantics (per-request `Secure` flag, name, SameSite, HttpOnly).
This means **no router/middleware changes** in `cmd/cc-backend/server.go`.

## Changes

### 1. Dependencies (`go.mod` / `go.sum`)
- Add `github.com/alexedwards/scs/v2` and `github.com/alexedwards/scs/sqlite3store`.
- Remove `github.com/gorilla/sessions` (and transitive `gorilla/securecookie` from go.sum) via `go mod tidy`.

### 2. New DB migration — `sessions` table
Create migration #12 in `internal/repository/migrations/sqlite3/`:
- `12_add-sessions-table.up.sql` — the schema sqlite3store expects:
  ```sql
  CREATE TABLE sessions (
      token  TEXT PRIMARY KEY,
      data   BLOB NOT NULL,
      expiry REAL NOT NULL
  );
  CREATE INDEX sessions_expiry_idx ON sessions(expiry);
  ```
- `12_add-sessions-table.down.sql` — `DROP TABLE IF EXISTS sessions;`
- Bump `Version` to `12` in `internal/repository/migration.go:28` and add a
  history comment line matching the existing style.

> Note: `repository.Connect()` only *checks* the version; existing installs must
> run `-migrate-db`. `-init-db` (`cmd/cc-backend/init.go:83`) already calls
> `MigrateDB`, so fresh installs get the table automatically.

### 3. `internal/auth/auth.go` — the core swap
Replace the gorilla import with:
```go
"github.com/alexedwards/scs/v2"
"github.com/alexedwards/scs/sqlite3store"
"github.com/ClusterCockpit/cc-backend/internal/repository"
"encoding/gob"
```
(Drop the now-unused `crypto/rand`, `encoding/base64`, `os` imports if nothing
else needs them — verify before removing.)

- **Struct** (line 121): replace `sessionStore *sessions.CookieStore` with
  `sessionManager *scs.SessionManager`.

- **`Init()`** (lines 189–213): delete the entire `SESSION_KEY` block
  (lines 189–209). Replace with scs setup:
  ```go
  gob.Register([]string{}) // needed to gob-encode user.Projects/Roles in session values
  sm := scs.New()
  sm.Store = sqlite3store.New(repository.GetConnection().DB.DB)
  sm.Cookie.Name = "session"
  sm.Cookie.HttpOnly = true
  sm.Cookie.SameSite = http.SameSiteLaxMode
  sm.Cookie.Path = "/"
  if d, err := time.ParseDuration(config.Keys.SessionMaxAge); err == nil && d != 0 {
      sm.Lifetime = d
      authInstance.SessionMaxAge = d
  } // else: keep scs default Lifetime; SessionMaxAge stays 0 -> "session cookie"
  authInstance.sessionManager = sm
  ```
  Keep the rest of `Init` (authenticator wiring) unchanged.

- **`SaveSession()`** (lines 321–354): rewrite using scs, but build the cookie
  manually to keep the per-request `Secure` logic identical:
  ```go
  ctx, _ := auth.sessionManager.Load(r.Context(), "") // new empty session
  auth.sessionManager.Put(ctx, "username", user.Username)
  auth.sessionManager.Put(ctx, "projects", user.Projects)   // []string
  auth.sessionManager.Put(ctx, "roles", user.Roles)         // []string
  auth.sessionManager.Put(ctx, "authSource", int(user.AuthSource))
  token, expiry, err := auth.sessionManager.Commit(ctx)
  // ... error handling identical to current ...
  secure := !(r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https")
  // (keep the existing warn logs for the insecure case)
  maxAge := 0
  if auth.SessionMaxAge != 0 { maxAge = int(auth.SessionMaxAge.Seconds()) }
  http.SetCookie(rw, &http.Cookie{
      Name: "session", Value: token, Path: "/",
      Expires: expiry, MaxAge: maxAge,
      Secure: secure, HttpOnly: true, SameSite: http.SameSiteLaxMode,
  })
  ```

- **`AuthViaSession()`** (lines 129–180): rewrite read path. Keep the same
  validation/return shape (`*schema.User` with `AuthType: schema.AuthSession`):
  ```go
  cookie, err := r.Cookie("session")
  if err != nil { return nil, nil } // no session cookie == not logged in (mirrors session.IsNew)
  ctx, err := auth.sessionManager.Load(r.Context(), cookie.Value)
  if err != nil { return nil, err }
  username := auth.sessionManager.GetString(ctx, "username")
  if username == "" { return nil, nil } // unknown/expired token -> treat as no session
  // projects: auth.sessionManager.Get(ctx,"projects").([]string) with ok-check -> default []string
  // roles: same; if empty -> invalid; destroy + return error (preserve current behavior)
  // authSource: auth.sessionManager.GetInt(ctx,"authSource"), default AuthViaLocalPassword
  ```
  For the "corrupted session" branches that currently set `MaxAge=-1` and save,
  call `auth.sessionManager.Destroy(ctx)` and write an expired `session` cookie
  (same manual-cookie helper) to clear it client-side.

- **`Logout()`** (lines 610–628): rewrite:
  ```go
  if cookie, err := r.Cookie("session"); err == nil {
      ctx, err := auth.sessionManager.Load(r.Context(), cookie.Value)
      if err == nil { _ = auth.sessionManager.Destroy(ctx) } // removes row from store
  }
  http.SetCookie(rw, &http.Cookie{Name:"session", Value:"", Path:"/", MaxAge:-1, HttpOnly:true, SameSite:http.SameSiteLaxMode})
  onsuccess.ServeHTTP(rw, r)
  ```

> Consider a small private helper `writeSessionCookie(rw, token, expiry, secure, maxAge)` to avoid duplicating the `http.SetCookie` block across SaveSession / AuthViaSession-clear / Logout.

### 4. Remove SESSION_KEY references elsewhere
- `.env:8` and `configs/env-template.txt:11` — remove the `SESSION_KEY=` line.
- `cmd/cc-backend/init.go` — remove `SESSION_KEY` from the embedded `.env` template string it generates.
- `internal/api/api_test.go` and `internal/api/nats_test.go` — remove the
  `t.Setenv("SESSION_KEY", ...)` calls (now no-ops; clean them up).
- Search docs (`*.md`, `docs/`) for `SESSION_KEY` and update any setup instructions.

## Key files
- `internal/auth/auth.go` — struct, `Init`, `SaveSession`, `AuthViaSession`, `Logout`
- `internal/repository/migrations/sqlite3/12_add-sessions-table.{up,down}.sql` (new)
- `internal/repository/migration.go` — `Version` → 12
- `go.mod` / `go.sum`
- `.env`, `configs/env-template.txt`, `cmd/cc-backend/init.go`
- `internal/api/api_test.go`, `internal/api/nats_test.go`

## Verification
1. `go build ./...` and `make` (frontend unaffected) succeed; `go vet ./...` clean.
2. `go mod tidy` removes gorilla/sessions; confirm it's gone from go.mod/go.sum.
3. `go test ./...` passes (esp. `internal/api`, `internal/auth`, `internal/repository`).
   Tests that build a temp DB must run migrations so the `sessions` table exists.
4. Fresh DB: `./cc-backend -init-db -add-user demo:admin:demo` then
   `./cc-backend -server -dev` — DB reaches version 12.
5. Existing DB: starting `-server` without migrating must fail with the version-12
   mismatch message; `./cc-backend -migrate-db` then succeeds.
6. Manual end-to-end in browser:
   - Log in (local user) → a `session` cookie is set; a row appears in the
     `sessions` table (`sqlite3 var/job.db 'select token,expiry from sessions;'`).
   - Authenticated pages/GraphQL `/query` work; verify `/query` responses are not
     being buffered/regressed.
   - **Restart the server, reload** → still logged in (durability preserved).
   - Log out → `sessions` row removed, cookie cleared, redirected to login.
   - Confirm cookie flags: `HttpOnly`, `SameSite=Lax`, and `Secure` set only over
     HTTPS/`X-Forwarded-Proto: https` (matches old behavior).
   - OIDC/JWT-cookie login paths still establish a session (they call `SaveSession`).


File created successfully at: /Users/jan/.claude/plans/replace-the-gorilla-sessions-package-atomic-metcalfe.md (file state is current in your context — no need to Read it back)

# Replace gorilla/sessions with alexedwards/scs/v2

## Context

`internal/auth/auth.go` uses `github.com/gorilla/sessions` (a `CookieStore`) for
browser session cookies after login. We are replacing it with
`github.com/alexedwards/scs/v2`.

The packages differ architecturally:
- **gorilla `CookieStore`** is *stateless* — all session data is encrypted into
  the cookie itself (signed/encrypted with `SESSION_KEY`). Sessions survive
  restarts as long as the key is stable.
- **scs/v2** is *server-side* — only a random token lives in the cookie; the data
  lives in a store. The cookie needs no encryption key.

**Decisions made:**
- **Store backend: `sqlite3store`** (persist sessions in the existing SQLite DB).
  Preserves the current "stay logged in across restarts" behavior using the DB
  they already run. Adds dep `github.com/alexedwards/scs/sqlite3store`.
- **Remove `SESSION_KEY`** entirely — scs generates secure random tokens, so the
  key is dead code. Drop the env var, the fatal startup check, and config/doc refs.

**Performance note:** scs's idiomatic `LoadAndSave` middleware buffers the entire
HTTP response in memory to inject the cookie. The secured route group includes the
large GraphQL `/query` endpoint, and CLAUDE.md mandates streaming over buffering.
Therefore we will **NOT** use `LoadAndSave`. Instead we keep the existing
on-demand pattern and drive scs's lower-level API (`Load`/`Put`/`Commit`/`Destroy`)
inside the existing methods, and write the cookie ourselves to preserve the exact
current cookie semantics (per-request `Secure` flag, name, SameSite, HttpOnly).
This means **no router/middleware changes** in `cmd/cc-backend/server.go`.

## Changes

### 1. Dependencies (`go.mod` / `go.sum`)
- Add `github.com/alexedwards/scs/v2` and `github.com/alexedwards/scs/sqlite3store`.
- Remove `github.com/gorilla/sessions` (and transitive `gorilla/securecookie` from go.sum) via `go mod tidy`.

### 2. New DB migration — `sessions` table
Create migration #12 in `internal/repository/migrations/sqlite3/`:
- `12_add-sessions-table.up.sql` — the schema sqlite3store expects:
  ```sql
  CREATE TABLE sessions (
      token  TEXT PRIMARY KEY,
      data   BLOB NOT NULL,
      expiry REAL NOT NULL
  );
  CREATE INDEX sessions_expiry_idx ON sessions(expiry);
  ```
- `12_add-sessions-table.down.sql` — `DROP TABLE IF EXISTS sessions;`
- Bump `Version` to `12` in `internal/repository/migration.go:28` and add a
  history comment line matching the existing style.

> Note: `repository.Connect()` only *checks* the version; existing installs must
> run `-migrate-db`. `-init-db` (`cmd/cc-backend/init.go:83`) already calls
> `MigrateDB`, so fresh installs get the table automatically.

### 3. `internal/auth/auth.go` — the core swap
Replace the gorilla import with:
```go
"github.com/alexedwards/scs/v2"
"github.com/alexedwards/scs/sqlite3store"
"github.com/ClusterCockpit/cc-backend/internal/repository"
"encoding/gob"
```
(Drop the now-unused `crypto/rand`, `encoding/base64`, `os` imports if nothing
else needs them — verify before removing.)

- **Struct** (line 121): replace `sessionStore *sessions.CookieStore` with
  `sessionManager *scs.SessionManager`.

- **`Init()`** (lines 189–213): delete the entire `SESSION_KEY` block
  (lines 189–209). Replace with scs setup:
  ```go
  gob.Register([]string{}) // needed to gob-encode user.Projects/Roles in session values
  sm := scs.New()
  sm.Store = sqlite3store.New(repository.GetConnection().DB.DB)
  sm.Cookie.Name = "session"
  sm.Cookie.HttpOnly = true
  sm.Cookie.SameSite = http.SameSiteLaxMode
  sm.Cookie.Path = "/"
  if d, err := time.ParseDuration(config.Keys.SessionMaxAge); err == nil && d != 0 {
      sm.Lifetime = d
      authInstance.SessionMaxAge = d
  } // else: keep scs default Lifetime; SessionMaxAge stays 0 -> "session cookie"
  authInstance.sessionManager = sm
  ```
  Keep the rest of `Init` (authenticator wiring) unchanged.

- **`SaveSession()`** (lines 321–354): rewrite using scs, but build the cookie
  manually to keep the per-request `Secure` logic identical:
  ```go
  ctx, _ := auth.sessionManager.Load(r.Context(), "") // new empty session
  auth.sessionManager.Put(ctx, "username", user.Username)
  auth.sessionManager.Put(ctx, "projects", user.Projects)   // []string
  auth.sessionManager.Put(ctx, "roles", user.Roles)         // []string
  auth.sessionManager.Put(ctx, "authSource", int(user.AuthSource))
  token, expiry, err := auth.sessionManager.Commit(ctx)
  // ... error handling identical to current ...
  secure := !(r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https")
  // (keep the existing warn logs for the insecure case)
  maxAge := 0
  if auth.SessionMaxAge != 0 { maxAge = int(auth.SessionMaxAge.Seconds()) }
  http.SetCookie(rw, &http.Cookie{
      Name: "session", Value: token, Path: "/",
      Expires: expiry, MaxAge: maxAge,
      Secure: secure, HttpOnly: true, SameSite: http.SameSiteLaxMode,
  })
  ```

- **`AuthViaSession()`** (lines 129–180): rewrite read path. Keep the same
  validation/return shape (`*schema.User` with `AuthType: schema.AuthSession`):
  ```go
  cookie, err := r.Cookie("session")
  if err != nil { return nil, nil } // no session cookie == not logged in (mirrors session.IsNew)
  ctx, err := auth.sessionManager.Load(r.Context(), cookie.Value)
  if err != nil { return nil, err }
  username := auth.sessionManager.GetString(ctx, "username")
  if username == "" { return nil, nil } // unknown/expired token -> treat as no session
  // projects: auth.sessionManager.Get(ctx,"projects").([]string) with ok-check -> default []string
  // roles: same; if empty -> invalid; destroy + return error (preserve current behavior)
  // authSource: auth.sessionManager.GetInt(ctx,"authSource"), default AuthViaLocalPassword
  ```
  For the "corrupted session" branches that currently set `MaxAge=-1` and save,
  call `auth.sessionManager.Destroy(ctx)` and write an expired `session` cookie
  (same manual-cookie helper) to clear it client-side.

- **`Logout()`** (lines 610–628): rewrite:
  ```go
  if cookie, err := r.Cookie("session"); err == nil {
      ctx, err := auth.sessionManager.Load(r.Context(), cookie.Value)
      if err == nil { _ = auth.sessionManager.Destroy(ctx) } // removes row from store
  }
  http.SetCookie(rw, &http.Cookie{Name:"session", Value:"", Path:"/", MaxAge:-1, HttpOnly:true, SameSite:http.SameSiteLaxMode})
  onsuccess.ServeHTTP(rw, r)
  ```

> Consider a small private helper `writeSessionCookie(rw, token, expiry, secure, maxAge)` to avoid duplicating the `http.SetCookie` block across SaveSession / AuthViaSession-clear / Logout.

### 4. Remove SESSION_KEY references elsewhere
- `.env:8` and `configs/env-template.txt:11` — remove the `SESSION_KEY=` line.
- `cmd/cc-backend/init.go` — remove `SESSION_KEY` from the embedded `.env` template string it generates.
- `internal/api/api_test.go` and `internal/api/nats_test.go` — remove the
  `t.Setenv("SESSION_KEY", ...)` calls (now no-ops; clean them up).
- Search docs (`*.md`, `docs/`) for `SESSION_KEY` and update any setup instructions.

## Key files
- `internal/auth/auth.go` — struct, `Init`, `SaveSession`, `AuthViaSession`, `Logout`
- `internal/repository/migrations/sqlite3/12_add-sessions-table.{up,down}.sql` (new)
- `internal/repository/migration.go` — `Version` → 12
- `go.mod` / `go.sum`
- `.env`, `configs/env-template.txt`, `cmd/cc-backend/init.go`
- `internal/api/api_test.go`, `internal/api/nats_test.go`

## Verification
1. `go build ./...` and `make` (frontend unaffected) succeed; `go vet ./...` clean.
2. `go mod tidy` removes gorilla/sessions; confirm it's gone from go.mod/go.sum.
3. `go test ./...` passes (esp. `internal/api`, `internal/auth`, `internal/repository`).
   Tests that build a temp DB must run migrations so the `sessions` table exists.
4. Fresh DB: `./cc-backend -init-db -add-user demo:admin:demo` then
   `./cc-backend -server -dev` — DB reaches version 12.
5. Existing DB: starting `-server` without migrating must fail with the version-12
   mismatch message; `./cc-backend -migrate-db` then succeeds.
6. Manual end-to-end in browser:
   - Log in (local user) → a `session` cookie is set; a row appears in the
     `sessions` table (`sqlite3 var/job.db 'select token,expiry from sessions;'`).
   - Authenticated pages/GraphQL `/query` work; verify `/query` responses are not
     being buffered/regressed.
   - **Restart the server, reload** → still logged in (durability preserved).
   - Log out → `sessions` row removed, cookie cleared, redirected to login.
   - Confirm cookie flags: `HttpOnly`, `SameSite=Lax`, and `Secure` set only over
     HTTPS/`X-Forwarded-Proto: https` (matches old behavior).
   - OIDC/JWT-cookie login paths still establish a session (they call `SaveSession`).


The plan is ready. Here's the summary:

## Plan: Replace gorilla/sessions with alexedwards/scs/v2

**The key architectural shift:** gorilla `CookieStore` is *stateless* (all data encrypted in the cookie via `SESSION_KEY`), while scs/v2 is *server-side* (random token in cookie, data in a store). Per your choices, sessions go into the **existing SQLite DB** via `sqlite3store`, and **`SESSION_KEY` is removed** as dead code.

**Performance-conscious design:** I deliberately avoid scs's idiomatic `LoadAndSave` middleware because it buffers the *entire* HTTP response in memory — unacceptable for the large GraphQL `/query` endpoint under your streaming-over-buffering guidance. Instead I keep the existing on-demand pattern, driving scs's lower-level `Load`/`Put`/`Commit`/`Destroy` API inside the three existing methods and writing the cookie manually. This preserves the exact current cookie semantics (per-request `Secure` flag, `SameSite=Lax`, `HttpOnly`) and requires **zero changes to the chi router/middleware** in `server.go`.

**Scope:**
- `internal/auth/auth.go` — swap the `sessionStore` field + rewrite `Init`/`SaveSession`/`AuthViaSession`/`Logout`
- New migration #12 creating the `sessions` table; bump `Version` to 12
- Dependency swap + `go mod tidy`
- Strip `SESSION_KEY` from `.env`, `env-template.txt`, `init.go`, and two test files

I verified the critical ordering: `repository.Connect()` runs before `auth.Init()`, so the store's DB handle is ready, and `-init-db` already runs migrations so the table exists for fresh installs.

One behavior change worth flagging: existing deployments will need to run `./cc-backend -migrate-db` once (and any in-flight sessions are dropped at cutover since the storage model changes).


ToolSearch

Question: select:ExitPlanMode


Question: select:ExitPlanMode


ExitPlanMode

# Replace gorilla/sessions with alexedwards/scs/v2

## Context

`internal/auth/auth.go` uses `github.com/gorilla/sessions` (a `CookieStore`) for
browser session cookies after login. We are replacing it with
`github.com/alexedwards/scs/v2`.

The packages differ architecturally:
- **gorilla `CookieStore`** is *stateless* — all session data is encrypted into
  the cookie itself (signed/encrypted with `SESSION_KEY`). Sessions survive
  restarts as long as the key is stable.
- **scs/v2** is *server-side* — only a random token lives in the cookie; the data
  lives in a store. The cookie needs no encryption key.

**Decisions made:**
- **Store backend: `sqlite3store`** (persist sessions in the existing SQLite DB).
  Preserves the current "stay logged in across restarts" behavior using the DB
  they already run. Adds dep `github.com/alexedwards/scs/sqlite3store`.
- **Remove `SESSION_KEY`** entirely — scs generates secure random tokens, so the
  key is dead code. Drop the env var, the fatal startup check, and config/doc refs.

**Performance note:** scs's idiomatic `LoadAndSave` middleware buffers the entire
HTTP response in memory to inject the cookie. The secured route group includes the
large GraphQL `/query` endpoint, and CLAUDE.md mandates streaming over buffering.
Therefore we will **NOT** use `LoadAndSave`. Instead we keep the existing
on-demand pattern and drive scs's lower-level API (`Load`/`Put`/`Commit`/`Destroy`)
inside the existing methods, and write the cookie ourselves to preserve the exact
current cookie semantics (per-request `Secure` flag, name, SameSite, HttpOnly).
This means **no router/middleware changes** in `cmd/cc-backend/server.go`.

## Changes

### 1. Dependencies (`go.mod` / `go.sum`)
- Add `github.com/alexedwards/scs/v2` and `github.com/alexedwards/scs/sqlite3store`.
- Remove `github.com/gorilla/sessions` (and transitive `gorilla/securecookie` from go.sum) via `go mod tidy`.

### 2. New DB migration — `sessions` table
Create migration #12 in `internal/repository/migrations/sqlite3/`:
- `12_add-sessions-table.up.sql` — the schema sqlite3store expects:
  ```sql
  CREATE TABLE sessions (
      token  TEXT PRIMARY KEY,
      data   BLOB NOT NULL,
      expiry REAL NOT NULL
  );
  CREATE INDEX sessions_expiry_idx ON sessions(expiry);
  ```
- `12_add-sessions-table.down.sql` — `DROP TABLE IF EXISTS sessions;`
- Bump `Version` to `12` in `internal/repository/migration.go:28` and add a
  history comment line matching the existing style.

> Note: `repository.Connect()` only *checks* the version; existing installs must
> run `-migrate-db`. `-init-db` (`cmd/cc-backend/init.go:83`) already calls
> `MigrateDB`, so fresh installs get the table automatically.

### 3. `internal/auth/auth.go` — the core swap
Replace the gorilla import with:
```go
"github.com/alexedwards/scs/v2"
"github.com/alexedwards/scs/sqlite3store"
"github.com/ClusterCockpit/cc-backend/internal/repository"
"encoding/gob"
```
(Drop the now-unused `crypto/rand`, `encoding/base64`, `os` imports if nothing
else needs them — verify before removing.)

- **Struct** (line 121): replace `sessionStore *sessions.CookieStore` with
  `sessionManager *scs.SessionManager`.

- **`Init()`** (lines 189–213): delete the entire `SESSION_KEY` block
  (lines 189–209). Replace with scs setup:
  ```go
  gob.Register([]string{}) // needed to gob-encode user.Projects/Roles in session values
  sm := scs.New()
  sm.Store = sqlite3store.New(repository.GetConnection().DB.DB)
  sm.Cookie.Name = "session"
  sm.Cookie.HttpOnly = true
  sm.Cookie.SameSite = http.SameSiteLaxMode
  sm.Cookie.Path = "/"
  if d, err := time.ParseDuration(config.Keys.SessionMaxAge); err == nil && d != 0 {
      sm.Lifetime = d
      authInstance.SessionMaxAge = d
  } // else: keep scs default Lifetime; SessionMaxAge stays 0 -> "session cookie"
  authInstance.sessionManager = sm
  ```
  Keep the rest of `Init` (authenticator wiring) unchanged.

- **`SaveSession()`** (lines 321–354): rewrite using scs, but build the cookie
  manually to keep the per-request `Secure` logic identical:
  ```go
  ctx, _ := auth.sessionManager.Load(r.Context(), "") // new empty session
  auth.sessionManager.Put(ctx, "username", user.Username)
  auth.sessionManager.Put(ctx, "projects", user.Projects)   // []string
  auth.sessionManager.Put(ctx, "roles", user.Roles)         // []string
  auth.sessionManager.Put(ctx, "authSource", int(user.AuthSource))
  token, expiry, err := auth.sessionManager.Commit(ctx)
  // ... error handling identical to current ...
  secure := !(r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https")
  // (keep the existing warn logs for the insecure case)
  maxAge := 0
  if auth.SessionMaxAge != 0 { maxAge = int(auth.SessionMaxAge.Seconds()) }
  http.SetCookie(rw, &http.Cookie{
      Name: "session", Value: token, Path: "/",
      Expires: expiry, MaxAge: maxAge,
      Secure: secure, HttpOnly: true, SameSite: http.SameSiteLaxMode,
  })
  ```

- **`AuthViaSession()`** (lines 129–180): rewrite read path. Keep the same
  validation/return shape (`*schema.User` with `AuthType: schema.AuthSession`):
  ```go
  cookie, err := r.Cookie("session")
  if err != nil { return nil, nil } // no session cookie == not logged in (mirrors session.IsNew)
  ctx, err := auth.sessionManager.Load(r.Context(), cookie.Value)
  if err != nil { return nil, err }
  username := auth.sessionManager.GetString(ctx, "username")
  if username == "" { return nil, nil } // unknown/expired token -> treat as no session
  // projects: auth.sessionManager.Get(ctx,"projects").([]string) with ok-check -> default []string
  // roles: same; if empty -> invalid; destroy + return error (preserve current behavior)
  // authSource: auth.sessionManager.GetInt(ctx,"authSource"), default AuthViaLocalPassword
  ```
  For the "corrupted session" branches that currently set `MaxAge=-1` and save,
  call `auth.sessionManager.Destroy(ctx)` and write an expired `session` cookie
  (same manual-cookie helper) to clear it client-side.

- **`Logout()`** (lines 610–628): rewrite:
  ```go
  if cookie, err := r.Cookie("session"); err == nil {
      ctx, err := auth.sessionManager.Load(r.Context(), cookie.Value)
      if err == nil { _ = auth.sessionManager.Destroy(ctx) } // removes row from store
  }
  http.SetCookie(rw, &http.Cookie{Name:"session", Value:"", Path:"/", MaxAge:-1, HttpOnly:true, SameSite:http.SameSiteLaxMode})
  onsuccess.ServeHTTP(rw, r)
  ```

> Consider a small private helper `writeSessionCookie(rw, token, expiry, secure, maxAge)` to avoid duplicating the `http.SetCookie` block across SaveSession / AuthViaSession-clear / Logout.

### 4. Remove SESSION_KEY references elsewhere
- `.env:8` and `configs/env-template.txt:11` — remove the `SESSION_KEY=` line.
- `cmd/cc-backend/init.go` — remove `SESSION_KEY` from the embedded `.env` template string it generates.
- `internal/api/api_test.go` and `internal/api/nats_test.go` — remove the
  `t.Setenv("SESSION_KEY", ...)` calls (now no-ops; clean them up).
- Search docs (`*.md`, `docs/`) for `SESSION_KEY` and update any setup instructions.

## Key files
- `internal/auth/auth.go` — struct, `Init`, `SaveSession`, `AuthViaSession`, `Logout`
- `internal/repository/migrations/sqlite3/12_add-sessions-table.{up,down}.sql` (new)
- `internal/repository/migration.go` — `Version` → 12
- `go.mod` / `go.sum`
- `.env`, `configs/env-template.txt`, `cmd/cc-backend/init.go`
- `internal/api/api_test.go`, `internal/api/nats_test.go`

## Verification
1. `go build ./...` and `make` (frontend unaffected) succeed; `go vet ./...` clean.
2. `go mod tidy` removes gorilla/sessions; confirm it's gone from go.mod/go.sum.
3. `go test ./...` passes (esp. `internal/api`, `internal/auth`, `internal/repository`).
   Tests that build a temp DB must run migrations so the `sessions` table exists.
4. Fresh DB: `./cc-backend -init-db -add-user demo:admin:demo` then
   `./cc-backend -server -dev` — DB reaches version 12.
5. Existing DB: starting `-server` without migrating must fail with the version-12
   mismatch message; `./cc-backend -migrate-db` then succeeds.
6. Manual end-to-end in browser:
   - Log in (local user) → a `session` cookie is set; a row appears in the
     `sessions` table (`sqlite3 var/job.db 'select token,expiry from sessions;'`).
   - Authenticated pages/GraphQL `/query` work; verify `/query` responses are not
     being buffered/regressed.
   - **Restart the server, reload** → still logged in (durability preserved).
   - Log out → `sessions` row removed, cookie cleared, redirected to login.
   - Confirm cookie flags: `HttpOnly`, `SameSite=Lax`, and `Secure` set only over
     HTTPS/`X-Forwarded-Proto: https` (matches old behavior).
   - OIDC/JWT-cookie login paths still establish a session (they call `SaveSession`).


Replace the gorilla/sessions package with  github.com/alexedwards/scs/v2


Error: The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). To tell you how to proceed, the user said:
I decided for a cleaner implementation and use LoadAndSave throughput. Is this an issue?


Good question — let me give you a straight answer, because there are two real tradeoffs with `LoadAndSave` and one of them matters for this app.


Bash

grep -rn "websocket\|Websocket\|WebSocket\|transport\.\|SSE\|Flush\|subscriptions" cmd/cc-backend/server.go internal/graph/ 2>/dev/null | grep -iv "_test" | head -30

Check for GraphQL websocket/streaming transports


cmd/cc-backend/server.go:99:	graphQLServer.AddTransport(transport.POST{})
cmd/cc-backend/server.go:332:			return fmt.Errorf("starting NATS subscriptions: %w", err)


## Short answer: No, it's not a blocker — but scope it, don't apply it globally.

I checked: the GraphQL server only registers `transport.POST{}` (server.go:99) — **no WebSocket subscriptions, no SSE**, and the secured group has no streaming endpoints. That eliminates the scary part of `LoadAndSave` (interfering with flushing/hijacking). So correctness is fine and the code is cleaner. Two things to get right:

**1. Buffering — scope the middleware, never apply it at the router root.**
`LoadAndSave` wraps the response in a buffered writer for *every* request it covers, regardless of whether a session exists. If you `s.router.Use(...)` it globally, you'd buffer the **high-volume JWT data APIs** — `/api`, `/api/metricstore`, `/userapi`, `/frontend` — which return the large metric payloads. That's the real throughput regression, and it's avoidable: those routes are JWT-authenticated and never touch the session.

So apply `LoadAndSave` only to the routes that read/write the session:
- `/login`, `/jwt-login`, `/logout` (tiny login-page responses — buffering is free), and
- the **secured group** (server.go:247 — `/query`, `/search`, template pages).

That leaves the metric-store/data APIs completely untouched. GraphQL `/query` POST responses do get buffered, but they're fully materialized before write anyway, so the cost is one transient in-memory copy — acceptable.

**2. Per-request `Secure` flag is lost.** This is the one genuine behavior change. Today `SaveSession` sets `Secure=false` per-request when the connection isn't HTTPS. With `LoadAndSave`, `sm.Cookie.Secure` is a single global setting. We'll need to derive it once at startup (e.g. `Secure = HTTPSCertFile != ""`, or a small config flag) — meaning a proxy-terminated-TLS deployment would need that flag set. Minor, but worth a note.

With `LoadAndSave`, the method bodies also get simpler: `SaveSession` becomes `RenewToken(ctx)` + `Put(...)` on the request context (the middleware commits + sets the cookie); `AuthViaSession` reads via `GetString(ctx, ...)`; `Logout` calls `Destroy(ctx)`. No manual cookie handling.

Want me to update the plan to the `LoadAndSave` approach (scoped to login/logout + secured group, with the global `Secure` setting derived from config)? Once you confirm I'll revise and re-present.


We're planning the gorilla/sessions → scs/v2 migration (SQLite-backed sessions, SESSION_KEY removed). You chose the idiomatic LoadAndSave middleware. Next: I update the plan to scope LoadAndSave to login/logout plus the secured group, with the cookie Secure flag derived from config. (disable recaps in /config)


Can you do a thorough analysis on the endpoints used between the frontend and the backend. They should all be authenticated via the session. Only external (outside the web ui) use cases should use JWT authentication.


This is an important question — it directly determines which routes `LoadAndSave` must wrap. Let me analyze both sides: what auth each backend endpoint requires, and what the frontend actually calls.


420		})
421	}
422
423	func (auth *Authentication) Auth(
424		onsuccess http.Handler,
425		onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
426	) http.Handler {
427		return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
428			user, err := auth.JwtAuth.AuthViaJWT(rw, r)
429			if err != nil {
430				cclog.Infof("auth -> authentication failed: %s", err.Error())
431				http.Error(rw, err.Error(), http.StatusUnauthorized)
432				return
433			}
434			if user == nil {
435				user, err = auth.AuthViaSession(rw, r)
436				if err != nil {
437					cclog.Infof("auth -> authentication failed: %s", err.Error())
438					http.Error(rw, err.Error(), http.StatusUnauthorized)
439					return
440				}
441			}
442			if user != nil {
443				ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
444				onsuccess.ServeHTTP(rw, r.WithContext(ctx))
445				return
446			}
447
448			cclog.Infof("auth -> authentication failed: no valid session or JWT for %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
449			onfailure(rw, r, errors.New("unauthorized (please login first)"))
450		})
451	}
452
453	func (auth *Authentication) AuthAPI(
454		onsuccess http.Handler,
455		onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
456	) http.Handler {
457		return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
458			user, err := auth.JwtAuth.AuthViaJWT(rw, r)
459			if err != nil {
460				cclog.Infof("auth api -> authentication failed: %s", err.Error())
461				onfailure(rw, r, err)
462				return
463			}
464
465			ipErr := securedCheck(user, r)
466			if ipErr != nil {
467				cclog.Infof("auth api -> secured check failed: %s", ipErr.Error())
468				onfailure(rw, r, ipErr)
469				return
470			}
471
472			if user != nil {
473				switch {
474				case len(user.Roles) == 1:
475					if user.HasRole(schema.RoleAPI) {
476						ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
477						onsuccess.ServeHTTP(rw, r.WithContext(ctx))
478						return
479					}
480				case len(user.Roles) >= 2:
481					if user.HasAllRoles([]schema.Role{schema.RoleAdmin, schema.RoleAPI}) {
482						ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
483						onsuccess.ServeHTTP(rw, r.WithContext(ctx))
484						return
485					}
486				default:
487					cclog.Info("auth api -> authentication failed: missing role")
488					onfailure(rw, r, errors.New("unauthorized"))
489				}
490			}
491			cclog.Info("auth api -> authentication failed: no auth")
492			onfailure(rw, r, errors.New("unauthorized"))
493		})
494	}
495
496	func (auth *Authentication) AuthUserAPI(
497		onsuccess http.Handler,
498		onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
499	) http.Handler {
500		return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
501			user, err := auth.JwtAuth.AuthViaJWT(rw, r)
502			if err != nil {
503				cclog.Infof("auth user api -> authentication failed: %s", err.Error())
504				onfailure(rw, r, err)
505				return
506			}
507
508			if user != nil {
509				switch {
510				case len(user.Roles) == 1:
511					if user.HasRole(schema.RoleAPI) {
512						ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
513						onsuccess.ServeHTTP(rw, r.WithContext(ctx))
514						return
515					}
516				case len(user.Roles) >= 2:
517					if user.HasRole(schema.RoleAPI) && user.HasAnyRole([]schema.Role{schema.RoleUser, schema.RoleManager, schema.RoleSupport, schema.RoleAdmin}) {
518						ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
519						onsuccess.ServeHTTP(rw, r.WithContext(ctx))
520						return
521					}
522				default:
523					cclog.Info("auth user api -> authentication failed: missing role")
524					onfailure(rw, r, errors.New("unauthorized"))
525				}
526			}
527			cclog.Info("auth user api -> authentication failed: no auth")
528			onfailure(rw, r, errors.New("unauthorized"))
529		})
530	}
531
532	func (auth *Authentication) AuthMetricStoreAPI(
533		onsuccess http.Handler,
534		onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
535	) http.Handler {
536		return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
537			user, err := auth.JwtAuth.AuthViaJWT(rw, r)
538			if err != nil {
539				cclog.Infof("auth metricstore api -> authentication failed: %s", err.Error())
540				onfailure(rw, r, err)
541				return
542			}
543
544			if user != nil {
545				switch {
546				case len(user.Roles) == 1:
547					if user.HasRole(schema.RoleAPI) {
548						ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
549						onsuccess.ServeHTTP(rw, r.WithContext(ctx))
550						return
551					}
552				case len(user.Roles) >= 2:
553					if user.HasRole(schema.RoleAPI) && user.HasAnyRole([]schema.Role{schema.RoleUser, schema.RoleManager, schema.RoleAdmin}) {
554						ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
555						onsuccess.ServeHTTP(rw, r.WithContext(ctx))
556						return
557					}
558				default:
559					cclog.Info("auth metricstore api -> authentication failed: missing role")
560					onfailure(rw, r, errors.New("unauthorized"))
561				}
562			}
563			cclog.Info("auth metricstore api -> authentication failed: no auth")
564			onfailure(rw, r, errors.New("unauthorized"))
565		})
566	}
567
568	func (auth *Authentication) AuthConfigAPI(
569		onsuccess http.Handler,
570		onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
571	) http.Handler {
572		return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
573			user, err := auth.AuthViaSession(rw, r)
574			if err != nil {
575				cclog.Infof("auth config api -> authentication failed: %s", err.Error())
576				onfailure(rw, r, err)
577				return
578			}
579			if user != nil && user.HasRole(schema.RoleAdmin) {
580				ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
581				onsuccess.ServeHTTP(rw, r.WithContext(ctx))
582				return
583			}
584			cclog.Info("auth config api -> authentication failed: no auth")
585			onfailure(rw, r, errors.New("unauthorized"))
586		})
587	}
588
589	func (auth *Authentication) AuthFrontendAPI(
590		onsuccess http.Handler,
591		onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
592	) http.Handler {
593		return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
594			user, err := auth.AuthViaSession(rw, r)
595			if err != nil {
596				cclog.Infof("auth frontend api -> authentication failed: %s", err.Error())
597				onfailure(rw, r, err)
598				return
599			}
600			if user != nil {
601				ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
602				onsuccess.ServeHTTP(rw, r.WithContext(ctx))
603				return
604			}
605			cclog.Info("auth frontend api -> authentication failed: no auth")
606			onfailure(rw, r, errors.New("unauthorized"))
607		})
608	}
609

	})
}

func (auth *Authentication) Auth(
	onsuccess http.Handler,
	onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		user, err := auth.JwtAuth.AuthViaJWT(rw, r)
		if err != nil {
			cclog.Infof("auth -> authentication failed: %s", err.Error())
			http.Error(rw, err.Error(), http.StatusUnauthorized)
			return
		}
		if user == nil {
			user, err = auth.AuthViaSession(rw, r)
			if err != nil {
				cclog.Infof("auth -> authentication failed: %s", err.Error())
				http.Error(rw, err.Error(), http.StatusUnauthorized)
				return
			}
		}
		if user != nil {
			ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
			onsuccess.ServeHTTP(rw, r.WithContext(ctx))
			return
		}

		cclog.Infof("auth -> authentication failed: no valid session or JWT for %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
		onfailure(rw, r, errors.New("unauthorized (please login first)"))
	})
}

func (auth *Authentication) AuthAPI(
	onsuccess http.Handler,
	onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		user, err := auth.JwtAuth.AuthViaJWT(rw, r)
		if err != nil {
			cclog.Infof("auth api -> authentication failed: %s", err.Error())
			onfailure(rw, r, err)
			return
		}

		ipErr := securedCheck(user, r)
		if ipErr != nil {
			cclog.Infof("auth api -> secured check failed: %s", ipErr.Error())
			onfailure(rw, r, ipErr)
			return
		}

		if user != nil {
			switch {
			case len(user.Roles) == 1:
				if user.HasRole(schema.RoleAPI) {
					ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
					onsuccess.ServeHTTP(rw, r.WithContext(ctx))
					return
				}
			case len(user.Roles) >= 2:
				if user.HasAllRoles([]schema.Role{schema.RoleAdmin, schema.RoleAPI}) {
					ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
					onsuccess.ServeHTTP(rw, r.WithContext(ctx))
					return
				}
			default:
				cclog.Info("auth api -> authentication failed: missing role")
				onfailure(rw, r, errors.New("unauthorized"))
			}
		}
		cclog.Info("auth api -> authentication failed: no auth")
		onfailure(rw, r, errors.New("unauthorized"))
	})
}

func (auth *Authentication) AuthUserAPI(
	onsuccess http.Handler,
	onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		user, err := auth.JwtAuth.AuthViaJWT(rw, r)
		if err != nil {
			cclog.Infof("auth user api -> authentication failed: %s", err.Error())
			onfailure(rw, r, err)
			return
		}

		if user != nil {
			switch {
			case len(user.Roles) == 1:
				if user.HasRole(schema.RoleAPI) {
					ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
					onsuccess.ServeHTTP(rw, r.WithContext(ctx))
					return
				}
			case len(user.Roles) >= 2:
				if user.HasRole(schema.RoleAPI) && user.HasAnyRole([]schema.Role{schema.RoleUser, schema.RoleManager, schema.RoleSupport, schema.RoleAdmin}) {
					ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
					onsuccess.ServeHTTP(rw, r.WithContext(ctx))
					return
				}
			default:
				cclog.Info("auth user api -> authentication failed: missing role")
				onfailure(rw, r, errors.New("unauthorized"))
			}
		}
		cclog.Info("auth user api -> authentication failed: no auth")
		onfailure(rw, r, errors.New("unauthorized"))
	})
}

func (auth *Authentication) AuthMetricStoreAPI(
	onsuccess http.Handler,
	onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		user, err := auth.JwtAuth.AuthViaJWT(rw, r)
		if err != nil {
			cclog.Infof("auth metricstore api -> authentication failed: %s", err.Error())
			onfailure(rw, r, err)
			return
		}

		if user != nil {
			switch {
			case len(user.Roles) == 1:
				if user.HasRole(schema.RoleAPI) {
					ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
					onsuccess.ServeHTTP(rw, r.WithContext(ctx))
					return
				}
			case len(user.Roles) >= 2:
				if user.HasRole(schema.RoleAPI) && user.HasAnyRole([]schema.Role{schema.RoleUser, schema.RoleManager, schema.RoleAdmin}) {
					ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
					onsuccess.ServeHTTP(rw, r.WithContext(ctx))
					return
				}
			default:
				cclog.Info("auth metricstore api -> authentication failed: missing role")
				onfailure(rw, r, errors.New("unauthorized"))
			}
		}
		cclog.Info("auth metricstore api -> authentication failed: no auth")
		onfailure(rw, r, errors.New("unauthorized"))
	})
}

func (auth *Authentication) AuthConfigAPI(
	onsuccess http.Handler,
	onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		user, err := auth.AuthViaSession(rw, r)
		if err != nil {
			cclog.Infof("auth config api -> authentication failed: %s", err.Error())
			onfailure(rw, r, err)
			return
		}
		if user != nil && user.HasRole(schema.RoleAdmin) {
			ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
			onsuccess.ServeHTTP(rw, r.WithContext(ctx))
			return
		}
		cclog.Info("auth config api -> authentication failed: no auth")
		onfailure(rw, r, errors.New("unauthorized"))
	})
}

func (auth *Authentication) AuthFrontendAPI(
	onsuccess http.Handler,
	onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		user, err := auth.AuthViaSession(rw, r)
		if err != nil {
			cclog.Infof("auth frontend api -> authentication failed: %s", err.Error())
			onfailure(rw, r, err)
			return
		}
		if user != nil {
			ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
			onsuccess.ServeHTTP(rw, r.WithContext(ctx))
			return
		}
		cclog.Info("auth frontend api -> authentication failed: no auth")
		onfailure(rw, r, errors.New("unauthorized"))
	})
}


Agent

Map backend routes to auth type

Prompt:
In the Go repo /Users/jan/prg/ClusterCockpit/cc-backend, I need a COMPLETE map of every HTTP route and which authentication middleware protects it (session-based vs JWT-based).

Context: Routes are registered in /Users/jan/prg/ClusterCockpit/cc-backend/cmd/cc-backend/server.go using chi. There are several route groups each with a different auth middleware:
- secured group: uses authHandle.Auth(...)
- /api: uses authHandle.AuthAPI(...)
- /api metricstore: uses authHandle.AuthMetricStoreAPI(...)
- /userapi: uses authHandle.AuthUserAPI(...)
- config api: uses authHandle.AuthConfigAPI(...)
- /frontend: uses authHandle.AuthFrontendAPI(...)

Tasks:
1. In internal/auth/auth.go, read EACH of these middleware functions: `Auth`, `AuthApi`/`AuthAPI`, `AuthUserApi`/`AuthUserAPI`, `AuthConfigApi`/`AuthConfigAPI`, `AuthFrontendApi`/`AuthFrontendAPI`, `AuthMetricStoreApi`/`AuthMetricStoreAPI`. For each, report exactly which auth methods it tries and in what order: does it call AuthViaSession (session cookie), AuthViaJWT (Bearer/X-Auth-Token header), AuthViaOIDC, the JWT-cookie authenticator, etc. State clearly: SESSION, JWT, or BOTH (and order).

2. Enumerate the actual endpoints mounted under each group. Look in internal/api/rest.go for MountApiRoutes/MountAPIRoutes, MountUserApiRoutes, MountConfigApiRoutes, MountFrontendApiRoutes, MountMetricStoreApiRoutes (names may vary). List each route path + HTTP method + handler func name, grouped by which auth middleware applies. Also list the routes registered directly in server.go (login, logout, /query GraphQL, /search, and routerConfig.SetupRoutes — read internal/routerConfig or internal/web to enumerate those template page routes).

3. Specifically flag any endpoint that is mounted under a JWT-auth group (/api, /userapi, /frontend, config) BUT whose handler or purpose looks like it is meant for the web UI / browser.

Report exact file:line references, route paths, methods, and the SESSION/JWT/BOTH classification for each group. Be exhaustive and precise.


Map frontend HTTP calls

Prompt:
In the repo /Users/jan/prg/ClusterCockpit/cc-backend, I need a COMPLETE inventory of every HTTP request the web UI makes to the backend, so I can determine which backend endpoints are used by the browser (web UI) vs only by external clients.

The frontend is a Svelte app in web/frontend/ (source in web/frontend/src/), plus server-side Go templates in web/templates/. The GraphQL client is @urql/svelte.

Tasks:
1. Find the GraphQL endpoint URL the urql client is configured with (search web/frontend/src for createClient, urql, fetchExchange, url:, '/query'). Report file:line.

2. Find EVERY non-GraphQL HTTP call the frontend makes. Search web/frontend/src for: `fetch(`, `fetch ("`, axios, `XMLHttpRequest`, and any string literals that look like backend paths: '/api/', '/userapi/', '/frontend/', '/config/', '/jobs/', '/search', etc. For each, report the file:line, the HTTP method, the exact URL/path (including how it's built), and what it does. Pay special attention to calls to paths under /frontend/, /config/, /api/, /userapi/.

3. Search the Go server-side templates in web/templates/ for `<form action=`, `fetch(`, and hardcoded URLs/paths the browser would hit (login form, logout, etc.). Report file:line and path/method.

4. Check how the frontend authenticates its requests: does it rely on the session cookie (credentials/same-origin) or does it attach a JWT/Bearer token? Search for 'Authorization', 'Bearer', 'X-Auth-Token', 'credentials'. Report findings.

5. Produce a deduplicated list of all backend paths the web UI calls, grouped by path prefix (/query, /frontend/*, /config/*, /api/*, /userapi/*, page routes, etc.).

Report exact file:line references and the literal paths. Be exhaustive.


Can you do a thorough analysis on the endpoints used between the frontend and the backend. They should all be authenticated via the session. Only external (outside the web ui) use cases should use JWT…


Perfect! Now I have all the information I need. Let me compile a comprehensive report:

---

## COMPLETE HTTP ROUTE AND AUTHENTICATION MIDDLEWARE MAP

I have completed a thorough analysis of the cc-backend routing and authentication system. Here is the comprehensive mapping:

### AUTHENTICATION MIDDLEWARE CLASSIFICATION

**File:** `/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go`

#### 1. **Auth() - SECURED GROUP (session page routes)**
**Lines 423-451** - Tries both methods, SESSION first, then JWT (fallback):
- First: `auth.JwtAuth.AuthViaJWT(rw, r)` (JWT via X-Auth-Token or Authorization Bearer header)
- Second: `auth.AuthViaSession(rw, r)` (session cookie validation)
- **Classification: BOTH (tries JWT first, then SESSION)**

#### 2. **AuthAPI() - API ROUTES (/api)**
**Lines 453-494** - JWT only, with role checking:
- Only: `auth.JwtAuth.AuthViaJWT(rw, r)` (X-Auth-Token or Bearer header)
- Requires: RoleAPI alone, or RoleAdmin + RoleAPI together
- Also calls `securedCheck()` for IP whitelist validation
- **Classification: JWT**

#### 3. **AuthUserAPI() - USER API ROUTES (/userapi)**
**Lines 496-530** - JWT only, with role checking:
- Only: `auth.JwtAuth.AuthViaJWT(rw, r)`
- Requires: RoleAPI + at least one of {RoleUser, RoleManager, RoleSupport, RoleAdmin}
- **Classification: JWT**

#### 4. **AuthMetricStoreAPI() - METRIC STORE API ROUTES (/api/metricstore)**
**Lines 532-566** - JWT only, with role checking:
- Only: `auth.JwtAuth.AuthViaJWT(rw, r)`
- Requires: RoleAPI + at least one of {RoleUser, RoleManager, RoleAdmin}
- **Classification: JWT**

#### 5. **AuthConfigAPI() - CONFIG API ROUTES (full /config paths)**
**Lines 568-587** - Session only:
- Only: `auth.AuthViaSession(rw, r)` (session cookie)
- Requires: RoleAdmin
- **Classification: SESSION**

#### 6. **AuthFrontendAPI() - FRONTEND API ROUTES (/frontend)**
**Lines 589-608** - Session only:
- Only: `auth.AuthViaSession(rw, r)`
- **Classification: SESSION**

---

### ROUTE REGISTRATION AND ENDPOINTS

**File:** `/Users/jan/prg/ClusterCockpit/cc-backend/cmd/cc-backend/server.go`

#### PUBLIC/UNPROTECTED ROUTES (no middleware):
| Path | Method | Handler | File:Line |
|------|--------|---------|-----------|
| `/login` | GET | render login page | server.go:195 |
| `/login` | POST | `authHandle.Login()` | server.go:223 |
| `/jwt-login` | GET/POST | `authHandle.Login()` | server.go:224 |
| `/logout` | POST | `authHandle.Logout()` | server.go:226 |
| `/imprint` | GET | render imprint page | server.go:200 |
| `/privacy` | GET | render privacy page | server.go:204 |
| `/playground` | GET | GraphQL playground (dev only) | server.go:241 |
| `/swagger/*` | GET | Swagger docs (dev only) | server.go:242 |
| `/img/*` | GET | static image files | server.go:364 |
| `/*` | GET | static files (SPA, fonts, etc.) | server.go:368/378 |

#### SECURED GROUP - SESSION/JWT (both methods)
**Authentication:** `Auth()` - **SESSION** first, JWT fallback
**File:** server.go:247-273

| Path | Method | Handler | Template | Use Case |
|------|--------|---------|----------|----------|
| `/query` | POST | GraphQL handler | N/A | Main GraphQL API for SPA |
| `/search` | GET | `HandleSearchBar()` | message.tmpl | Search functionality |
| `/` | GET | home page | home.tmpl | Dashboard |
| `/config` | GET | settings page | config.tmpl | Admin settings |
| `/monitoring/jobs/` | GET | jobs list page | monitoring/jobs.tmpl | Job listing |
| `/monitoring/job/{id:[0-9]+}` | GET | job detail page | monitoring/job.tmpl | Single job view |
| `/monitoring/users/` | GET | users list page | monitoring/list.tmpl | User listing |
| `/monitoring/projects/` | GET | projects list page | monitoring/list.tmpl | Project listing |
| `/monitoring/tags/` | GET | tags page | monitoring/taglist.tmpl | Tag management |
| `/monitoring/user/{id}` | GET | user detail page | monitoring/user.tmpl | User details |
| `/monitoring/systems/{cluster}` | GET | cluster overview | monitoring/systems.tmpl | Cluster overview |
| `/monitoring/systems/list/{cluster}` | GET | cluster node list | monitoring/systems.tmpl | Node list (cluster view) |
| `/monitoring/systems/list/{cluster}/{subcluster}` | GET | subcluster node list | monitoring/systems.tmpl | Node list (subcluster) |
| `/monitoring/node/{cluster}/{hostname}` | GET | node detail page | monitoring/node.tmpl | Single node view |
| `/monitoring/analysis/{cluster}` | GET | analysis page | monitoring/analysis.tmpl | Analysis view |
| `/monitoring/status/{cluster}` | GET | cluster status page | monitoring/status.tmpl | Status dashboard |
| `/monitoring/status/detail/{cluster}` | GET | cluster detail page | monitoring/status.tmpl | Detailed status |
| `/monitoring/dashboard/{cluster}` | GET | cluster dashboard | monitoring/dashboard.tmpl | Public dashboard |
| `/monitoring/logs` | GET | logs page | monitoring/logs.tmpl | System logs |

**Source:** `/Users/jan/prg/ClusterCockpit/cc-backend/internal/routerConfig/routes.go:36-54, 474-516`

---

### API ROUTE GROUP - /api

#### Main API Routes (with JWT auth via AuthAPI())
**Authentication:** `AuthAPI()` - **JWT only**
**File:** server.go:276-285
**Mount:** rest.go:78-118

| Path | Method | Handler | Notes |
|------|--------|---------|-------|
| `/api/users/` | GET | `getUsers` | Lists all users |
| `/api/user/{id}` | POST | `updateUserByRequest` | Update user (JSON request body) |
| `/api/clusters/` | GET | `getClusters` | Lists all clusters |
| `/api/nodestate/` | POST/PUT | `updateNodeStates` | Slurm node state updates |
| `/api/jobs/start_job/` | POST/PUT | `startJob` | Start job tracking (if NATS not configured) |
| `/api/jobs/stop_job/` | POST/PUT | `stopJobByRequest` | Stop job tracking (if NATS not configured) |
| `/api/jobs/` | GET | `getJobs` | List jobs |
| `/api/jobs/used_nodes` | GET | `getUsedNodes` | Get used nodes |
| `/api/jobs/tag_job/{id}` | POST/PATCH | `tagJob` | Tag a job |
| `/api/jobs/tag_job/{id}` | DELETE | `removeTagJob` | Remove tag from job |
| `/api/jobs/edit_meta/{id}` | PATCH | `editMeta` | Edit job metadata by ID |
| `/api/jobs/edit_meta/` | PATCH | `editMetaByRequest` | Edit job metadata (JSON) |
| `/api/jobs/metrics/{id}` | GET | `getJobMetrics` | Get job metrics |
| `/api/jobs/delete_job/` | DELETE | `deleteJobByRequest` | Delete job (JSON) |
| `/api/jobs/delete_job/{id}` | DELETE | `deleteJobByID` | Delete job by ID |
| `/api/jobs/delete_job_before/{ts}` | DELETE | `deleteJobBefore` | Delete jobs before timestamp |
| `/api/jobs/{id}` | POST | `getJobByID` | Get job by ID (legacy) |
| `/api/jobs/{id}` | GET | `getCompleteJobByID` | Get full job data |
| `/api/tags/` | DELETE | `removeTags` | Remove tags |
| `/api/machine_state/{cluster}/{host}` | GET/PUT/POST | `getMachineState`/`putMachineState` | Store/retrieve machine state (if enabled) |

#### Metric Store API Routes (with separate JWT auth via AuthMetricStoreAPI())
**Authentication:** `AuthMetricStoreAPI()` - **JWT only**
**File:** server.go:288-295
**Mount:** rest.go:132-143

| Path | Method | Handler | Notes |
|------|--------|---------|-------|
| `/api/free` | POST | `freeMetrics` | Free metrics |
| `/api/free/` | POST | `freeMetrics` | Free metrics (trailing slash) |
| `/api/write` | POST | `writeMetrics` | Write metrics |
| `/api/write/` | POST | `writeMetrics` | Write metrics (trailing slash) |
| `/api/debug` | GET | `debugMetrics` | Debug metrics |
| `/api/debug/` | GET | `debugMetrics` | Debug metrics (trailing slash) |
| `/api/healthcheck` | POST | `updateNodeStates` | Health check |
| `/api/healthcheck/` | POST | `updateNodeStates` | Health check (trailing slash) |

---

### USER API ROUTE GROUP - /userapi

**Authentication:** `AuthUserAPI()` - **JWT only**
**File:** server.go:299-306
**Mount:** rest.go:122-128

| Path | Method | Handler | Notes |
|------|--------|---------|-------|
| `/userapi/jobs/` | GET | `getJobs` | List jobs (restricted view) |
| `/userapi/jobs/{id}` | POST | `getJobByID` | Get job by ID (legacy) |
| `/userapi/jobs/{id}` | GET | `getCompleteJobByID` | Get full job data |
| `/userapi/jobs/metrics/{id}` | GET | `getJobMetrics` | Get job metrics |

---

### CONFIG API ROUTE GROUP - /config/* (full paths)

**Authentication:** `AuthConfigAPI()` - **SESSION only**
**File:** server.go:310-317
**Mount:** rest.go:149-162

| Path | Method | Handler | Role Requirement | Notes |
|--------|--------|---------|------------------|-------|
| `/config/roles/` | GET | `getRoles` | Admin | Get list of valid roles |
| `/config/users/` | POST/PUT | `createUser` | Admin | Create user |
| `/config/users/` | GET | `getUsers` | Admin | List users |
| `/config/users/` | DELETE | `deleteUser` | Admin | Delete user |
| `/config/user/{id}` | POST | `updateUserByForm` | Admin | Update user (form data) |
| `/config/notice/` | POST | `editNotice` | Admin | Edit system notice |
| `/config/taggers/` | GET | `getTaggers` | Admin | List taggers |
| `/config/taggers/run/` | POST | `runTagger` | Admin | Run a tagger |

---

### FRONTEND API ROUTE GROUP - /frontend

**Authentication:** `AuthFrontendAPI()` - **SESSION only**
**File:** server.go:320-327
**Mount:** rest.go:166-173

| Path | Method | Handler | Role Requirement | Notes |
|--------|--------|---------|------------------|-------|
| `/frontend/logs/` | GET | `getJournalLog` | Any | Get system journal logs |
| `/frontend/jwt/` | GET | `getJWT` | Any | Generate JWT token for user (admins can gen for others) |
| `/frontend/configuration/` | POST | `updateConfiguration` | Any | Update user configuration |

---

### CRITICAL FINDINGS - WEB UI ON JWT-AUTH ENDPOINTS

**MISCLASSIFIED ENDPOINTS (designed for browsers but on JWT-auth groups):**

1. **`/frontend/logs/`** (rest.go:167)
   - Mounted in `/frontend` group (JWT session auth: SESSION)
   - Likely called by browser (logs viewing)
   - Authenticated via SESSION cookie only ✓

2. **`/frontend/jwt/`** (rest.go:170)
   - Mounted in `/frontend` group (JWT session auth: SESSION)
   - Called by browser to generate JWT tokens
   - Authenticated via SESSION cookie ✓
   - **Purpose:** Allows browser SPA to obtain JWT token for programmatic API access

3. **`/frontend/configuration/`** (rest.go:171)
   - Mounted in `/frontend` group (JWT session auth: SESSION)
   - Called by browser to save user preferences
   - Authenticated via SESSION cookie ✓

**These are NOT misclassified** - they are intentionally in `/frontend` group under SESSION auth because they are browser endpoints, not API endpoints for machine-to-machine communication.

---

### AUTHENTICATION METHOD SUMMARY BY GROUP

| Group | Middleware | Auth Methods | Order | JWT Allowed | Session Allowed |
|-------|-----------|--------------|-------|-------------|-----------------|
| Secured (page routes) | `Auth()` | JWT → SESSION | Fallback | ✓ | ✓ |
| `/api` | `AuthAPI()` | JWT only | Direct | ✓ | ✗ |
| `/api/metricstore` | `AuthMetricStoreAPI()` | JWT only | Direct | ✓ | ✗ |
| `/userapi` | `AuthUserAPI()` | JWT only | Direct | ✓ | ✗ |
| `/config/*` | `AuthConfigAPI()` | SESSION only | Direct | ✗ | ✓ |
| `/frontend` | `AuthFrontendAPI()` | SESSION only | Direct | ✗ | ✓ |

---

### JWT TOKEN HANDLING

**File:** `/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/jwt.go`

**AuthViaJWT() method (lines 71-119):**
- Checks for token in: `X-Auth-Token` header first, then `Authorization: Bearer` header
- Validates Ed25519/EdDSA signature using public key from `JWT_PUBLIC_KEY` env var
- Returns nil if no token present (allows fallback to session in secured routes)
- Validates token claims and expiration (if MaxAge configured)

**ProvideJWT() method (lines 122-142):**
- Creates JWT with claims: `sub` (username), `roles`, `iat` (issued-at), optionally `exp` (expiration)
- Signs with Ed25519 private key from `JWT_PRIVATE_KEY` env var
- Called by `/frontend/jwt/` endpoint for SPA to get tokens

---

### KEY OBSERVATIONS

1. **The secured group is "browser-first":** Uses session cookies primarily (set after login), but falls back to JWT for API clients
2. **All /api endpoints are machine-to-machine:** Require JWT tokens, no session support
3. **Config and Frontend APIs are browser-only:** Use session cookies exclusively (CSRF protection via SameSite=Lax)
4. **/query (GraphQL) is the main bridge:** Accessible via session from browser OR JWT from clients, called by SPA for all data
5. **IP whitelist on AuthAPI():** Config key `APIAllowedIPs` restricts which clients can use the main API
6. **No OIDC in middleware:** OIDC is handled separately via `openIDConnect.RegisterEndpoints()`

Prompt:
In the Go repo /Users/jan/prg/ClusterCockpit/cc-backend, I need a COMPLETE map of every HTTP route and which authentication middleware protects it (session-based vs JWT-based).

Context: Routes are registered in /Users/jan/prg/ClusterCockpit/cc-backend/cmd/cc-backend/server.go using chi. There are several route groups each with a different auth middleware:
- secured group: uses authHandle.Auth(...)
- /api: uses authHandle.AuthAPI(...)
- /api metricstore: uses authHandle.AuthMetricStoreAPI(...)
- /userapi: uses authHandle.AuthUserAPI(...)
- config api: uses authHandle.AuthConfigAPI(...)
- /frontend: uses authHandle.AuthFrontendAPI(...)

Tasks:
1. In internal/auth/auth.go, read EACH of these middleware functions: `Auth`, `AuthApi`/`AuthAPI`, `AuthUserApi`/`AuthUserAPI`, `AuthConfigApi`/`AuthConfigAPI`, `AuthFrontendApi`/`AuthFrontendAPI`, `AuthMetricStoreApi`/`AuthMetricStoreAPI`. For each, report exactly which auth methods it tries and in what order: does it call AuthViaSession (session cookie), AuthViaJWT (Bearer/X-Auth-Token header), AuthViaOIDC, the JWT-cookie authenticator, etc. State clearly: SESSION, JWT, or BOTH (and order).

2. Enumerate the actual endpoints mounted under each group. Look in internal/api/rest.go for MountApiRoutes/MountAPIRoutes, MountUserApiRoutes, MountConfigApiRoutes, MountFrontendApiRoutes, MountMetricStoreApiRoutes (names may vary). List each route path + HTTP method + handler func name, grouped by which auth middleware applies. Also list the routes registered directly in server.go (login, logout, /query GraphQL, /search, and routerConfig.SetupRoutes — read internal/routerConfig or internal/web to enumerate those template page routes).

3. Specifically flag any endpoint that is mounted under a JWT-auth group (/api, /userapi, /frontend, config) BUT whose handler or purpose looks like it is meant for the web UI / browser.

Report exact file:line references, route paths, methods, and the SESSION/JWT/BOTH classification for each group. Be exhaustive and precise.


Perfect! Now let me create the final comprehensive inventory. Let me compile all the findings:

## Complete Inventory of HTTP Requests from Web UI to Backend

### TASK 1: GraphQL Endpoint Configuration

**File:** `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/generic/utils.js:29-31`

GraphQL endpoint is configured as:
```javascript
const client = new Client({
    url: `${window.location.origin}/query`,
    fetchOptions: jwt != null ? { headers: { Authorization: `Bearer ${jwt}` } } : {},
```

**GraphQL Endpoint:** `POST /query`
- Uses urql library (@urql/svelte)
- Endpoint is at `${window.location.origin}/query` (same origin)
- Authentication: Optional Bearer token (JWT) in Authorization header if available

---

### TASK 2: Non-GraphQL HTTP Calls from Frontend

All fetch() calls found in the Svelte/JS frontend:

| File | Line | Method | Path | Query Params | Purpose |
|------|------|--------|------|--------------|---------|
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/Logs.root.svelte` | 101 | GET | `/frontend/logs/` | `since`, `lines`, `level`, `search` | Fetch application logs |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/config/AdminSettings.svelte` | 35 | GET | `/config/users/` | `via-ldap=false&not-just-user=true` | Get list of users for admin |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/config/AdminSettings.svelte` | 43 | GET | `/config/roles/` | (none) | Get list of available roles |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/config/UserSettings.svelte` | 38 | POST | `/frontend/configuration/` | (FormData) | Update user configuration settings |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/config/admin/RunTaggers.svelte` | 26 | GET | `/config/taggers/` | (none) | List available taggers |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/config/admin/RunTaggers.svelte` | 40 | POST | `/config/taggers/run/` | (FormData: name) | Run a specific tagger |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/config/admin/ShowUsers.svelte` | 30 | DELETE | `/config/users/` | (FormData: username) | Delete a user |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/config/admin/EditProject.svelte` | 38 | POST | `/config/user/{username}` | (FormData: add-project) | Add project to user |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/config/admin/EditProject.svelte` | 71 | POST | `/config/user/{username}` | (FormData: remove-project) | Remove project from user |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/config/admin/EditRole.svelte` | 40 | POST | `/config/user/{username}` | (FormData: add-role) | Add role to user |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/config/admin/EditRole.svelte` | 73 | POST | `/config/user/{username}` | (FormData: remove-role) | Remove role from user |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/config/admin/AddUser.svelte` | 31 | POST | `/config/users/` | (FormData) | Create new user |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/config/admin/NoticeEdit.svelte` | 30 | POST | `/config/notice/` | (FormData: new-content) | Update notice content |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/config/support/SupportOptions.svelte` | 28 | POST | `/frontend/configuration/` | (FormData) | Update support options |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/config/user/UserOptions.svelte` | 87 | POST | `/frontend/configuration/` | (FormData: key, value) | Update job list paging config |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/config/user/PlotRenderOptions.svelte` | 37 | POST | `/frontend/configuration/` | (FormData: key, value) | Update line width config |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/config/user/PlotRenderOptions.svelte` | 85 | POST | `/frontend/configuration/` | (FormData: key, value) | Update plots per row config |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/config/user/PlotRenderOptions.svelte` | 133 | POST | `/frontend/configuration/` | (FormData: key, value) | Update background colors config |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/config/user/PlotRenderOptions.svelte` | 185 | POST | `/frontend/configuration/` | (FormData: key, value) | Update plot hover info config |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/config/user/PlotColorScheme.svelte` | 331 | POST | `/frontend/configuration/` | (FormData: key, value) | Update color scheme config |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/generic/utils.js` | 583 | GET | `/frontend/jwt/` | `username={username}` | Get JWT token for user |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/header/NavbarTools.svelte` | 41,59,170 | GET | `/search` | `searchId={query}` | Search functionality |
| `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/header/NavbarTools.svelte` | 106,154 | POST | `/logout` | (none) | User logout |

---

### TASK 3: Go Template Form Submissions

**File:** `/Users/jan/prg/ClusterCockpit/cc-backend/web/templates/login.tmpl:31`

| File | Line | Method | Path | Purpose |
|------|------|--------|------|---------|
| login.tmpl | 31 | POST | `/login` | User login form submission |

---

### TASK 4: Authentication Method

**Authentication Approach:**

1. **GraphQL Requests (`/query`):**
   - Uses optional JWT Bearer token in Authorization header
   - Configuration at `/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/generic/utils.js:31`
   - `Authorization: Bearer ${jwt}` (only if JWT exists)

2. **Non-GraphQL Requests:**
   - Use **session cookies** (credentials: same-origin by default in fetch API)
   - No explicit Authorization header is set in fetch calls
   - Form submissions rely on browser's automatic cookie handling
   - JWT is only used for GraphQL queries

3. **JWT Token Source:**
   - Obtained via `/frontend/jwt/?username={username}` endpoint
   - Only used for GraphQL client initialization
   - Users with API authority can generate/copy JWT for API access

---

### TASK 5: Deduplicated List of All Backend Paths Called by Web UI

#### By Path Prefix:

**`/query` (GraphQL)**
- `POST /query` - GraphQL endpoint (primary data fetching)

**`/frontend/*`**
- `GET /frontend/logs/?since=&lines=&level=&search=` - Fetch logs
- `POST /frontend/configuration/` - Update user configuration/preferences (key, value pairs)
- `GET /frontend/jwt/?username=` - Get JWT token for user

**`/config/*`**
- `GET /config/users/?via-ldap=false&not-just-user=true` - List users
- `GET /config/roles/` - List roles
- `POST /config/users/` - Create user
- `DELETE /config/users/` - Delete user
- `POST /config/user/{username}` - Modify user (add/remove role, add/remove project)
- `GET /config/taggers/` - List taggers
- `POST /config/taggers/run/` - Run tagger
- `POST /config/notice/` - Update notice content

**`/monitoring/*` (Page Navigation Links - NOT HTTP requests, client-side routing)**
- `/monitoring/jobs/` - Jobs list page
- `/monitoring/job/{jobId}` - Single job detail page
- `/monitoring/user/{username}` - User detail page
- `/monitoring/users/` - Users list page
- `/monitoring/projects/` - Projects list page
- `/monitoring/tags/` - Tags list page
- `/monitoring/node/{cluster}/{hostname}` - Node detail page
- `/monitoring/systems/` - Systems/nodes overview page
- `/monitoring/analysis/` - Analysis page
- `/monitoring/status/` - Status/dashboard page
- `/monitoring/dashboard/{cluster}` - Cluster dashboard
- `/monitoring/logs` - Logs page

**Authentication & Session**
- `POST /login` - Login form
- `POST /logout` - Logout
- `GET /oidc-login` - OpenID Connect login (referenced in template)

**Other Page Routes**
- `GET /` - Home page
- `GET /config` - Configuration page
- `GET /search` - Search results page
- `POST /search` - Search submission

---

## Summary Table: Complete Backend Endpoint Inventory

| Endpoint | Method | Used By | Auth Type |
|----------|--------|---------|-----------|
| `/query` | POST | GraphQL (@urql/svelte) | Bearer JWT (optional) |
| `/login` | POST | Form (login.tmpl) | Cookie (session) |
| `/logout` | POST | Form (NavbarTools.svelte) | Cookie (session) |
| `/search` | GET/POST | Form (NavbarTools.svelte) | Cookie (session) |
| `/frontend/logs/` | GET | fetch() (Logs.root.svelte) | Cookie (session) |
| `/frontend/jwt/` | GET | fetch() (utils.js) | Cookie (session) |
| `/frontend/configuration/` | POST | fetch() (multiple config components) | Cookie (session) |
| `/config/users/` | GET, POST, DELETE | fetch() (AdminSettings, AddUser, ShowUsers) | Cookie (session) |
| `/config/roles/` | GET | fetch() (AdminSettings) | Cookie (session) |
| `/config/user/{username}` | POST | fetch() (EditRole, EditProject) | Cookie (session) |
| `/config/taggers/` | GET, POST | fetch() (RunTaggers) | Cookie (session) |
| `/config/notice/` | POST | fetch() (NoticeEdit) | Cookie (session) |

**Total Unique Backend Endpoints Called by Web UI: 12 (excluding `/monitoring/*` page routes)**

Prompt:
In the repo /Users/jan/prg/ClusterCockpit/cc-backend, I need a COMPLETE inventory of every HTTP request the web UI makes to the backend, so I can determine which backend endpoints are used by the browser (web UI) vs only by external clients.

The frontend is a Svelte app in web/frontend/ (source in web/frontend/src/), plus server-side Go templates in web/templates/. The GraphQL client is @urql/svelte.

Tasks:
1. Find the GraphQL endpoint URL the urql client is configured with (search web/frontend/src for createClient, urql, fetchExchange, url:, '/query'). Report file:line.

2. Find EVERY non-GraphQL HTTP call the frontend makes. Search web/frontend/src for: `fetch(`, `fetch ("`, axios, `XMLHttpRequest`, and any string literals that look like backend paths: '/api/', '/userapi/', '/frontend/', '/config/', '/jobs/', '/search', etc. For each, report the file:line, the HTTP method, the exact URL/path (including how it's built), and what it does. Pay special attention to calls to paths under /frontend/, /config/, /api/, /userapi/.

3. Search the Go server-side templates in web/templates/ for `<form action=`, `fetch(`, and hardcoded URLs/paths the browser would hit (login form, logout, etc.). Report file:line and path/method.

4. Check how the frontend authenticates its requests: does it rely on the session cookie (credentials/same-origin) or does it attach a JWT/Bearer token? Search for 'Authorization', 'Bearer', 'X-Auth-Token', 'credentials'. Report findings.

5. Produce a deduplicated list of all backend paths the web UI calls, grouped by path prefix (/query, /frontend/*, /config/*, /api/*, /userapi/*, page routes, etc.).

Report exact file:line references and the literal paths. Be exhaustive.


Let me confirm one critical detail — whether the web UI's GraphQL client actually authenticates with the session cookie or with a JWT, since that determines whether the frontend conforms to your desired model.


20	 * - Adds 'getMetricConfig' to the context, a function that takes a cluster, subCluster and metric name and returns the MetricConfig (or undefined)
21	 * - Adds 'getHardwareTopology' to the context, a function that takes a cluster nad subCluster and returns the subCluster topology (or undefined)
22	 */
23	export function init(extraInitQuery = "") {
24	    const jwt = hasContext("jwt")
25	        ? getContext("jwt")
26	        : getContext("cc-config")["jwt"];
27
28	    const client = new Client({
29	        url: `${window.location.origin}/query`,
30	        fetchOptions:
31	            jwt != null ? { headers: { Authorization: `Bearer ${jwt}` } } : {},
32	        exchanges: [
33	            expiringCacheExchange({
34	                ttl: 5 * 60 * 1000,
35	                maxSize: 150,
36	            }),
37	            fetchExchange,
38	        ],
39	    });
40
41	    setContextClient(client);
42
43	    const query = client
44	        .query(
45	            `query {
46	        clusters {
47	            name
48	            partitions
49	            subClusters {
50	                name
51	                nodes
52	                numberOfNodes
53	                processorType
54	                socketsPerNode
55	                coresPerSocket
56	                threadsPerCore
57	                flopRateScalar { unit { base, prefix }, value }
58	                flopRateSimd { unit { base, prefix }, value }
59	                memoryBandwidth { unit { base, prefix }, value }
60	                topology {
61	                    node
62	                    socket
63	                    memoryDomain
64	                    core

 * - Adds 'getMetricConfig' to the context, a function that takes a cluster, subCluster and metric name and returns the MetricConfig (or undefined)
 * - Adds 'getHardwareTopology' to the context, a function that takes a cluster nad subCluster and returns the subCluster topology (or undefined)
 */
export function init(extraInitQuery = "") {
    const jwt = hasContext("jwt")
        ? getContext("jwt")
        : getContext("cc-config")["jwt"];

    const client = new Client({
        url: `${window.location.origin}/query`,
        fetchOptions:
            jwt != null ? { headers: { Authorization: `Bearer ${jwt}` } } : {},
        exchanges: [
            expiringCacheExchange({
                ttl: 5 * 60 * 1000,
                maxSize: 150,
            }),
            fetchExchange,
        ],
    });

    setContextClient(client);

    const query = client
        .query(
            `query {
        clusters {
            name
            partitions
            subClusters {
                name
                nodes
                numberOfNodes
                processorType
                socketsPerNode
                coresPerSocket
                threadsPerCore
                flopRateScalar { unit { base, prefix }, value }
                flopRateSimd { unit { base, prefix }, value }
                memoryBandwidth { unit { base, prefix }, value }
                topology {
                    node
                    socket
                    memoryDomain
                    core


Bash

grep -rn "jwt\|JWT\|getContext\|setContext\|Authorization" web/frontend/src/generic/utils.js | head -30; echo "---- where initClient is called / jwt passed ----"; grep -rn "initClient\|getJWT\|jwt" web/frontend/src/*.svelte web/frontend/src/**/*.svelte 2>/dev/null | head -30

Trace how frontend obtains/uses JWT for GraphQL


web/frontend/src/generic/utils.js:4:    setContextClient,
web/frontend/src/generic/utils.js:7:import { setContext, getContext, hasContext, onDestroy, tick } from "svelte";
web/frontend/src/generic/utils.js:24:    const jwt = hasContext("jwt")
web/frontend/src/generic/utils.js:25:        ? getContext("jwt")
web/frontend/src/generic/utils.js:26:        : getContext("cc-config")["jwt"];
web/frontend/src/generic/utils.js:31:            jwt != null ? { headers: { Authorization: `Bearer ${jwt}` } } : {},
web/frontend/src/generic/utils.js:41:    setContextClient(client);
web/frontend/src/generic/utils.js:109:    setContext("tags", tags);
web/frontend/src/generic/utils.js:110:    setContext("clusters", clusters);
web/frontend/src/generic/utils.js:111:    setContext("globalMetrics", globalMetrics);
web/frontend/src/generic/utils.js:112:    setContext("getMetricConfig", (cluster, subCluster, metric) => {
web/frontend/src/generic/utils.js:121:    setContext("getHardwareTopology", (cluster, subCluster) => {
web/frontend/src/generic/utils.js:130:    setContext("on-init", (callback) =>
web/frontend/src/generic/utils.js:133:    setContext(
web/frontend/src/generic/utils.js:339:    const globalMetrics = getContext("globalMetrics")
web/frontend/src/generic/utils.js:397:        const topol = getContext("getHardwareTopology")(job.cluster, job.subCluster)
web/frontend/src/generic/utils.js:409:    const clusters = getContext("clusters");
web/frontend/src/generic/utils.js:583:    const raw = await fetch(`/frontend/jwt/?username=${username}`);
---- where initClient is called / jwt passed ----
web/frontend/src/config/admin/ShowUsersRow.svelte:19:  let jwt = $state("");
web/frontend/src/config/admin/ShowUsersRow.svelte:25:        jwt = content
web/frontend/src/config/admin/ShowUsersRow.svelte:38:  {#if !jwt}
web/frontend/src/config/admin/ShowUsersRow.svelte:43:    <textarea rows="3" cols="20">{jwt}</textarea>
web/frontend/src/config/user/UserOptions.svelte:36:  let jwt = $state("");
web/frontend/src/config/user/UserOptions.svelte:44:        jwt = content
web/frontend/src/config/user/UserOptions.svelte:56:        .writeText(jwt)
web/frontend/src/config/user/UserOptions.svelte:61:      textArea.value = jwt;
web/frontend/src/config/user/UserOptions.svelte:137:          {#if jwt}
web/frontend/src/config/user/UserOptions.svelte:166:          <textarea cols="32" rows="5" readonly>{jwt ? jwt : 'Press "Gen. JWT" to request token ...'}</textarea>


echo "==== templates: jwt / cc-config injection ===="; grep -rn "jwt\|JWT\|cc-config\|ccconfig\|setContext" web/templates/ web/frontend/src/*.js web/frontend/rollup.config.mjs 2>/dev/null | head -30; echo; echo "==== Go side: what fills the template JWT field ===="; grep -rn "JWT\|jwt" internal/web/*.go internal/routerConfig/*.go 2>/dev/null | head -20

Find where template injects JWT into SPA config


==== templates: jwt / cc-config injection ====
web/frontend/src/analysis.entrypoint.js:14:            ['cc-config', clusterCockpitConfig]
web/frontend/src/config.entrypoint.js:16:            ['cc-config', clusterCockpitConfig],
web/frontend/src/dashpublic.entrypoint.js:11:            ['cc-config', clusterCockpitConfig]
web/frontend/src/job.entrypoint.js:14:            ['cc-config', clusterCockpitConfig],
web/frontend/src/node.entrypoint.js:14:            ['cc-config', clusterCockpitConfig]
web/frontend/src/jobs.entrypoint.js:13:            ['cc-config', clusterCockpitConfig],
web/frontend/src/list.entrypoint.js:12:            ['cc-config', clusterCockpitConfig]
web/frontend/src/user.entrypoint.js:12:            ['cc-config', clusterCockpitConfig],
web/frontend/src/systems.entrypoint.js:15:            ['cc-config', clusterCockpitConfig],
web/frontend/src/status.entrypoint.js:12:            ['cc-config', clusterCockpitConfig]
web/frontend/src/tags.entrypoint.js:13:        ['cc-config', clusterCockpitConfig]

==== Go side: what fills the template JWT field ====
(eval):1: no matches found: internal/web/*.go


echo "==== clusterCockpitConfig global ===="; grep -rn "clusterCockpitConfig" web/templates/ web/frontend/src/ 2>/dev/null | grep -iv "entrypoint" | head; echo; echo "==== JWT in web package (Go) ===="; grep -rln "JWT\|jwt" web/ --include=*.go; grep -rn "JWT\|jwt\|Config " web/web.go 2>/dev/null | head -20; echo "==== template config object ===="; grep -rn "jwt\|JWT\|config" web/templates/base.tmpl web/templates/*.tmpl 2>/dev/null | grep -i "jwt\|clusterCockpitConfig\|var config\|JSON" | head

Locate JWT injection into SPA global config


Error: Exit code 1
==== clusterCockpitConfig global ====
web/templates/config.tmpl:15:        const clusterCockpitConfig = {{ .Config }};
web/templates/monitoring/list.tmpl:12:        const clusterCockpitConfig = {{ .Config }};
web/templates/monitoring/job.tmpl:13:        const clusterCockpitConfig = {{ .Config }};
web/templates/monitoring/systems.tmpl:12:        const clusterCockpitConfig = {{ .Config }};
web/templates/monitoring/user.tmpl:12:        const clusterCockpitConfig = {{ .Config }};
web/templates/monitoring/dashboard.tmpl:11:        const clusterCockpitConfig = {{ .Config }};
web/templates/monitoring/node.tmpl:11:        const clusterCockpitConfig = {{ .Config }};
web/templates/monitoring/analysis.tmpl:12:        const clusterCockpitConfig = {{ .Config }};
web/templates/monitoring/jobs.tmpl:12:        const clusterCockpitConfig = {{ .Config }};
web/templates/monitoring/status.tmpl:12:        const clusterCockpitConfig = {{ .Config }};

==== JWT in web package (Go) ====
(eval):1: no matches found: --include=*.go


echo "==== web.go and Page/Config struct ===="; find web -name "*.go" | head; grep -rn "Config\b\|jwt\|JWT\|\"jwt\"" web/web.go 2>/dev/null | head -20; echo "==== where Page.Config is populated with jwt ===="; grep -rn "\"jwt\"\|Config\[\|Config:\|\.Config" internal/routerConfig/routes.go | head -30

Find Config struct and jwt field population


==== web.go and Page/Config struct ====
web/webConfig_test.go
web/web.go
web/configSchema.go
web/web.go:24:type WebConfig struct {
web/web.go:25:	JobList           JobListConfig     `json:"job-list"`
web/web.go:26:	NodeList          NodeListConfig    `json:"node-list"`
web/web.go:27:	JobView           JobViewConfig     `json:"job-view"`
web/web.go:28:	MetricConfig      MetricConfig      `json:"metric-config"`
web/web.go:32:type JobListConfig struct {
web/web.go:37:type NodeListConfig struct {
web/web.go:41:type JobViewConfig struct {
web/web.go:48:type MetricConfig struct {
web/web.go:52:	Clusters            []ClusterConfig `json:"clusters"`
web/web.go:55:type ClusterConfig struct {
web/web.go:60:	SubClusters         []SubClusterConfig `json:"sub-clusters"`
web/web.go:63:type SubClusterConfig struct {
web/web.go:77:var UIDefaults = WebConfig{
web/web.go:78:	JobList: JobListConfig{
web/web.go:82:	NodeList: NodeListConfig{
web/web.go:85:	JobView: JobViewConfig{
web/web.go:91:	MetricConfig: MetricConfig{
web/web.go:118:func Init(rawConfig json.RawMessage) error {
web/web.go:121:	if rawConfig != nil {
==== where Page.Config is populated with jwt ====
internal/routerConfig/routes.go:504:				Config:     conf,


grep -rn "\"jwt\"\|jwt\|JWT\|conf\b\|Config map\|map\[string\]" internal/routerConfig/routes.go | head -30

Find jwt in routes Config map


internal/routerConfig/routes.go:26:type InfoType map[string]any
internal/routerConfig/routes.go:196:	tagMap := make(map[string][]map[string]any)
internal/routerConfig/routes.go:207:			tagItem := map[string]any{
internal/routerConfig/routes.go:219:				tagItem := map[string]any{
internal/routerConfig/routes.go:235:func buildFilterPresets(query url.Values) map[string]any {
internal/routerConfig/routes.go:236:	filterPresets := map[string]any{}
internal/routerConfig/routes.go:289:					filterPresets["duration"] = map[string]int{"lessThan": lt, "from": 0, "to": 0}
internal/routerConfig/routes.go:294:					filterPresets["duration"] = map[string]int{"moreThan": mt, "from": 0, "to": 0}
internal/routerConfig/routes.go:300:					filterPresets["duration"] = map[string]int{"from": a, "to": b}
internal/routerConfig/routes.go:314:					filterPresets["numNodes"] = map[string]int{"from": 0, "to": lt}
internal/routerConfig/routes.go:319:					filterPresets["numNodes"] = map[string]int{"from": mt, "to": 0}
internal/routerConfig/routes.go:325:					filterPresets["numNodes"] = map[string]int{"from": a, "to": b}
internal/routerConfig/routes.go:336:					filterPresets["numHWThreads"] = map[string]int{"from": 0, "to": lt}
internal/routerConfig/routes.go:341:					filterPresets["numHWThreads"] = map[string]int{"from": mt, "to": 0}
internal/routerConfig/routes.go:347:					filterPresets["numHWThreads"] = map[string]int{"from": a, "to": b}
internal/routerConfig/routes.go:358:					filterPresets["numAccelerators"] = map[string]int{"from": 0, "to": lt}
internal/routerConfig/routes.go:363:					filterPresets["numAccelerators"] = map[string]int{"from": mt, "to": 0}
internal/routerConfig/routes.go:369:					filterPresets["numAccelerators"] = map[string]int{"from": a, "to": b}
internal/routerConfig/routes.go:397:				filterPresets["startTime"] = map[string]string{
internal/routerConfig/routes.go:403:			filterPresets["startTime"] = map[string]string{
internal/routerConfig/routes.go:414:					filterPresets["energy"] = map[string]int{"from": 0, "to": lt}
internal/routerConfig/routes.go:419:					filterPresets["energy"] = map[string]int{"from": mt, "to": 0}
internal/routerConfig/routes.go:425:					filterPresets["energy"] = map[string]int{"from": a, "to": b}
internal/routerConfig/routes.go:431:		statList := make([]map[string]any, 0)
internal/routerConfig/routes.go:438:						statEntry := map[string]any{
internal/routerConfig/routes.go:448:						statEntry := map[string]any{
internal/routerConfig/routes.go:459:						statEntry := map[string]any{
internal/routerConfig/routes.go:478:			conf, err := userCfgRepo.GetUIConfig(repository.GetUserFromContext(r.Context()))
internal/routerConfig/routes.go:485:			infos := route.Setup(map[string]any{}, r)
internal/routerConfig/routes.go:504:				Config:     conf,


grep -rn "\"jwt\"\|jwt\|JWT" internal/repository/userConfig.go internal/config/*.go 2>/dev/null | head; echo "==== GetUIConfig def ===="; grep -rln "func.*GetUIConfig" internal/; echo "==== any place setting jwt into UI config / context 'jwt' ===="; grep -rn "setContext(\"jwt\"\|'jwt'\|\"jwt\"" web/frontend/src/ | head

Confirm whether UI config carries a JWT


==== GetUIConfig def ====
internal/repository/userConfig_test.go
internal/repository/userConfig.go
==== any place setting jwt into UI config / context 'jwt' ====
web/frontend/src/generic/utils.js:24:    const jwt = hasContext("jwt")
web/frontend/src/generic/utils.js:25:        ? getContext("jwt")
web/frontend/src/generic/utils.js:26:        : getContext("cc-config")["jwt"];


I now have the full picture, including the decisive detail. Here is the thorough analysis.

## Frontend ↔ Backend endpoint analysis

### How the web UI authenticates
- **Non-GraphQL `fetch()` calls** (config, frontend, search, logout) send no `Authorization` header → they ride the **session cookie** (same-origin).
- **GraphQL `/query`** (urql client, `utils.js:28`): it *would* attach `Authorization: Bearer <jwt>` **only if** a `jwt` value exists in the `cc-config` context (`utils.js:24-26`). I traced where that comes from: `cc-config` is the Go-injected `{{ .Config }}` = the user's UI config (`GetUIConfig`), and **nothing anywhere sets a `"jwt"` key** in it, and there is no `setContext("jwt", …)` in the frontend. So `jwt` is always `null` → **the web UI's GraphQL calls also use the session cookie.** The Bearer-token branch is **dormant/dead code.**

### Endpoint classification (what's actually wired today)

| Route group | Middleware | Auth | Used by | Conforms? |
|---|---|---|---|---|
| `/query` (GraphQL) | `Auth()` | session, JWT fallback | **Web UI** (session) + external clients (JWT) | ✅ |
| `/search`, `/`, `/config`, `/monitoring/*` pages | `Auth()` | session (JWT fallback) | Web UI | ✅ |
| `/frontend/*` (logs, jwt, configuration) | `AuthFrontendAPI()` | **session only** | Web UI | ✅ |
| `/config/*` (admin: users, roles, taggers, notice) | `AuthConfigAPI()` | **session only** | Web UI (admin) | ✅ |
| `/login`, `/jwt-login`, `/logout` | public / session write | session | Web UI | ✅ |
| `/api/*` (jobs, users, clusters, nodestate…) | `AuthAPI()` | **JWT only** | External | ✅ |
| `/api/metricstore` (write/free/debug/healthcheck) | `AuthMetricStoreAPI()` | **JWT only** | External (cc-metric-store etc.) | ✅ |
| `/userapi/*` (jobs, metrics) | `AuthUserAPI()` | **JWT only** | External | ✅ |

### Verdict
**The codebase already implements your desired model.** Web-UI traffic is session-authenticated; only the external machine-to-machine APIs (`/api`, `/userapi`, `/api/metricstore`) require JWT. The one purpose-built bridge is `/frontend/jwt/` — a **session-authenticated** endpoint that mints a JWT for a logged-in user to copy and use against the *external* JWT APIs (the "Gen. JWT" button in `UserOptions.svelte`). That's exactly the right boundary.

Two things stand out, neither breaking the model:
1. **`/query` is dual-auth** (session *and* JWT). This is correct and intentional — the UI uses the session, external GraphQL clients use JWT. It fits "external uses JWT."
2. **Dead Bearer code in `utils.js:24-31`** — inert today. Could be removed for clarity, but harmless.

### Why this matters for the scs migration (LoadAndSave scoping)
The session middleware must wrap **exactly the session-authenticated routes**, and must **not** wrap the JWT-only groups — which is also where the large metric/job payloads live (so buffering stays off the hot path):

- **Wrap with `LoadAndSave`:** secured group (`/query`, `/search`, pages), `/config/*` group, `/frontend/*` group, and the `/login` `/jwt-login` `/logout` handlers.
- **Do NOT wrap:** `/api/*`, `/api/metricstore`, `/userapi/*`, static files (`/*`, `/img/*`), `/imprint`, `/privacy`.

The only large session-route that `LoadAndSave` will buffer is GraphQL `/query`. That's the residual cost of the LoadAndSave approach (the JWT data APIs are excluded, so the heaviest payloads are unaffected).

Let me record this in the plan and confirm one design choice with you.


AskUserQuestion

Question: GraphQL /query is currently dual-auth: the web UI uses the session cookie, external clients use a Bearer JWT. Under your 'frontend=session, external=JWT' model, how should /query stay configured?

Choices:
Leave Auth() trying JWT then session. Web UI keeps using the session; external GraphQL clients keep using JWT. Matches your model and is the least risky. Recommended.
Auth(): try JWT -> fallback session
// UI: session cookie
// external GraphQL client: Bearer JWT
// no code change to /query
Make /query reject JWT and require a session. Breaks any external client that queries GraphQL via JWT today. Only choose if you want GraphQL to be strictly browser-only.
Auth(): session ONLY
// external GraphQL clients via JWT BREAK
// must move them to /api or /userapi

Question: The frontend's GraphQL client has a dormant Bearer-JWT branch (utils.js:24-31) that never activates because no jwt is injected. Touch it as part of this work?

Choices:
Keep utils.js untouched. The session migration is backend-only; the dead branch is harmless. Recommended to keep this PR focused.
// utils.js unchanged
// dormant Bearer branch stays (inert)
Simplify the urql client to always use the session cookie (drop the jwt/Authorization logic). Small frontend cleanup, requires a frontend rebuild.
const client = new Client({
  url: `${origin}/query`,
  exchanges: [...]
}); // no Authorization header


Your questions have been answered: "GraphQL /query is currently dual-auth: the web UI uses the session cookie, external clients use a Bearer JWT. Under your 'frontend=session, external=JWT' model, how should /query stay configured?"="Keep dual-auth (session + JWT)" selected preview:
Auth(): try JWT -> fallback session
// UI: session cookie
// external GraphQL client: Bearer JWT
// no code change to /query, "The frontend's GraphQL client has a dormant Bearer-JWT branch (utils.js:24-31) that never activates because no jwt is injected. Touch it as part of this work?"="Remove the dead branch" selected preview:
const client = new Client({
  url: `${origin}/query`,
  exchanges: [...]
}); // no Authorization header. You can now continue with these answers in mind.

Question: GraphQL /query is currently dual-auth: the web UI uses the session cookie, external clients use a Bearer JWT. Under your 'frontend=session, external=JWT' model, how should /query stay configured?

Choices:
Leave Auth() trying JWT then session. Web UI keeps using the session; external GraphQL clients keep using JWT. Matches your model and is the least risky. Recommended.
Auth(): try JWT -> fallback session
// UI: session cookie
// external GraphQL client: Bearer JWT
// no code change to /query
Make /query reject JWT and require a session. Breaks any external client that queries GraphQL via JWT today. Only choose if you want GraphQL to be strictly browser-only.
Auth(): session ONLY
// external GraphQL clients via JWT BREAK
// must move them to /api or /userapi

Question: The frontend's GraphQL client has a dormant Bearer-JWT branch (utils.js:24-31) that never activates because no jwt is injected. Touch it as part of this work?

Choices:
Keep utils.js untouched. The session migration is backend-only; the dead branch is harmless. Recommended to keep this PR focused.
// utils.js unchanged
// dormant Bearer branch stays (inert)
Simplify the urql client to always use the session cookie (drop the jwt/Authorization logic). Small frontend cleanup, requires a frontend rebuild.
const client = new Client({
  url: `${origin}/query`,
  exchanges: [...]
}); // no Authorization header


Both decisions recorded: keep `/query` dual-auth (no backend change there), and remove the dead Bearer branch in `utils.js` (keeping the separate user-facing "Gen. JWT" feature at `utils.js:583`). Let me rewrite the plan to the `LoadAndSave` approach with the analysis-derived middleware scoping.