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`, backed by the existing SQLite DB.

The packages differ architecturally:
- **gorilla `CookieStore`** is *stateless* — all data is encrypted into the cookie
  (signed with `SESSION_KEY`); sessions survive restarts while the key is stable.
- **scs/v2** is *server-side* — only a random token lives in the cookie; data lives
  in a store. No encryption key needed.

**Decisions made:**
- **Store: `sqlite3store`** in the existing SQLite DB (preserves "stay logged in
  across restarts"). Adds dep `github.com/alexedwards/scs/sqlite3store`.
- **Remove `SESSION_KEY`** — dead code with server-side tokens.
- **Use scs's idiomatic `LoadAndSave` middleware** (the clean approach), scoped to
  only the session-authenticated route groups (see endpoint analysis below).
- **`/query` stays dual-auth** (session for the web UI, JWT for external GraphQL
  clients) — no change to `Auth()`.
- **Frontend cleanup:** remove the dormant Bearer-JWT branch in the urql client.

### Endpoint analysis (frontend=session, external=JWT)

The codebase already matches this model. Verified: the web UI's GraphQL client only
attaches `Authorization: Bearer` if a `jwt` exists in the `cc-config` context
(`web/frontend/src/generic/utils.js:24-31`), but nothing ever injects one — so the
UI authenticates `/query` via the **session cookie**; the Bearer branch is dead.

| Route group | Auth today | Caller | LoadAndSave? |
|---|---|---|---|
| `/query`, `/search`, pages `/`,`/config`,`/monitoring/*` (`Auth()`) | session (+JWT fallback) | web UI (+ external GraphQL) | **YES** |
| `/frontend/*` logs,jwt,configuration (`AuthFrontendAPI`) | session only | web UI | **YES** |
| `/config/*` admin (`AuthConfigAPI`) | session only | web UI (admin) | **YES** |
| `/login`, `/jwt-login`, `/logout` | session write | web UI | **YES** |
| `/api/*` (`AuthAPI`) | JWT only | external | **NO** |
| `/api/metricstore` (`AuthMetricStoreAPI`) | JWT only | external | **NO** |
| `/userapi/*` (`AuthUserAPI`) | JWT only | external | **NO** |
| static `/*`, `/img/*`, `/imprint`, `/privacy` | none | browser | **NO** |

`LoadAndSave` buffers the full response to inject the cookie. Scoping it to the
session groups keeps it **off the JWT data APIs** (`/api`, `/userapi`,
`/api/metricstore`) where the large metric/job payloads live. The only large
session-route it buffers is GraphQL `/query` — the accepted residual cost.
`/frontend/jwt/` (mints a JWT for a logged-in user to use against the external JWT
APIs) is the intended session→JWT bridge and stays as-is.

## Changes

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

### 2. New DB migration — `sessions` table
`internal/repository/migrations/sqlite3/12_add-sessions-table.{up,down}.sql`:
```sql
-- up
CREATE TABLE sessions (
    token  TEXT PRIMARY KEY,
    data   BLOB NOT NULL,
    expiry REAL NOT NULL
);
CREATE INDEX sessions_expiry_idx ON sessions(expiry);
-- down
DROP TABLE IF EXISTS sessions;
```
Bump `Version` to `12` in `internal/repository/migration.go:28` + add a history
comment. `Connect()` only *checks* the version, so existing installs run
`-migrate-db`; `-init-db` (`cmd/cc-backend/init.go:83`) already migrates fresh DBs.

### 3. `internal/auth/auth.go` — the core swap
Replace the gorilla import with `github.com/alexedwards/scs/v2`,
`github.com/alexedwards/scs/sqlite3store`, `encoding/gob`,
`.../internal/repository`. Drop now-unused `crypto/rand`, `encoding/base64`, `os`
(verify before removing).

- **Struct** (line 121): `sessionStore *sessions.CookieStore` → `sessionManager *scs.SessionManager`.
- **Add accessor** so the router can install the middleware:
  `func (auth *Authentication) SessionManager() *scs.SessionManager { return auth.sessionManager }`
- **`Init()`** (lines 189–213): delete the whole `SESSION_KEY` block; replace with:
  ```go
  gob.Register([]string{}) // user.Projects / user.Roles are []string in session values
  sm := scs.New()
  sm.Store = sqlite3store.New(repository.GetConnection().DB.DB)
  sm.Cookie.Name = "session"
  sm.Cookie.Path = "/"
  sm.Cookie.HttpOnly = true
  sm.Cookie.SameSite = http.SameSiteLaxMode
  sm.Cookie.Secure = config.Keys.HTTPSCertFile != "" // see note
  if d, err := time.ParseDuration(config.Keys.SessionMaxAge); err == nil && d != 0 {
      sm.Lifetime = d
      authInstance.SessionMaxAge = d
  } // else keep a long default lifetime (config says 0 == "no expiry")
  authInstance.sessionManager = sm
  ```
  > Note on `Secure`: scs sets the flag globally (no per-request option like the old
  > code). Deriving it from `HTTPSCertFile` is actually *stricter* than today — the
  > current code only ever sets `Secure=false` and never `true` (gorilla's default is
  > `false`), so the flag is effectively never set now. Reverse-proxy-TLS deployments
  > that want `Secure` can get a follow-up config flag.
- **`SaveSession(rw, r, user)`** (lines 321–354): the login routes are wrapped by
  `LoadAndSave`, so operate on the request context; the middleware commits + writes
  the cookie:
  ```go
  if err := auth.sessionManager.RenewToken(r.Context()); err != nil { // session-fixation guard
      cclog.Errorf("session renew failed: %s", err.Error()); http.Error(...); return err
  }
  auth.sessionManager.Put(r.Context(), "username", user.Username)
  auth.sessionManager.Put(r.Context(), "projects", user.Projects)
  auth.sessionManager.Put(r.Context(), "roles", user.Roles)
  auth.sessionManager.Put(r.Context(), "authSource", int(user.AuthSource))
  return nil
  ```
- **`AuthViaSession(rw, r)`** (lines 129–180): read from the context loaded by the
  middleware; keep the same `*schema.User`/error return shape:
  ```go
  if !auth.sessionManager.Exists(r.Context(), "username") { return nil, nil } // mirrors session.IsNew
  username := auth.sessionManager.GetString(r.Context(), "username")
  if username == "" { /* corrupted */ _ = auth.sessionManager.Destroy(r.Context()); return nil, errors.New("invalid session data") }
  projects, _ := auth.sessionManager.Get(r.Context(), "projects").([]string) // default []string
  roles, _ := auth.sessionManager.Get(r.Context(), "roles").([]string)
  if len(roles) == 0 { _ = auth.sessionManager.Destroy(r.Context()); return nil, errors.New("invalid session data") }
  authSource := auth.sessionManager.GetInt(r.Context(), "authSource") // 0 default == AuthViaLocalPassword
  return &schema.User{Username: username, Projects: projects, Roles: roles, AuthType: schema.AuthSession, AuthSource: schema.AuthSource(authSource)}, nil
  ```
- **`Logout()`** (lines 610–628): `_ = auth.sessionManager.Destroy(r.Context())` then
  call `onsuccess` (middleware clears the cookie). Drop all gorilla calls.

### 4. `cmd/cc-backend/server.go` — install LoadAndSave (scoped)
Grab the manager once: `sm := authHandle.SessionManager()`. Wrap **only** the
session groups (all inside the existing `if !config.Keys.DisableAuthentication`
guards so disabled-auth keeps no sessions):
- `/login`, `/jwt-login`, `/logout`: wrap each handler, e.g.
  `s.router.Post("/login", sm.LoadAndSave(authHandle.Login(loginFailureHandler)).ServeHTTP)`.
- Secured group (line 247): add `secured.Use(sm.LoadAndSave)` **before** the
  `authHandle.Auth` middleware (LoadAndSave must load the session into ctx first).
- Config group (line 310): add `configapi.Use(sm.LoadAndSave)` before `AuthConfigAPI`.
- Frontend group (line 320): add `frontendapi.Use(sm.LoadAndSave)` before `AuthFrontendAPI`.
- **Leave `/api`, `/api/metricstore`, `/userapi`, static files unwrapped.**

### 5. Frontend cleanup — `web/frontend/src/generic/utils.js`
In `init()` remove the dormant Bearer path: delete the `jwt` lookup (lines 24–26)
and the conditional `fetchOptions` Authorization header (line 31); construct the
urql `Client` with just `url` + `exchanges`. **Keep** the user-facing
`/frontend/jwt/` fetch at line ~583 (the "Gen. JWT" copy feature for external API
use). Rebuild with `make frontend`.

### 6. Remove SESSION_KEY everywhere
- `.env:8`, `configs/env-template.txt:11`: delete the `SESSION_KEY=` line.
- `cmd/cc-backend/init.go`: remove `SESSION_KEY` from the embedded `.env` template.
- `internal/api/api_test.go`, `internal/api/nats_test.go`: remove the
  `t.Setenv("SESSION_KEY", ...)` calls.
- Update any `*.md`/docs referencing `SESSION_KEY`.

## Key files
- `internal/auth/auth.go` — struct, `SessionManager()`, `Init`, `SaveSession`, `AuthViaSession`, `Logout`
- `cmd/cc-backend/server.go` — scoped `LoadAndSave` wiring
- `internal/repository/migrations/sqlite3/12_add-sessions-table.{up,down}.sql` (new) + `migration.go` (`Version` → 12)
- `web/frontend/src/generic/utils.js` — drop dead Bearer branch
- `go.mod`/`go.sum`, `.env`, `configs/env-template.txt`, `cmd/cc-backend/init.go`, `internal/api/{api,nats}_test.go`

## Verification
1. `go build ./...`, `make` (incl. `make frontend`), `go vet ./...` clean.
2. `go mod tidy` removes gorilla/sessions; confirm gone from go.mod/go.sum.
3. `go test ./...` passes (esp. `internal/api`, `internal/auth`, `internal/repository`).
4. Fresh DB: `./cc-backend -init-db -add-user demo:admin:demo` then `-server -dev` → DB at version 12.
5. Existing DB: `-server` without migrating fails with the version-12 message; `-migrate-db` then succeeds.
6. Manual end-to-end (browser, `-dev`):
   - Log in → `session` cookie set; a row appears: `sqlite3 var/job.db 'select token,expiry from sessions;'`.
   - Web UI works: dashboard/job views (GraphQL `/query` via session), `/search`,
     `/config/*` admin actions, `/frontend/configuration/` saves, `/frontend/logs/`.
   - **Restart server, reload** → still logged in (durability preserved).
   - Log out → `sessions` row removed, cookie cleared, back to login page.
   - Cookie flags: `HttpOnly`, `SameSite=Lax` (and `Secure` when serving HTTPS).
   - External JWT still works: `/query` with a Bearer token, and `/api/*` /
     `/userapi/*` endpoints (these are NOT session-wrapped) — confirm large
     `/api/jobs/metrics/{id}` responses are not buffered/regressed.
   - OIDC / JWT-cookie login still establishes a session (they call `SaveSession`).


The file /Users/jan/.claude/plans/replace-the-gorilla-sessions-package-atomic-metcalfe.md has been updated successfully. (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`, backed by the existing SQLite DB.

The packages differ architecturally:
- **gorilla `CookieStore`** is *stateless* — all data is encrypted into the cookie
  (signed with `SESSION_KEY`); sessions survive restarts while the key is stable.
- **scs/v2** is *server-side* — only a random token lives in the cookie; data lives
  in a store. No encryption key needed.

**Decisions made:**
- **Store: `sqlite3store`** in the existing SQLite DB (preserves "stay logged in
  across restarts"). Adds dep `github.com/alexedwards/scs/sqlite3store`.
- **Remove `SESSION_KEY`** — dead code with server-side tokens.
- **Use scs's idiomatic `LoadAndSave` middleware** (the clean approach), scoped to
  only the session-authenticated route groups (see endpoint analysis below).
- **`/query` stays dual-auth** (session for the web UI, JWT for external GraphQL
  clients) — no change to `Auth()`.
- **Frontend cleanup:** remove the dormant Bearer-JWT branch in the urql client.

### Endpoint analysis (frontend=session, external=JWT)

The codebase already matches this model. Verified: the web UI's GraphQL client only
attaches `Authorization: Bearer` if a `jwt` exists in the `cc-config` context
(`web/frontend/src/generic/utils.js:24-31`), but nothing ever injects one — so the
UI authenticates `/query` via the **session cookie**; the Bearer branch is dead.

| Route group | Auth today | Caller | LoadAndSave? |
|---|---|---|---|
| `/query`, `/search`, pages `/`,`/config`,`/monitoring/*` (`Auth()`) | session (+JWT fallback) | web UI (+ external GraphQL) | **YES** |
| `/frontend/*` logs,jwt,configuration (`AuthFrontendAPI`) | session only | web UI | **YES** |
| `/config/*` admin (`AuthConfigAPI`) | session only | web UI (admin) | **YES** |
| `/login`, `/jwt-login`, `/logout` | session write | web UI | **YES** |
| `/api/*` (`AuthAPI`) | JWT only | external | **NO** |
| `/api/metricstore` (`AuthMetricStoreAPI`) | JWT only | external | **NO** |
| `/userapi/*` (`AuthUserAPI`) | JWT only | external | **NO** |
| static `/*`, `/img/*`, `/imprint`, `/privacy` | none | browser | **NO** |

`LoadAndSave` buffers the full response to inject the cookie. Scoping it to the
session groups keeps it **off the JWT data APIs** (`/api`, `/userapi`,
`/api/metricstore`) where the large metric/job payloads live. The only large
session-route it buffers is GraphQL `/query` — the accepted residual cost.
`/frontend/jwt/` (mints a JWT for a logged-in user to use against the external JWT
APIs) is the intended session→JWT bridge and stays as-is.

## Changes

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

### 2. New DB migration — `sessions` table
`internal/repository/migrations/sqlite3/12_add-sessions-table.{up,down}.sql`:
```sql
-- up
CREATE TABLE sessions (
    token  TEXT PRIMARY KEY,
    data   BLOB NOT NULL,
    expiry REAL NOT NULL
);
CREATE INDEX sessions_expiry_idx ON sessions(expiry);
-- down
DROP TABLE IF EXISTS sessions;
```
Bump `Version` to `12` in `internal/repository/migration.go:28` + add a history
comment. `Connect()` only *checks* the version, so existing installs run
`-migrate-db`; `-init-db` (`cmd/cc-backend/init.go:83`) already migrates fresh DBs.

### 3. `internal/auth/auth.go` — the core swap
Replace the gorilla import with `github.com/alexedwards/scs/v2`,
`github.com/alexedwards/scs/sqlite3store`, `encoding/gob`,
`.../internal/repository`. Drop now-unused `crypto/rand`, `encoding/base64`, `os`
(verify before removing).

- **Struct** (line 121): `sessionStore *sessions.CookieStore` → `sessionManager *scs.SessionManager`.
- **Add accessor** so the router can install the middleware:
  `func (auth *Authentication) SessionManager() *scs.SessionManager { return auth.sessionManager }`
- **`Init()`** (lines 189–213): delete the whole `SESSION_KEY` block; replace with:
  ```go
  gob.Register([]string{}) // user.Projects / user.Roles are []string in session values
  sm := scs.New()
  sm.Store = sqlite3store.New(repository.GetConnection().DB.DB)
  sm.Cookie.Name = "session"
  sm.Cookie.Path = "/"
  sm.Cookie.HttpOnly = true
  sm.Cookie.SameSite = http.SameSiteLaxMode
  sm.Cookie.Secure = config.Keys.HTTPSCertFile != "" // see note
  if d, err := time.ParseDuration(config.Keys.SessionMaxAge); err == nil && d != 0 {
      sm.Lifetime = d
      authInstance.SessionMaxAge = d
  } // else keep a long default lifetime (config says 0 == "no expiry")
  authInstance.sessionManager = sm
  ```
  > Note on `Secure`: scs sets the flag globally (no per-request option like the old
  > code). Deriving it from `HTTPSCertFile` is actually *stricter* than today — the
  > current code only ever sets `Secure=false` and never `true` (gorilla's default is
  > `false`), so the flag is effectively never set now. Reverse-proxy-TLS deployments
  > that want `Secure` can get a follow-up config flag.
- **`SaveSession(rw, r, user)`** (lines 321–354): the login routes are wrapped by
  `LoadAndSave`, so operate on the request context; the middleware commits + writes
  the cookie:
  ```go
  if err := auth.sessionManager.RenewToken(r.Context()); err != nil { // session-fixation guard
      cclog.Errorf("session renew failed: %s", err.Error()); http.Error(...); return err
  }
  auth.sessionManager.Put(r.Context(), "username", user.Username)
  auth.sessionManager.Put(r.Context(), "projects", user.Projects)
  auth.sessionManager.Put(r.Context(), "roles", user.Roles)
  auth.sessionManager.Put(r.Context(), "authSource", int(user.AuthSource))
  return nil
  ```
- **`AuthViaSession(rw, r)`** (lines 129–180): read from the context loaded by the
  middleware; keep the same `*schema.User`/error return shape:
  ```go
  if !auth.sessionManager.Exists(r.Context(), "username") { return nil, nil } // mirrors session.IsNew
  username := auth.sessionManager.GetString(r.Context(), "username")
  if username == "" { /* corrupted */ _ = auth.sessionManager.Destroy(r.Context()); return nil, errors.New("invalid session data") }
  projects, _ := auth.sessionManager.Get(r.Context(), "projects").([]string) // default []string
  roles, _ := auth.sessionManager.Get(r.Context(), "roles").([]string)
  if len(roles) == 0 { _ = auth.sessionManager.Destroy(r.Context()); return nil, errors.New("invalid session data") }
  authSource := auth.sessionManager.GetInt(r.Context(), "authSource") // 0 default == AuthViaLocalPassword
  return &schema.User{Username: username, Projects: projects, Roles: roles, AuthType: schema.AuthSession, AuthSource: schema.AuthSource(authSource)}, nil
  ```
- **`Logout()`** (lines 610–628): `_ = auth.sessionManager.Destroy(r.Context())` then
  call `onsuccess` (middleware clears the cookie). Drop all gorilla calls.

### 4. `cmd/cc-backend/server.go` — install LoadAndSave (scoped)
Grab the manager once: `sm := authHandle.SessionManager()`. Wrap **only** the
session groups (all inside the existing `if !config.Keys.DisableAuthentication`
guards so disabled-auth keeps no sessions):
- `/login`, `/jwt-login`, `/logout`: wrap each handler, e.g.
  `s.router.Post("/login", sm.LoadAndSave(authHandle.Login(loginFailureHandler)).ServeHTTP)`.
- Secured group (line 247): add `secured.Use(sm.LoadAndSave)` **before** the
  `authHandle.Auth` middleware (LoadAndSave must load the session into ctx first).
- Config group (line 310): add `configapi.Use(sm.LoadAndSave)` before `AuthConfigAPI`.
- Frontend group (line 320): add `frontendapi.Use(sm.LoadAndSave)` before `AuthFrontendAPI`.
- **Leave `/api`, `/api/metricstore`, `/userapi`, static files unwrapped.**

### 5. Frontend cleanup — `web/frontend/src/generic/utils.js`
In `init()` remove the dormant Bearer path: delete the `jwt` lookup (lines 24–26)
and the conditional `fetchOptions` Authorization header (line 31); construct the
urql `Client` with just `url` + `exchanges`. **Keep** the user-facing
`/frontend/jwt/` fetch at line ~583 (the "Gen. JWT" copy feature for external API
use). Rebuild with `make frontend`.

### 6. Remove SESSION_KEY everywhere
- `.env:8`, `configs/env-template.txt:11`: delete the `SESSION_KEY=` line.
- `cmd/cc-backend/init.go`: remove `SESSION_KEY` from the embedded `.env` template.
- `internal/api/api_test.go`, `internal/api/nats_test.go`: remove the
  `t.Setenv("SESSION_KEY", ...)` calls.
- Update any `*.md`/docs referencing `SESSION_KEY`.

## Key files
- `internal/auth/auth.go` — struct, `SessionManager()`, `Init`, `SaveSession`, `AuthViaSession`, `Logout`
- `cmd/cc-backend/server.go` — scoped `LoadAndSave` wiring
- `internal/repository/migrations/sqlite3/12_add-sessions-table.{up,down}.sql` (new) + `migration.go` (`Version` → 12)
- `web/frontend/src/generic/utils.js` — drop dead Bearer branch
- `go.mod`/`go.sum`, `.env`, `configs/env-template.txt`, `cmd/cc-backend/init.go`, `internal/api/{api,nats}_test.go`

## Verification
1. `go build ./...`, `make` (incl. `make frontend`), `go vet ./...` clean.
2. `go mod tidy` removes gorilla/sessions; confirm gone from go.mod/go.sum.
3. `go test ./...` passes (esp. `internal/api`, `internal/auth`, `internal/repository`).
4. Fresh DB: `./cc-backend -init-db -add-user demo:admin:demo` then `-server -dev` → DB at version 12.
5. Existing DB: `-server` without migrating fails with the version-12 message; `-migrate-db` then succeeds.
6. Manual end-to-end (browser, `-dev`):
   - Log in → `session` cookie set; a row appears: `sqlite3 var/job.db 'select token,expiry from sessions;'`.
   - Web UI works: dashboard/job views (GraphQL `/query` via session), `/search`,
     `/config/*` admin actions, `/frontend/configuration/` saves, `/frontend/logs/`.
   - **Restart server, reload** → still logged in (durability preserved).
   - Log out → `sessions` row removed, cookie cleared, back to login page.
   - Cookie flags: `HttpOnly`, `SameSite=Lax` (and `Secure` when serving HTTPS).
   - External JWT still works: `/query` with a Bearer token, and `/api/*` /
     `/userapi/*` endpoints (these are NOT session-wrapped) — confirm large
     `/api/jobs/metrics/{id}` responses are not buffered/regressed.
   - OIDC / JWT-cookie login still establishes a session (they call `SaveSession`).

`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`, backed by the existing SQLite DB.

-- **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.

+- **gorilla `CookieStore`** is *stateless* — all data is encrypted into the cookie

+  (signed with `SESSION_KEY`); sessions survive restarts while the key is stable.

+- **scs/v2** is *server-side* — only a random token lives in the cookie; data lives

+  in a store. No encryption key needed.

-- **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.

+- **Store: `sqlite3store`** in the existing SQLite DB (preserves "stay logged in

+  across restarts"). Adds dep `github.com/alexedwards/scs/sqlite3store`.

+- **Remove `SESSION_KEY`** — dead code with server-side tokens.

+- **Use scs's idiomatic `LoadAndSave` middleware** (the clean approach), scoped to

+  only the session-authenticated route groups (see endpoint analysis below).

+- **`/query` stays dual-auth** (session for the web UI, JWT for external GraphQL

+- **Frontend cleanup:** remove the dormant Bearer-JWT branch in the urql client.

-**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`.

+### Endpoint analysis (frontend=session, external=JWT)

+The codebase already matches this model. Verified: the web UI's GraphQL client only

+attaches `Authorization: Bearer` if a `jwt` exists in the `cc-config` context

+(`web/frontend/src/generic/utils.js:24-31`), but nothing ever injects one — so the

+UI authenticates `/query` via the **session cookie**; the Bearer branch is dead.

+| Route group | Auth today | Caller | LoadAndSave? |

+| `/query`, `/search`, pages `/`,`/config`,`/monitoring/*` (`Auth()`) | session (+JWT fallback) | web UI (+ external GraphQL) | **YES** |

+| `/frontend/*` logs,jwt,configuration (`AuthFrontendAPI`) | session only | web UI | **YES** |

+| `/config/*` admin (`AuthConfigAPI`) | session only | web UI (admin) | **YES** |

+| `/login`, `/jwt-login`, `/logout` | session write | web UI | **YES** |

+| `/api/*` (`AuthAPI`) | JWT only | external | **NO** |

+| `/api/metricstore` (`AuthMetricStoreAPI`) | JWT only | external | **NO** |

+| `/userapi/*` (`AuthUserAPI`) | JWT only | external | **NO** |

+| static `/*`, `/img/*`, `/imprint`, `/privacy` | none | browser | **NO** |

+`LoadAndSave` buffers the full response to inject the cookie. Scoping it to the

+session groups keeps it **off the JWT data APIs** (`/api`, `/userapi`,

+`/api/metricstore`) where the large metric/job payloads live. The only large

+session-route it buffers is GraphQL `/query` — the accepted residual cost.

+`/frontend/jwt/` (mints a JWT for a logged-in user to use against the external JWT

+APIs) is the intended session→JWT bridge and stays as-is.

### 1. Dependencies (`go.mod` / `go.sum`)

-- Remove `github.com/gorilla/sessions` (and transitive `gorilla/securecookie` from go.sum) via `go mod tidy`.

+- Remove `github.com/gorilla/sessions` (+ transitive `gorilla/securecookie`) 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:

-  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.

+CREATE INDEX sessions_expiry_idx ON sessions(expiry);

+Bump `Version` to `12` in `internal/repository/migration.go:28` + add a history

+comment. `Connect()` only *checks* the version, so existing installs run

+`-migrate-db`; `-init-db` (`cmd/cc-backend/init.go:83`) already migrates fresh DBs.

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

-(Drop the now-unused `crypto/rand`, `encoding/base64`, `os` imports if nothing

-else needs them — verify before removing.)

+Replace the gorilla import with `github.com/alexedwards/scs/v2`,

+`.../internal/repository`. Drop now-unused `crypto/rand`, `encoding/base64`, `os`

-- **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:

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

+- **Add accessor** so the router can install the middleware:

+  `func (auth *Authentication) SessionManager() *scs.SessionManager { return auth.sessionManager }`

+- **`Init()`** (lines 189–213): delete the whole `SESSION_KEY` block; replace with:

-  gob.Register([]string{}) // needed to gob-encode user.Projects/Roles in session values

+  gob.Register([]string{}) // user.Projects / user.Roles are []string in session values

+  sm.Cookie.Secure = config.Keys.HTTPSCertFile != "" // see note

if d, err := time.ParseDuration(config.Keys.SessionMaxAge); err == nil && d != 0 {

-  } // else: keep scs default Lifetime; SessionMaxAge stays 0 -> "session cookie"

+  } // else keep a long default lifetime (config says 0 == "no expiry")

-  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:

+  > Note on `Secure`: scs sets the flag globally (no per-request option like the old

+  > code). Deriving it from `HTTPSCertFile` is actually *stricter* than today — the

+  > current code only ever sets `Secure=false` and never `true` (gorilla's default is

+  > `false`), so the flag is effectively never set now. Reverse-proxy-TLS deployments

+  > that want `Secure` can get a follow-up config flag.

+- **`SaveSession(rw, r, user)`** (lines 321–354): the login routes are wrapped by

+  `LoadAndSave`, so operate on the request context; the middleware commits + writes

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

-  if auth.SessionMaxAge != 0 { maxAge = int(auth.SessionMaxAge.Seconds()) }

-      Name: "session", Value: token, Path: "/",

-      Secure: secure, HttpOnly: true, SameSite: http.SameSiteLaxMode,

+  if err := auth.sessionManager.RenewToken(r.Context()); err != nil { // session-fixation guard

+      cclog.Errorf("session renew failed: %s", err.Error()); http.Error(...); return err

+  auth.sessionManager.Put(r.Context(), "username", user.Username)

+  auth.sessionManager.Put(r.Context(), "projects", user.Projects)

+  auth.sessionManager.Put(r.Context(), "roles", user.Roles)

+  auth.sessionManager.Put(r.Context(), "authSource", int(user.AuthSource))

-- **`AuthViaSession()`** (lines 129–180): rewrite read path. Keep the same

-  validation/return shape (`*schema.User` with `AuthType: schema.AuthSession`):

+- **`AuthViaSession(rw, r)`** (lines 129–180): read from the context loaded by the

+  middleware; keep the same `*schema.User`/error return shape:

-  if err != nil { return nil, nil } // no session cookie == not logged in (mirrors session.IsNew)

-  ctx, err := auth.sessionManager.Load(r.Context(), cookie.Value)

-  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

+  if !auth.sessionManager.Exists(r.Context(), "username") { return nil, nil } // mirrors session.IsNew

+  username := auth.sessionManager.GetString(r.Context(), "username")

+  if username == "" { /* corrupted */ _ = auth.sessionManager.Destroy(r.Context()); return nil, errors.New("invalid session data") }

+  projects, _ := auth.sessionManager.Get(r.Context(), "projects").([]string) // default []string

+  roles, _ := auth.sessionManager.Get(r.Context(), "roles").([]string)

+  if len(roles) == 0 { _ = auth.sessionManager.Destroy(r.Context()); return nil, errors.New("invalid session data") }

+  authSource := auth.sessionManager.GetInt(r.Context(), "authSource") // 0 default == AuthViaLocalPassword

+  return &schema.User{Username: username, Projects: projects, Roles: roles, AuthType: schema.AuthSession, AuthSource: schema.AuthSource(authSource)}, nil

-  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): `_ = auth.sessionManager.Destroy(r.Context())` then

+  call `onsuccess` (middleware clears the cookie). Drop all gorilla calls.

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

+### 4. `cmd/cc-backend/server.go` — install LoadAndSave (scoped)

+Grab the manager once: `sm := authHandle.SessionManager()`. Wrap **only** the

+session groups (all inside the existing `if !config.Keys.DisableAuthentication`

+guards so disabled-auth keeps no sessions):

+- `/login`, `/jwt-login`, `/logout`: wrap each handler, e.g.

+  `s.router.Post("/login", sm.LoadAndSave(authHandle.Login(loginFailureHandler)).ServeHTTP)`.

+- Secured group (line 247): add `secured.Use(sm.LoadAndSave)` **before** the

+  `authHandle.Auth` middleware (LoadAndSave must load the session into ctx first).

+- Config group (line 310): add `configapi.Use(sm.LoadAndSave)` before `AuthConfigAPI`.

+- Frontend group (line 320): add `frontendapi.Use(sm.LoadAndSave)` before `AuthFrontendAPI`.

+- **Leave `/api`, `/api/metricstore`, `/userapi`, static files unwrapped.**

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

+### 5. Frontend cleanup — `web/frontend/src/generic/utils.js`

+In `init()` remove the dormant Bearer path: delete the `jwt` lookup (lines 24–26)

+and the conditional `fetchOptions` Authorization header (line 31); construct the

+urql `Client` with just `url` + `exchanges`. **Keep** the user-facing

+`/frontend/jwt/` fetch at line ~583 (the "Gen. JWT" copy feature for external API

-### 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.

+- `.env:8`, `configs/env-template.txt:11`: delete the `SESSION_KEY=` line.

+- `cmd/cc-backend/init.go`: remove `SESSION_KEY` from the embedded `.env` template.

+- `internal/api/api_test.go`, `internal/api/nats_test.go`: remove the

+  `t.Setenv("SESSION_KEY", ...)` calls.

+- Update any `*.md`/docs referencing `SESSION_KEY`.

-- `internal/auth/auth.go` — struct, `Init`, `SaveSession`, `AuthViaSession`, `Logout`

-- `internal/repository/migration.go` — `Version` → 12

-- `.env`, `configs/env-template.txt`, `cmd/cc-backend/init.go`

+- `internal/auth/auth.go` — struct, `SessionManager()`, `Init`, `SaveSession`, `AuthViaSession`, `Logout`

+- `cmd/cc-backend/server.go` — scoped `LoadAndSave` wiring

+- `internal/repository/migrations/sqlite3/12_add-sessions-table.{up,down}.sql` (new) + `migration.go` (`Version` → 12)

+- `web/frontend/src/generic/utils.js` — drop dead Bearer branch

+- `go.mod`/`go.sum`, `.env`, `configs/env-template.txt`, `cmd/cc-backend/init.go`, `internal/api/{api,nats}_test.go`

-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.

+1. `go build ./...`, `make` (incl. `make frontend`), `go vet ./...` clean.

+2. `go mod tidy` removes gorilla/sessions; confirm gone from go.mod/go.sum.

-   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.

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

-   - **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`).

+4. Fresh DB: `./cc-backend -init-db -add-user demo:admin:demo` then `-server -dev` → DB at version 12.

+5. Existing DB: `-server` without migrating fails with the version-12 message; `-migrate-db` then succeeds.

+6. Manual end-to-end (browser, `-dev`):

+   - Log in → `session` cookie set; a row appears: `sqlite3 var/job.db 'select token,expiry from sessions;'`.

+   - Web UI works: dashboard/job views (GraphQL `/query` via session), `/search`,

+     `/config/*` admin actions, `/frontend/configuration/` saves, `/frontend/logs/`.

+   - **Restart server, reload** → still logged in (durability preserved).

+   - Log out → `sessions` row removed, cookie cleared, back to login page.

+   - Cookie flags: `HttpOnly`, `SameSite=Lax` (and `Secure` when serving HTTPS).

+   - External JWT still works: `/query` with a Bearer token, and `/api/*` /

+     `/userapi/*` endpoints (these are NOT session-wrapped) — confirm large

+     `/api/jobs/metrics/{id}` responses are not buffered/regressed.

+   - OIDC / JWT-cookie login still establishes a session (they call `SaveSession`).

# 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`).


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…


The plan is finalized. It reflects the analysis (the codebase already matches your frontend=session / external=JWT model), both your decisions (keep `/query` dual-auth, remove the dead Bearer branch), and the `LoadAndSave` middleware scoped precisely to the session-authenticated route groups so the heavy JWT data APIs stay off the buffering path.


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`, backed by the existing SQLite DB.

The packages differ architecturally:
- **gorilla `CookieStore`** is *stateless* — all data is encrypted into the cookie
  (signed with `SESSION_KEY`); sessions survive restarts while the key is stable.
- **scs/v2** is *server-side* — only a random token lives in the cookie; data lives
  in a store. No encryption key needed.

**Decisions made:**
- **Store: `sqlite3store`** in the existing SQLite DB (preserves "stay logged in
  across restarts"). Adds dep `github.com/alexedwards/scs/sqlite3store`.
- **Remove `SESSION_KEY`** — dead code with server-side tokens.
- **Use scs's idiomatic `LoadAndSave` middleware** (the clean approach), scoped to
  only the session-authenticated route groups (see endpoint analysis below).
- **`/query` stays dual-auth** (session for the web UI, JWT for external GraphQL
  clients) — no change to `Auth()`.
- **Frontend cleanup:** remove the dormant Bearer-JWT branch in the urql client.

### Endpoint analysis (frontend=session, external=JWT)

The codebase already matches this model. Verified: the web UI's GraphQL client only
attaches `Authorization: Bearer` if a `jwt` exists in the `cc-config` context
(`web/frontend/src/generic/utils.js:24-31`), but nothing ever injects one — so the
UI authenticates `/query` via the **session cookie**; the Bearer branch is dead.

| Route group | Auth today | Caller | LoadAndSave? |
|---|---|---|---|
| `/query`, `/search`, pages `/`,`/config`,`/monitoring/*` (`Auth()`) | session (+JWT fallback) | web UI (+ external GraphQL) | **YES** |
| `/frontend/*` logs,jwt,configuration (`AuthFrontendAPI`) | session only | web UI | **YES** |
| `/config/*` admin (`AuthConfigAPI`) | session only | web UI (admin) | **YES** |
| `/login`, `/jwt-login`, `/logout` | session write | web UI | **YES** |
| `/api/*` (`AuthAPI`) | JWT only | external | **NO** |
| `/api/metricstore` (`AuthMetricStoreAPI`) | JWT only | external | **NO** |
| `/userapi/*` (`AuthUserAPI`) | JWT only | external | **NO** |
| static `/*`, `/img/*`, `/imprint`, `/privacy` | none | browser | **NO** |

`LoadAndSave` buffers the full response to inject the cookie. Scoping it to the
session groups keeps it **off the JWT data APIs** (`/api`, `/userapi`,
`/api/metricstore`) where the large metric/job payloads live. The only large
session-route it buffers is GraphQL `/query` — the accepted residual cost.
`/frontend/jwt/` (mints a JWT for a logged-in user to use against the external JWT
APIs) is the intended session→JWT bridge and stays as-is.

## Changes

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

### 2. New DB migration — `sessions` table
`internal/repository/migrations/sqlite3/12_add-sessions-table.{up,down}.sql`:
```sql
-- up
CREATE TABLE sessions (
    token  TEXT PRIMARY KEY,
    data   BLOB NOT NULL,
    expiry REAL NOT NULL
);
CREATE INDEX sessions_expiry_idx ON sessions(expiry);
-- down
DROP TABLE IF EXISTS sessions;
```
Bump `Version` to `12` in `internal/repository/migration.go:28` + add a history
comment. `Connect()` only *checks* the version, so existing installs run
`-migrate-db`; `-init-db` (`cmd/cc-backend/init.go:83`) already migrates fresh DBs.

### 3. `internal/auth/auth.go` — the core swap
Replace the gorilla import with `github.com/alexedwards/scs/v2`,
`github.com/alexedwards/scs/sqlite3store`, `encoding/gob`,
`.../internal/repository`. Drop now-unused `crypto/rand`, `encoding/base64`, `os`
(verify before removing).

- **Struct** (line 121): `sessionStore *sessions.CookieStore` → `sessionManager *scs.SessionManager`.
- **Add accessor** so the router can install the middleware:
  `func (auth *Authentication) SessionManager() *scs.SessionManager { return auth.sessionManager }`
- **`Init()`** (lines 189–213): delete the whole `SESSION_KEY` block; replace with:
  ```go
  gob.Register([]string{}) // user.Projects / user.Roles are []string in session values
  sm := scs.New()
  sm.Store = sqlite3store.New(repository.GetConnection().DB.DB)
  sm.Cookie.Name = "session"
  sm.Cookie.Path = "/"
  sm.Cookie.HttpOnly = true
  sm.Cookie.SameSite = http.SameSiteLaxMode
  sm.Cookie.Secure = config.Keys.HTTPSCertFile != "" // see note
  if d, err := time.ParseDuration(config.Keys.SessionMaxAge); err == nil && d != 0 {
      sm.Lifetime = d
      authInstance.SessionMaxAge = d
  } // else keep a long default lifetime (config says 0 == "no expiry")
  authInstance.sessionManager = sm
  ```
  > Note on `Secure`: scs sets the flag globally (no per-request option like the old
  > code). Deriving it from `HTTPSCertFile` is actually *stricter* than today — the
  > current code only ever sets `Secure=false` and never `true` (gorilla's default is
  > `false`), so the flag is effectively never set now. Reverse-proxy-TLS deployments
  > that want `Secure` can get a follow-up config flag.
- **`SaveSession(rw, r, user)`** (lines 321–354): the login routes are wrapped by
  `LoadAndSave`, so operate on the request context; the middleware commits + writes
  the cookie:
  ```go
  if err := auth.sessionManager.RenewToken(r.Context()); err != nil { // session-fixation guard
      cclog.Errorf("session renew failed: %s", err.Error()); http.Error(...); return err
  }
  auth.sessionManager.Put(r.Context(), "username", user.Username)
  auth.sessionManager.Put(r.Context(), "projects", user.Projects)
  auth.sessionManager.Put(r.Context(), "roles", user.Roles)
  auth.sessionManager.Put(r.Context(), "authSource", int(user.AuthSource))
  return nil
  ```
- **`AuthViaSession(rw, r)`** (lines 129–180): read from the context loaded by the
  middleware; keep the same `*schema.User`/error return shape:
  ```go
  if !auth.sessionManager.Exists(r.Context(), "username") { return nil, nil } // mirrors session.IsNew
  username := auth.sessionManager.GetString(r.Context(), "username")
  if username == "" { /* corrupted */ _ = auth.sessionManager.Destroy(r.Context()); return nil, errors.New("invalid session data") }
  projects, _ := auth.sessionManager.Get(r.Context(), "projects").([]string) // default []string
  roles, _ := auth.sessionManager.Get(r.Context(), "roles").([]string)
  if len(roles) == 0 { _ = auth.sessionManager.Destroy(r.Context()); return nil, errors.New("invalid session data") }
  authSource := auth.sessionManager.GetInt(r.Context(), "authSource") // 0 default == AuthViaLocalPassword
  return &schema.User{Username: username, Projects: projects, Roles: roles, AuthType: schema.AuthSession, AuthSource: schema.AuthSource(authSource)}, nil
  ```
- **`Logout()`** (lines 610–628): `_ = auth.sessionManager.Destroy(r.Context())` then
  call `onsuccess` (middleware clears the cookie). Drop all gorilla calls.

### 4. `cmd/cc-backend/server.go` — install LoadAndSave (scoped)
Grab the manager once: `sm := authHandle.SessionManager()`. Wrap **only** the
session groups (all inside the existing `if !config.Keys.DisableAuthentication`
guards so disabled-auth keeps no sessions):
- `/login`, `/jwt-login`, `/logout`: wrap each handler, e.g.
  `s.router.Post("/login", sm.LoadAndSave(authHandle.Login(loginFailureHandler)).ServeHTTP)`.
- Secured group (line 247): add `secured.Use(sm.LoadAndSave)` **before** the
  `authHandle.Auth` middleware (LoadAndSave must load the session into ctx first).
- Config group (line 310): add `configapi.Use(sm.LoadAndSave)` before `AuthConfigAPI`.
- Frontend group (line 320): add `frontendapi.Use(sm.LoadAndSave)` before `AuthFrontendAPI`.
- **Leave `/api`, `/api/metricstore`, `/userapi`, static files unwrapped.**

### 5. Frontend cleanup — `web/frontend/src/generic/utils.js`
In `init()` remove the dormant Bearer path: delete the `jwt` lookup (lines 24–26)
and the conditional `fetchOptions` Authorization header (line 31); construct the
urql `Client` with just `url` + `exchanges`. **Keep** the user-facing
`/frontend/jwt/` fetch at line ~583 (the "Gen. JWT" copy feature for external API
use). Rebuild with `make frontend`.

### 6. Remove SESSION_KEY everywhere
- `.env:8`, `configs/env-template.txt:11`: delete the `SESSION_KEY=` line.
- `cmd/cc-backend/init.go`: remove `SESSION_KEY` from the embedded `.env` template.
- `internal/api/api_test.go`, `internal/api/nats_test.go`: remove the
  `t.Setenv("SESSION_KEY", ...)` calls.
- Update any `*.md`/docs referencing `SESSION_KEY`.

## Key files
- `internal/auth/auth.go` — struct, `SessionManager()`, `Init`, `SaveSession`, `AuthViaSession`, `Logout`
- `cmd/cc-backend/server.go` — scoped `LoadAndSave` wiring
- `internal/repository/migrations/sqlite3/12_add-sessions-table.{up,down}.sql` (new) + `migration.go` (`Version` → 12)
- `web/frontend/src/generic/utils.js` — drop dead Bearer branch
- `go.mod`/`go.sum`, `.env`, `configs/env-template.txt`, `cmd/cc-backend/init.go`, `internal/api/{api,nats}_test.go`

## Verification
1. `go build ./...`, `make` (incl. `make frontend`), `go vet ./...` clean.
2. `go mod tidy` removes gorilla/sessions; confirm gone from go.mod/go.sum.
3. `go test ./...` passes (esp. `internal/api`, `internal/auth`, `internal/repository`).
4. Fresh DB: `./cc-backend -init-db -add-user demo:admin:demo` then `-server -dev` → DB at version 12.
5. Existing DB: `-server` without migrating fails with the version-12 message; `-migrate-db` then succeeds.
6. Manual end-to-end (browser, `-dev`):
   - Log in → `session` cookie set; a row appears: `sqlite3 var/job.db 'select token,expiry from sessions;'`.
   - Web UI works: dashboard/job views (GraphQL `/query` via session), `/search`,
     `/config/*` admin actions, `/frontend/configuration/` saves, `/frontend/logs/`.
   - **Restart server, reload** → still logged in (durability preserved).
   - Log out → `sessions` row removed, cookie cleared, back to login page.
   - Cookie flags: `HttpOnly`, `SameSite=Lax` (and `Secure` when serving HTTPS).
   - External JWT still works: `/query` with a Bearer token, and `/api/*` /
     `/userapi/*` endpoints (these are NOT session-wrapped) — confirm large
     `/api/jobs/metrics/{id}` responses are not buffered/regressed.
   - OIDC / JWT-cookie login still establishes a session (they call `SaveSession`).


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:
One final question: The /query GraphQL endpoints actually have large payloads. As they wrap the job metric data. Is this an performance issue?


Honest answer: **yes, this is a real inefficiency for `/query` specifically** — though it's a throughput/memory cost, not a correctness problem.

## What `LoadAndSave` actually does to `/query`
scs's `LoadAndSave` wraps the `ResponseWriter` in a `bufferedResponseWriter` that accumulates the **entire** response body in a `bytes.Buffer`, then writes `Set-Cookie` and flushes. So every `/query` response is held in full in RAM before a single byte goes out.

Concretely, for job-metric GraphQL payloads:
- **Memory**: a full extra copy of each response, transiently, per in-flight request. A multi-node job view can be hundreds of KB to several MB; under N concurrent dashboard loads that's N × payload of extra heap. Pure overhead vs. streaming.
- **Allocations / GC**: each large query allocates and then discards a big buffer — exactly the allocation churn CLAUDE.md says to avoid on the hot path.
- **Latency**: marginal. gqlgen's `transport.POST` already marshals the whole response before writing, so buffering adds a memcopy, not a re-architecture of TTFB.

So: not a showstopper at small scale, but it directly contradicts the "streaming over buffering, minimize allocations" directive — and `/query` is the single hottest session-authenticated route.

## The fix — don't buffer the read paths
The key insight: **the session is only ever *written* at login/logout.** Every other session route (`/query`, `/search`, pages, `/config/*`, `/frontend/*`) only *reads* it to authenticate. Buffering is only needed when you write a cookie. So:

- **login / jwt-login / logout** (session written, tiny login-page responses): use `LoadAndSave` — buffering is free here.
- **secured / config / frontend** (session read-only): use a tiny **non-buffering** middleware that just loads the session into the request context (`sm.Load`, no commit, no cookie write, no `ResponseWriter` wrap). `AuthViaSession` then reads from context exactly as planned.

```go
func loadSession(sm *scs.SessionManager, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := ""
        if c, err := r.Cookie(sm.Cookie.Name); err == nil { token = c.Value }
        ctx, err := sm.Load(r.Context(), token) // expiry enforced here
        if err != nil { http.Error(w, "session load failed", 500); return }
        next.ServeHTTP(w, r.WithContext(ctx)) // no commit, no buffering -> /query streams
    })
}
```

This keeps the clean middleware structure you wanted **and** leaves `/query` fully streaming. The only tradeoff: a "corrupted session" detected on a read path is cleared best-effort via a manual expired-cookie write instead of an auto-commit (trivial; we don't use idle-timeout, and absolute expiry is enforced at `Load`).

Which approach do you want?


AskUserQuestion

Question: How should the scs session middleware be wired, given /query carries large job-metric payloads?

Choices:
LoadAndSave only on login/jwt-login/logout (tiny responses). Read-only non-buffering load middleware on secured/config/frontend. /query streams with zero buffering. Clean + performance-correct. Recommended.
login/logout: LoadAndSave (writes cookie)
secured/config/frontend: loadSession()
  -> sm.Load into ctx, no commit
  -> /query NOT buffered (streams)
Simplest wiring: LoadAndSave on all session groups incl. secured. But every /query response is fully buffered in RAM. Contradicts the streaming/allocation guidance.
all session groups: sm.LoadAndSave
  -> /query response buffered in
     bytes.Buffer before flush
  -> extra heap + GC per large query
No router middleware at all. Load/read inside AuthViaSession, Commit+cookie inside SaveSession/Logout. Zero buffering, preserves per-request Secure, leanest router — but least 'idiomatic' scs usage.
AuthViaSession: read cookie -> sm.Load -> read
SaveSession: Put -> Commit -> SetCookie
Logout: Destroy -> expire cookie
// no LoadAndSave, no buffering anywhere


Your questions have been answered: "How should the scs session middleware be wired, given /query carries large job-metric payloads?"="Hybrid: no buffering on reads" selected preview:
login/logout: LoadAndSave (writes cookie)
secured/config/frontend: loadSession()
  -> sm.Load into ctx, no commit
  -> /query NOT buffered (streams). You can now continue with these answers in mind.

Question: How should the scs session middleware be wired, given /query carries large job-metric payloads?

Choices:
LoadAndSave only on login/jwt-login/logout (tiny responses). Read-only non-buffering load middleware on secured/config/frontend. /query streams with zero buffering. Clean + performance-correct. Recommended.
login/logout: LoadAndSave (writes cookie)
secured/config/frontend: loadSession()
  -> sm.Load into ctx, no commit
  -> /query NOT buffered (streams)
Simplest wiring: LoadAndSave on all session groups incl. secured. But every /query response is fully buffered in RAM. Contradicts the streaming/allocation guidance.
all session groups: sm.LoadAndSave
  -> /query response buffered in
     bytes.Buffer before flush
  -> extra heap + GC per large query
No router middleware at all. Load/read inside AuthViaSession, Commit+cookie inside SaveSession/Logout. Zero buffering, preserves per-request Secure, leanest router — but least 'idiomatic' scs usage.
AuthViaSession: read cookie -> sm.Load -> read
SaveSession: Put -> Commit -> SetCookie
Logout: Destroy -> expire cookie
// no LoadAndSave, no buffering anywhere


Hybrid it is. Let me update the plan's middleware sections to reflect the no-buffering-on-reads design, then finalize.


Edit

- **Use scs's idiomatic `LoadAndSave` middleware** (the clean approach), scoped to
  only the session-authenticated route groups (see endpoint analysis below).

- **Hybrid middleware (performance-correct):** `LoadAndSave` only on the session
  *write* paths (login/jwt-login/logout — tiny responses); a custom **non-buffering**
  read-only load middleware on the session *read* paths (secured/config/frontend).
  The session is only ever written at login/logout, so the large `/query`
  GraphQL responses stream with **zero buffering** (no `ResponseWriter` wrap).


- **Use scs's idiomatic `LoadAndSave` middleware** (the clean approach), scoped to
  only the session-authenticated route groups (see endpoint analysis below).

- **Hybrid middleware (performance-correct):** `LoadAndSave` only on the session
  *write* paths (login/jwt-login/logout — tiny responses); a custom **non-buffering**
  read-only load middleware on the session *read* paths (secured/config/frontend).
  The session is only ever written at login/logout, so the large `/query`
  GraphQL responses stream with **zero buffering** (no `ResponseWriter` wrap).

- **Store: `sqlite3store`** in the existing SQLite DB (preserves "stay logged in

across restarts"). Adds dep `github.com/alexedwards/scs/sqlite3store`.

- **Remove `SESSION_KEY`** — dead code with server-side tokens.

-- **Use scs's idiomatic `LoadAndSave` middleware** (the clean approach), scoped to

-  only the session-authenticated route groups (see endpoint analysis below).

+- **Hybrid middleware (performance-correct):** `LoadAndSave` only on the session

+  *write* paths (login/jwt-login/logout — tiny responses); a custom **non-buffering**

+  read-only load middleware on the session *read* paths (secured/config/frontend).

+  The session is only ever written at login/logout, so the large `/query`

+  GraphQL responses stream with **zero buffering** (no `ResponseWriter` wrap).

- **`/query` stays dual-auth** (session for the web UI, JWT for external GraphQL

- **Frontend cleanup:** remove the dormant Bearer-JWT branch in the urql client.


`LoadAndSave` buffers the full response to inject the cookie. Scoping it to the
session groups keeps it **off the JWT data APIs** (`/api`, `/userapi`,
`/api/metricstore`) where the large metric/job payloads live. The only large
session-route it buffers is GraphQL `/query` — the accepted residual cost.
`/frontend/jwt/` (mints a JWT for a logged-in user to use against the external JWT
APIs) is the intended session→JWT bridge and stays as-is.

`LoadAndSave` buffers the full response to inject the cookie, so it is used **only**
on the tiny login/logout write-paths. The read paths (incl. GraphQL `/query`, which
wraps large job-metric payloads) use a non-buffering load middleware and stream
normally. The JWT data APIs (`/api`, `/userapi`, `/api/metricstore`) get no session
middleware at all. `/frontend/jwt/` (mints a JWT for a logged-in user to use against
the external JWT APIs) is the intended session→JWT bridge and stays as-is.

> Why no buffering on reads: the session is mutated only at login (`SaveSession`)
> and logout (`Destroy`). Every other session route just reads username/roles to
> authenticate, so it needs the data loaded into the request context but never
> writes a cookie — hence no need to wrap/buffer the response.


`LoadAndSave` buffers the full response to inject the cookie. Scoping it to the
session groups keeps it **off the JWT data APIs** (`/api`, `/userapi`,
`/api/metricstore`) where the large metric/job payloads live. The only large
session-route it buffers is GraphQL `/query` — the accepted residual cost.
`/frontend/jwt/` (mints a JWT for a logged-in user to use against the external JWT
APIs) is the intended session→JWT bridge and stays as-is.

`LoadAndSave` buffers the full response to inject the cookie, so it is used **only**
on the tiny login/logout write-paths. The read paths (incl. GraphQL `/query`, which
wraps large job-metric payloads) use a non-buffering load middleware and stream
normally. The JWT data APIs (`/api`, `/userapi`, `/api/metricstore`) get no session
middleware at all. `/frontend/jwt/` (mints a JWT for a logged-in user to use against
the external JWT APIs) is the intended session→JWT bridge and stays as-is.

> Why no buffering on reads: the session is mutated only at login (`SaveSession`)
> and logout (`Destroy`). Every other session route just reads username/roles to
> authenticate, so it needs the data loaded into the request context but never
> writes a cookie — hence no need to wrap/buffer the response.

| `/userapi/*` (`AuthUserAPI`) | JWT only | external | **NO** |

| static `/*`, `/img/*`, `/imprint`, `/privacy` | none | browser | **NO** |

-`LoadAndSave` buffers the full response to inject the cookie. Scoping it to the

-session groups keeps it **off the JWT data APIs** (`/api`, `/userapi`,

-`/api/metricstore`) where the large metric/job payloads live. The only large

-session-route it buffers is GraphQL `/query` — the accepted residual cost.

-`/frontend/jwt/` (mints a JWT for a logged-in user to use against the external JWT

-APIs) is the intended session→JWT bridge and stays as-is.

+`LoadAndSave` buffers the full response to inject the cookie, so it is used **only**

+on the tiny login/logout write-paths. The read paths (incl. GraphQL `/query`, which

+wraps large job-metric payloads) use a non-buffering load middleware and stream

+normally. The JWT data APIs (`/api`, `/userapi`, `/api/metricstore`) get no session

+middleware at all. `/frontend/jwt/` (mints a JWT for a logged-in user to use against

+the external JWT APIs) is the intended session→JWT bridge and stays as-is.

+> Why no buffering on reads: the session is mutated only at login (`SaveSession`)

+> and logout (`Destroy`). Every other session route just reads username/roles to

+> authenticate, so it needs the data loaded into the request context but never

+> writes a cookie — hence no need to wrap/buffer the response.

### 1. Dependencies (`go.mod` / `go.sum`)


Now the AuthViaSession corrupted-session handling and the server.go wiring section:


- **`AuthViaSession(rw, r)`** (lines 129–180): read from the context loaded by the
  middleware; keep the same `*schema.User`/error return shape:
  ```go
  if !auth.sessionManager.Exists(r.Context(), "username") { return nil, nil } // mirrors session.IsNew
  username := auth.sessionManager.GetString(r.Context(), "username")
  if username == "" { /* corrupted */ _ = auth.sessionManager.Destroy(r.Context()); return nil, errors.New("invalid session data") }
  projects, _ := auth.sessionManager.Get(r.Context(), "projects").([]string) // default []string
  roles, _ := auth.sessionManager.Get(r.Context(), "roles").([]string)
  if len(roles) == 0 { _ = auth.sessionManager.Destroy(r.Context()); return nil, errors.New("invalid session data") }
  authSource := auth.sessionManager.GetInt(r.Context(), "authSource") // 0 default == AuthViaLocalPassword
  return &schema.User{Username: username, Projects: projects, Roles: roles, AuthType: schema.AuthSession, AuthSource: schema.AuthSource(authSource)}, nil
  ```
- **`Logout()`** (lines 610–628): `_ = auth.sessionManager.Destroy(r.Context())` then
  call `onsuccess` (middleware clears the cookie). Drop all gorilla calls.

### 4. `cmd/cc-backend/server.go` — install LoadAndSave (scoped)
Grab the manager once: `sm := authHandle.SessionManager()`. Wrap **only** the
session groups (all inside the existing `if !config.Keys.DisableAuthentication`
guards so disabled-auth keeps no sessions):
- `/login`, `/jwt-login`, `/logout`: wrap each handler, e.g.
  `s.router.Post("/login", sm.LoadAndSave(authHandle.Login(loginFailureHandler)).ServeHTTP)`.
- Secured group (line 247): add `secured.Use(sm.LoadAndSave)` **before** the
  `authHandle.Auth` middleware (LoadAndSave must load the session into ctx first).
- Config group (line 310): add `configapi.Use(sm.LoadAndSave)` before `AuthConfigAPI`.
- Frontend group (line 320): add `frontendapi.Use(sm.LoadAndSave)` before `AuthFrontendAPI`.
- **Leave `/api`, `/api/metricstore`, `/userapi`, static files unwrapped.**

- **`AuthViaSession(rw, r)`** (lines 129–180): read from the context loaded by the
  read middleware; keep the same `*schema.User`/error return shape. On a read path
  there is no commit, so a corrupted session is cleared best-effort by writing an
  expired `session` cookie (small helper) rather than `Destroy`:
  ```go
  if !auth.sessionManager.Exists(r.Context(), "username") { return nil, nil } // mirrors session.IsNew
  username := auth.sessionManager.GetString(r.Context(), "username")
  if username == "" { expireSessionCookie(rw); return nil, errors.New("invalid session data") }
  projects, _ := auth.sessionManager.Get(r.Context(), "projects").([]string) // default []string
  roles, _ := auth.sessionManager.Get(r.Context(), "roles").([]string)
  if len(roles) == 0 { expireSessionCookie(rw); return nil, errors.New("invalid session data") }
  authSource := auth.sessionManager.GetInt(r.Context(), "authSource") // 0 default == AuthViaLocalPassword
  return &schema.User{Username: username, Projects: projects, Roles: roles, AuthType: schema.AuthSession, AuthSource: schema.AuthSource(authSource)}, nil
  ```
  where `expireSessionCookie(rw)` writes `&http.Cookie{Name:"session", Path:"/", MaxAge:-1, HttpOnly:true, SameSite:http.SameSiteLaxMode}`.
- **`Logout()`** (lines 610–628): logout is wrapped by `LoadAndSave`, so
  `_ = auth.sessionManager.Destroy(r.Context())` then call `onsuccess` — the
  middleware removes the store row and clears the cookie. Drop all gorilla calls.
- **Add the non-buffering read middleware** on the manager (or a small free func):
  ```go
  func (auth *Authentication) LoadSession(next http.Handler) http.Handler {
      return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
          token := ""
          if c, err := r.Cookie(auth.sessionManager.Cookie.Name); err == nil { token = c.Value }
          ctx, err := auth.sessionManager.Load(r.Context(), token) // absolute expiry enforced here
          if err != nil { http.Error(w, "session load failed", http.StatusInternalServerError); return }
          next.ServeHTTP(w, r.WithContext(ctx)) // no commit, no ResponseWriter wrap -> no buffering
      })
  }
  ```

### 4. `cmd/cc-backend/server.go` — hybrid wiring
Grab the manager once: `sm := authHandle.SessionManager()`. All wiring stays inside
the existing `if !config.Keys.DisableAuthentication` guards.
- **Write paths — `LoadAndSave`** (tiny responses): `/login`, `/jwt-login`,
  `/logout`, e.g. `s.router.Post("/login", sm.LoadAndSave(authHandle.Login(loginFailureHandler)).ServeHTTP)`.
- **Read paths — `authHandle.LoadSession`** (no buffering), added **before** the
  existing auth middleware so the session is in ctx when `AuthViaSession` runs:
  - Secured group (line 247): `secured.Use(authHandle.LoadSession)` before `authHandle.Auth`.
  - Config group (line 310): `configapi.Use(authHandle.LoadSession)` before `AuthConfigAPI`.
  - Frontend group (line 320): `frontendapi.Use(authHandle.LoadSession)` before `AuthFrontendAPI`.
- **Leave `/api`, `/api/metricstore`, `/userapi`, static files unwrapped** (JWT-only / public).

> `/query` lives in the secured group → covered by the non-buffering `LoadSession`,
> so large GraphQL payloads stream unchanged.


- **`AuthViaSession(rw, r)`** (lines 129–180): read from the context loaded by the
  middleware; keep the same `*schema.User`/error return shape:
  ```go
  if !auth.sessionManager.Exists(r.Context(), "username") { return nil, nil } // mirrors session.IsNew
  username := auth.sessionManager.GetString(r.Context(), "username")
  if username == "" { /* corrupted */ _ = auth.sessionManager.Destroy(r.Context()); return nil, errors.New("invalid session data") }
  projects, _ := auth.sessionManager.Get(r.Context(), "projects").([]string) // default []string
  roles, _ := auth.sessionManager.Get(r.Context(), "roles").([]string)
  if len(roles) == 0 { _ = auth.sessionManager.Destroy(r.Context()); return nil, errors.New("invalid session data") }
  authSource := auth.sessionManager.GetInt(r.Context(), "authSource") // 0 default == AuthViaLocalPassword
  return &schema.User{Username: username, Projects: projects, Roles: roles, AuthType: schema.AuthSession, AuthSource: schema.AuthSource(authSource)}, nil
  ```
- **`Logout()`** (lines 610–628): `_ = auth.sessionManager.Destroy(r.Context())` then
  call `onsuccess` (middleware clears the cookie). Drop all gorilla calls.

### 4. `cmd/cc-backend/server.go` — install LoadAndSave (scoped)
Grab the manager once: `sm := authHandle.SessionManager()`. Wrap **only** the
session groups (all inside the existing `if !config.Keys.DisableAuthentication`
guards so disabled-auth keeps no sessions):
- `/login`, `/jwt-login`, `/logout`: wrap each handler, e.g.
  `s.router.Post("/login", sm.LoadAndSave(authHandle.Login(loginFailureHandler)).ServeHTTP)`.
- Secured group (line 247): add `secured.Use(sm.LoadAndSave)` **before** the
  `authHandle.Auth` middleware (LoadAndSave must load the session into ctx first).
- Config group (line 310): add `configapi.Use(sm.LoadAndSave)` before `AuthConfigAPI`.
- Frontend group (line 320): add `frontendapi.Use(sm.LoadAndSave)` before `AuthFrontendAPI`.
- **Leave `/api`, `/api/metricstore`, `/userapi`, static files unwrapped.**

- **`AuthViaSession(rw, r)`** (lines 129–180): read from the context loaded by the
  read middleware; keep the same `*schema.User`/error return shape. On a read path
  there is no commit, so a corrupted session is cleared best-effort by writing an
  expired `session` cookie (small helper) rather than `Destroy`:
  ```go
  if !auth.sessionManager.Exists(r.Context(), "username") { return nil, nil } // mirrors session.IsNew
  username := auth.sessionManager.GetString(r.Context(), "username")
  if username == "" { expireSessionCookie(rw); return nil, errors.New("invalid session data") }
  projects, _ := auth.sessionManager.Get(r.Context(), "projects").([]string) // default []string
  roles, _ := auth.sessionManager.Get(r.Context(), "roles").([]string)
  if len(roles) == 0 { expireSessionCookie(rw); return nil, errors.New("invalid session data") }
  authSource := auth.sessionManager.GetInt(r.Context(), "authSource") // 0 default == AuthViaLocalPassword
  return &schema.User{Username: username, Projects: projects, Roles: roles, AuthType: schema.AuthSession, AuthSource: schema.AuthSource(authSource)}, nil
  ```
  where `expireSessionCookie(rw)` writes `&http.Cookie{Name:"session", Path:"/", MaxAge:-1, HttpOnly:true, SameSite:http.SameSiteLaxMode}`.
- **`Logout()`** (lines 610–628): logout is wrapped by `LoadAndSave`, so
  `_ = auth.sessionManager.Destroy(r.Context())` then call `onsuccess` — the
  middleware removes the store row and clears the cookie. Drop all gorilla calls.
- **Add the non-buffering read middleware** on the manager (or a small free func):
  ```go
  func (auth *Authentication) LoadSession(next http.Handler) http.Handler {
      return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
          token := ""
          if c, err := r.Cookie(auth.sessionManager.Cookie.Name); err == nil { token = c.Value }
          ctx, err := auth.sessionManager.Load(r.Context(), token) // absolute expiry enforced here
          if err != nil { http.Error(w, "session load failed", http.StatusInternalServerError); return }
          next.ServeHTTP(w, r.WithContext(ctx)) // no commit, no ResponseWriter wrap -> no buffering
      })
  }
  ```

### 4. `cmd/cc-backend/server.go` — hybrid wiring
Grab the manager once: `sm := authHandle.SessionManager()`. All wiring stays inside
the existing `if !config.Keys.DisableAuthentication` guards.
- **Write paths — `LoadAndSave`** (tiny responses): `/login`, `/jwt-login`,
  `/logout`, e.g. `s.router.Post("/login", sm.LoadAndSave(authHandle.Login(loginFailureHandler)).ServeHTTP)`.
- **Read paths — `authHandle.LoadSession`** (no buffering), added **before** the
  existing auth middleware so the session is in ctx when `AuthViaSession` runs:
  - Secured group (line 247): `secured.Use(authHandle.LoadSession)` before `authHandle.Auth`.
  - Config group (line 310): `configapi.Use(authHandle.LoadSession)` before `AuthConfigAPI`.
  - Frontend group (line 320): `frontendapi.Use(authHandle.LoadSession)` before `AuthFrontendAPI`.
- **Leave `/api`, `/api/metricstore`, `/userapi`, static files unwrapped** (JWT-only / public).

> `/query` lives in the secured group → covered by the non-buffering `LoadSession`,
> so large GraphQL payloads stream unchanged.

-  middleware; keep the same `*schema.User`/error return shape:

+  read middleware; keep the same `*schema.User`/error return shape. On a read path

+  there is no commit, so a corrupted session is cleared best-effort by writing an

+  expired `session` cookie (small helper) rather than `Destroy`:

-  if username == "" { /* corrupted */ _ = auth.sessionManager.Destroy(r.Context()); return nil, errors.New("invalid session data") }

+  if username == "" { expireSessionCookie(rw); return nil, errors.New("invalid session data") }

-  if len(roles) == 0 { _ = auth.sessionManager.Destroy(r.Context()); return nil, errors.New("invalid session data") }

+  if len(roles) == 0 { expireSessionCookie(rw); return nil, errors.New("invalid session data") }

-- **`Logout()`** (lines 610–628): `_ = auth.sessionManager.Destroy(r.Context())` then

-  call `onsuccess` (middleware clears the cookie). Drop all gorilla calls.

+  where `expireSessionCookie(rw)` writes `&http.Cookie{Name:"session", Path:"/", MaxAge:-1, HttpOnly:true, SameSite:http.SameSiteLaxMode}`.

+- **`Logout()`** (lines 610–628): logout is wrapped by `LoadAndSave`, so

+  `_ = auth.sessionManager.Destroy(r.Context())` then call `onsuccess` — the

+  middleware removes the store row and clears the cookie. Drop all gorilla calls.

+- **Add the non-buffering read middleware** on the manager (or a small free func):

+  func (auth *Authentication) LoadSession(next http.Handler) http.Handler {

+      return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

+          if c, err := r.Cookie(auth.sessionManager.Cookie.Name); err == nil { token = c.Value }

+          ctx, err := auth.sessionManager.Load(r.Context(), token) // absolute expiry enforced here

+          if err != nil { http.Error(w, "session load failed", http.StatusInternalServerError); return }

+          next.ServeHTTP(w, r.WithContext(ctx)) // no commit, no ResponseWriter wrap -> no buffering

-### 4. `cmd/cc-backend/server.go` — install LoadAndSave (scoped)

-Grab the manager once: `sm := authHandle.SessionManager()`. Wrap **only** the

-session groups (all inside the existing `if !config.Keys.DisableAuthentication`

-guards so disabled-auth keeps no sessions):

-- `/login`, `/jwt-login`, `/logout`: wrap each handler, e.g.

-  `s.router.Post("/login", sm.LoadAndSave(authHandle.Login(loginFailureHandler)).ServeHTTP)`.

-- Secured group (line 247): add `secured.Use(sm.LoadAndSave)` **before** the

-  `authHandle.Auth` middleware (LoadAndSave must load the session into ctx first).

-- Config group (line 310): add `configapi.Use(sm.LoadAndSave)` before `AuthConfigAPI`.

-- Frontend group (line 320): add `frontendapi.Use(sm.LoadAndSave)` before `AuthFrontendAPI`.

-- **Leave `/api`, `/api/metricstore`, `/userapi`, static files unwrapped.**

+### 4. `cmd/cc-backend/server.go` — hybrid wiring

+Grab the manager once: `sm := authHandle.SessionManager()`. All wiring stays inside

+the existing `if !config.Keys.DisableAuthentication` guards.

+- **Write paths — `LoadAndSave`** (tiny responses): `/login`, `/jwt-login`,

+  `/logout`, e.g. `s.router.Post("/login", sm.LoadAndSave(authHandle.Login(loginFailureHandler)).ServeHTTP)`.

+- **Read paths — `authHandle.LoadSession`** (no buffering), added **before** the

+  existing auth middleware so the session is in ctx when `AuthViaSession` runs:

+  - Secured group (line 247): `secured.Use(authHandle.LoadSession)` before `authHandle.Auth`.

+  - Config group (line 310): `configapi.Use(authHandle.LoadSession)` before `AuthConfigAPI`.

+  - Frontend group (line 320): `frontendapi.Use(authHandle.LoadSession)` before `AuthFrontendAPI`.

+- **Leave `/api`, `/api/metricstore`, `/userapi`, static files unwrapped** (JWT-only / public).

+> `/query` lives in the secured group → covered by the non-buffering `LoadSession`,

+> so large GraphQL payloads stream unchanged.

### 5. Frontend cleanup — `web/frontend/src/generic/utils.js`

In `init()` remove the dormant Bearer path: delete the `jwt` lookup (lines 24–26)

and the conditional `fetchOptions` Authorization header (line 31); construct the


The plan now reflects the hybrid, no-buffering-on-reads design. Summary of the finalized approach:

- **scs/v2 + sqlite3store** in the existing SQLite DB (sessions survive restarts); new migration #12, `Version`→12.
- **`SESSION_KEY` removed** (dead with server-side tokens).
- **Hybrid middleware**: `LoadAndSave` only on `/login`/`/jwt-login`/`/logout` (session writes, tiny responses); a custom non-buffering `LoadSession` on the secured/`/config`/`/frontend` read paths — so **GraphQL `/query` streams its large job-metric payloads with zero buffering**. JWT data APIs (`/api`, `/userapi`, `/api/metricstore`) and static files get no session middleware.
- **Endpoint model confirmed**: frontend → session, external → JWT, already matches; `/query` stays dual-auth.
- **Frontend cleanup**: drop the dormant Bearer branch in `utils.js` (keep the "Gen. JWT" feature).


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`, backed by the existing SQLite DB.

The packages differ architecturally:
- **gorilla `CookieStore`** is *stateless* — all data is encrypted into the cookie
  (signed with `SESSION_KEY`); sessions survive restarts while the key is stable.
- **scs/v2** is *server-side* — only a random token lives in the cookie; data lives
  in a store. No encryption key needed.

**Decisions made:**
- **Store: `sqlite3store`** in the existing SQLite DB (preserves "stay logged in
  across restarts"). Adds dep `github.com/alexedwards/scs/sqlite3store`.
- **Remove `SESSION_KEY`** — dead code with server-side tokens.
- **Hybrid middleware (performance-correct):** `LoadAndSave` only on the session
  *write* paths (login/jwt-login/logout — tiny responses); a custom **non-buffering**
  read-only load middleware on the session *read* paths (secured/config/frontend).
  The session is only ever written at login/logout, so the large `/query`
  GraphQL responses stream with **zero buffering** (no `ResponseWriter` wrap).
- **`/query` stays dual-auth** (session for the web UI, JWT for external GraphQL
  clients) — no change to `Auth()`.
- **Frontend cleanup:** remove the dormant Bearer-JWT branch in the urql client.

### Endpoint analysis (frontend=session, external=JWT)

The codebase already matches this model. Verified: the web UI's GraphQL client only
attaches `Authorization: Bearer` if a `jwt` exists in the `cc-config` context
(`web/frontend/src/generic/utils.js:24-31`), but nothing ever injects one — so the
UI authenticates `/query` via the **session cookie**; the Bearer branch is dead.

| Route group | Auth today | Caller | LoadAndSave? |
|---|---|---|---|
| `/query`, `/search`, pages `/`,`/config`,`/monitoring/*` (`Auth()`) | session (+JWT fallback) | web UI (+ external GraphQL) | **YES** |
| `/frontend/*` logs,jwt,configuration (`AuthFrontendAPI`) | session only | web UI | **YES** |
| `/config/*` admin (`AuthConfigAPI`) | session only | web UI (admin) | **YES** |
| `/login`, `/jwt-login`, `/logout` | session write | web UI | **YES** |
| `/api/*` (`AuthAPI`) | JWT only | external | **NO** |
| `/api/metricstore` (`AuthMetricStoreAPI`) | JWT only | external | **NO** |
| `/userapi/*` (`AuthUserAPI`) | JWT only | external | **NO** |
| static `/*`, `/img/*`, `/imprint`, `/privacy` | none | browser | **NO** |

`LoadAndSave` buffers the full response to inject the cookie, so it is used **only**
on the tiny login/logout write-paths. The read paths (incl. GraphQL `/query`, which
wraps large job-metric payloads) use a non-buffering load middleware and stream
normally. The JWT data APIs (`/api`, `/userapi`, `/api/metricstore`) get no session
middleware at all. `/frontend/jwt/` (mints a JWT for a logged-in user to use against
the external JWT APIs) is the intended session→JWT bridge and stays as-is.

> Why no buffering on reads: the session is mutated only at login (`SaveSession`)
> and logout (`Destroy`). Every other session route just reads username/roles to
> authenticate, so it needs the data loaded into the request context but never
> writes a cookie — hence no need to wrap/buffer the response.

## Changes

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

### 2. New DB migration — `sessions` table
`internal/repository/migrations/sqlite3/12_add-sessions-table.{up,down}.sql`:
```sql
-- up
CREATE TABLE sessions (
    token  TEXT PRIMARY KEY,
    data   BLOB NOT NULL,
    expiry REAL NOT NULL
);
CREATE INDEX sessions_expiry_idx ON sessions(expiry);
-- down
DROP TABLE IF EXISTS sessions;
```
Bump `Version` to `12` in `internal/repository/migration.go:28` + add a history
comment. `Connect()` only *checks* the version, so existing installs run
`-migrate-db`; `-init-db` (`cmd/cc-backend/init.go:83`) already migrates fresh DBs.

### 3. `internal/auth/auth.go` — the core swap
Replace the gorilla import with `github.com/alexedwards/scs/v2`,
`github.com/alexedwards/scs/sqlite3store`, `encoding/gob`,
`.../internal/repository`. Drop now-unused `crypto/rand`, `encoding/base64`, `os`
(verify before removing).

- **Struct** (line 121): `sessionStore *sessions.CookieStore` → `sessionManager *scs.SessionManager`.
- **Add accessor** so the router can install the middleware:
  `func (auth *Authentication) SessionManager() *scs.SessionManager { return auth.sessionManager }`
- **`Init()`** (lines 189–213): delete the whole `SESSION_KEY` block; replace with:
  ```go
  gob.Register([]string{}) // user.Projects / user.Roles are []string in session values
  sm := scs.New()
  sm.Store = sqlite3store.New(repository.GetConnection().DB.DB)
  sm.Cookie.Name = "session"
  sm.Cookie.Path = "/"
  sm.Cookie.HttpOnly = true
  sm.Cookie.SameSite = http.SameSiteLaxMode
  sm.Cookie.Secure = config.Keys.HTTPSCertFile != "" // see note
  if d, err := time.ParseDuration(config.Keys.SessionMaxAge); err == nil && d != 0 {
      sm.Lifetime = d
      authInstance.SessionMaxAge = d
  } // else keep a long default lifetime (config says 0 == "no expiry")
  authInstance.sessionManager = sm
  ```
  > Note on `Secure`: scs sets the flag globally (no per-request option like the old
  > code). Deriving it from `HTTPSCertFile` is actually *stricter* than today — the
  > current code only ever sets `Secure=false` and never `true` (gorilla's default is
  > `false`), so the flag is effectively never set now. Reverse-proxy-TLS deployments
  > that want `Secure` can get a follow-up config flag.
- **`SaveSession(rw, r, user)`** (lines 321–354): the login routes are wrapped by
  `LoadAndSave`, so operate on the request context; the middleware commits + writes
  the cookie:
  ```go
  if err := auth.sessionManager.RenewToken(r.Context()); err != nil { // session-fixation guard
      cclog.Errorf("session renew failed: %s", err.Error()); http.Error(...); return err
  }
  auth.sessionManager.Put(r.Context(), "username", user.Username)
  auth.sessionManager.Put(r.Context(), "projects", user.Projects)
  auth.sessionManager.Put(r.Context(), "roles", user.Roles)
  auth.sessionManager.Put(r.Context(), "authSource", int(user.AuthSource))
  return nil
  ```
- **`AuthViaSession(rw, r)`** (lines 129–180): read from the context loaded by the
  read middleware; keep the same `*schema.User`/error return shape. On a read path
  there is no commit, so a corrupted session is cleared best-effort by writing an
  expired `session` cookie (small helper) rather than `Destroy`:
  ```go
  if !auth.sessionManager.Exists(r.Context(), "username") { return nil, nil } // mirrors session.IsNew
  username := auth.sessionManager.GetString(r.Context(), "username")
  if username == "" { expireSessionCookie(rw); return nil, errors.New("invalid session data") }
  projects, _ := auth.sessionManager.Get(r.Context(), "projects").([]string) // default []string
  roles, _ := auth.sessionManager.Get(r.Context(), "roles").([]string)
  if len(roles) == 0 { expireSessionCookie(rw); return nil, errors.New("invalid session data") }
  authSource := auth.sessionManager.GetInt(r.Context(), "authSource") // 0 default == AuthViaLocalPassword
  return &schema.User{Username: username, Projects: projects, Roles: roles, AuthType: schema.AuthSession, AuthSource: schema.AuthSource(authSource)}, nil
  ```
  where `expireSessionCookie(rw)` writes `&http.Cookie{Name:"session", Path:"/", MaxAge:-1, HttpOnly:true, SameSite:http.SameSiteLaxMode}`.
- **`Logout()`** (lines 610–628): logout is wrapped by `LoadAndSave`, so
  `_ = auth.sessionManager.Destroy(r.Context())` then call `onsuccess` — the
  middleware removes the store row and clears the cookie. Drop all gorilla calls.
- **Add the non-buffering read middleware** on the manager (or a small free func):
  ```go
  func (auth *Authentication) LoadSession(next http.Handler) http.Handler {
      return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
          token := ""
          if c, err := r.Cookie(auth.sessionManager.Cookie.Name); err == nil { token = c.Value }
          ctx, err := auth.sessionManager.Load(r.Context(), token) // absolute expiry enforced here
          if err != nil { http.Error(w, "session load failed", http.StatusInternalServerError); return }
          next.ServeHTTP(w, r.WithContext(ctx)) // no commit, no ResponseWriter wrap -> no buffering
      })
  }
  ```

### 4. `cmd/cc-backend/server.go` — hybrid wiring
Grab the manager once: `sm := authHandle.SessionManager()`. All wiring stays inside
the existing `if !config.Keys.DisableAuthentication` guards.
- **Write paths — `LoadAndSave`** (tiny responses): `/login`, `/jwt-login`,
  `/logout`, e.g. `s.router.Post("/login", sm.LoadAndSave(authHandle.Login(loginFailureHandler)).ServeHTTP)`.
- **Read paths — `authHandle.LoadSession`** (no buffering), added **before** the
  existing auth middleware so the session is in ctx when `AuthViaSession` runs:
  - Secured group (line 247): `secured.Use(authHandle.LoadSession)` before `authHandle.Auth`.
  - Config group (line 310): `configapi.Use(authHandle.LoadSession)` before `AuthConfigAPI`.
  - Frontend group (line 320): `frontendapi.Use(authHandle.LoadSession)` before `AuthFrontendAPI`.
- **Leave `/api`, `/api/metricstore`, `/userapi`, static files unwrapped** (JWT-only / public).

> `/query` lives in the secured group → covered by the non-buffering `LoadSession`,
> so large GraphQL payloads stream unchanged.

### 5. Frontend cleanup — `web/frontend/src/generic/utils.js`
In `init()` remove the dormant Bearer path: delete the `jwt` lookup (lines 24–26)
and the conditional `fetchOptions` Authorization header (line 31); construct the
urql `Client` with just `url` + `exchanges`. **Keep** the user-facing
`/frontend/jwt/` fetch at line ~583 (the "Gen. JWT" copy feature for external API
use). Rebuild with `make frontend`.

### 6. Remove SESSION_KEY everywhere
- `.env:8`, `configs/env-template.txt:11`: delete the `SESSION_KEY=` line.
- `cmd/cc-backend/init.go`: remove `SESSION_KEY` from the embedded `.env` template.
- `internal/api/api_test.go`, `internal/api/nats_test.go`: remove the
  `t.Setenv("SESSION_KEY", ...)` calls.
- Update any `*.md`/docs referencing `SESSION_KEY`.

## Key files
- `internal/auth/auth.go` — struct, `SessionManager()`, `Init`, `SaveSession`, `AuthViaSession`, `Logout`
- `cmd/cc-backend/server.go` — scoped `LoadAndSave` wiring
- `internal/repository/migrations/sqlite3/12_add-sessions-table.{up,down}.sql` (new) + `migration.go` (`Version` → 12)
- `web/frontend/src/generic/utils.js` — drop dead Bearer branch
- `go.mod`/`go.sum`, `.env`, `configs/env-template.txt`, `cmd/cc-backend/init.go`, `internal/api/{api,nats}_test.go`

## Verification
1. `go build ./...`, `make` (incl. `make frontend`), `go vet ./...` clean.
2. `go mod tidy` removes gorilla/sessions; confirm gone from go.mod/go.sum.
3. `go test ./...` passes (esp. `internal/api`, `internal/auth`, `internal/repository`).
4. Fresh DB: `./cc-backend -init-db -add-user demo:admin:demo` then `-server -dev` → DB at version 12.
5. Existing DB: `-server` without migrating fails with the version-12 message; `-migrate-db` then succeeds.
6. Manual end-to-end (browser, `-dev`):
   - Log in → `session` cookie set; a row appears: `sqlite3 var/job.db 'select token,expiry from sessions;'`.
   - Web UI works: dashboard/job views (GraphQL `/query` via session), `/search`,
     `/config/*` admin actions, `/frontend/configuration/` saves, `/frontend/logs/`.
   - **Restart server, reload** → still logged in (durability preserved).
   - Log out → `sessions` row removed, cookie cleared, back to login page.
   - Cookie flags: `HttpOnly`, `SameSite=Lax` (and `Secure` when serving HTTPS).
   - External JWT still works: `/query` with a Bearer token, and `/api/*` /
     `/userapi/*` endpoints (these are NOT session-wrapped) — confirm large
     `/api/jobs/metrics/{id}` responses are not buffered/regressed.
   - OIDC / JWT-cookie login still establishes a session (they call `SaveSession`).