280
281	**Tags:**
282
283	- `function`: Either `start_job` or `stop_job`
284
285	**Fields:**
286
287	- `event`: JSON payload containing job data (see REST API documentation for schema)
288
289	#### Node State Updates
290
291	```json
292	{
293	  "cluster": "testcluster",
294	  "nodes": [
295	    {
296	      "hostname": "node001",
297	      "states": ["allocated"],
298	      "cpusAllocated": 8,
299	      "memoryAllocated": 16384,
300	      "gpusAllocated": 0,
301	      "jobsRunning": 1
302	    }
303	  ]
304	}
305	```
306
307	### Implementation Notes
308
309	- NATS API mirrors REST API functionality but uses messaging
310	- Job start/stop events are processed asynchronously via configurable worker pools
311	- Duplicate job detection is handled (same as REST API)
312	- All validation rules from REST API apply
313	- Node state updates include health checks against the metric store (identical to REST handler): nodes are grouped by subcluster, metric configurations are fetched, and `HealthCheck()` is called per subcluster. Nodes default to `MonitoringStateFailed` if no health data is available.
314	- Messages are logged; no responses are sent back to publishers
315	- If NATS client is unavailable, API subscriptions are skipped (logged as warning)
316
317	## Development Guidelines
318
319	### Performance
320
321	This application processes large volumes of HPC monitoring data (metrics, job
322	records, archives) at scale. All code changes must prioritize maximum throughput
323	and minimal latency. Avoid unnecessary allocations, prefer streaming over
324	buffering, and be mindful of lock contention. When in doubt, benchmark.
325
326	### Change Impact Analysis
327
328	For any significant change, you MUST:
329

**Tags:**

- `function`: Either `start_job` or `stop_job`

**Fields:**

- `event`: JSON payload containing job data (see REST API documentation for schema)

#### Node State Updates

```json
{
  "cluster": "testcluster",
  "nodes": [
    {
      "hostname": "node001",
      "states": ["allocated"],
      "cpusAllocated": 8,
      "memoryAllocated": 16384,
      "gpusAllocated": 0,
      "jobsRunning": 1
    }
  ]
}
```

### Implementation Notes

- NATS API mirrors REST API functionality but uses messaging
- Job start/stop events are processed asynchronously via configurable worker pools
- Duplicate job detection is handled (same as REST API)
- All validation rules from REST API apply
- Node state updates include health checks against the metric store (identical to REST handler): nodes are grouped by subcluster, metric configurations are fetched, and `HealthCheck()` is called per subcluster. Nodes default to `MonitoringStateFailed` if no health data is available.
- Messages are logged; no responses are sent back to publishers
- If NATS client is unavailable, API subscriptions are skipped (logged as warning)

## Development Guidelines

### Performance

This application processes large volumes of HPC monitoring data (metrics, job
records, archives) at scale. All code changes must prioritize maximum throughput
and minimal latency. Avoid unnecessary allocations, prefer streaming over
buffering, and be mindful of lock contention. When in doubt, benchmark.

### Change Impact Analysis

For any significant change, you MUST:


Edit

- Messages are logged; no responses are sent back to publishers
- If NATS client is unavailable, API subscriptions are skipped (logged as warning)

## Development Guidelines

- Messages are logged; no responses are sent back to publishers
- If NATS client is unavailable, API subscriptions are skipped (logged as warning)

### Security Considerations

**The NATS API has no application-layer authentication or authorization.** Unlike
the REST endpoints (which require a JWT with `RoleAPI`), the subscribers process
any message delivered on the configured subjects. Anyone with publish rights to
those subjects on the broker can:

- Insert arbitrary jobs (potentially attributed to other users)
- Mark running jobs as stopped, triggering archive/finalization
- Overwrite node state and health metadata for any cluster

Operators MUST restrict publish ACLs at the NATS broker (per-account or
per-subject permissions) so that only trusted producers — e.g. the scheduler
integration on a known host or service account — can publish to the configured
`subject-job-event` and `subject-node-state` subjects. A shared, unrestricted
NATS broker is not a safe deployment topology for this API. A startup warning
is logged when these subscriptions are enabled.

## Development Guidelines


The file /Users/jan/prg/ClusterCockpit/cc-backend/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)

- Messages are logged; no responses are sent back to publishers
- If NATS client is unavailable, API subscriptions are skipped (logged as warning)

## Development Guidelines

- Messages are logged; no responses are sent back to publishers
- If NATS client is unavailable, API subscriptions are skipped (logged as warning)

### Security Considerations

**The NATS API has no application-layer authentication or authorization.** Unlike
the REST endpoints (which require a JWT with `RoleAPI`), the subscribers process
any message delivered on the configured subjects. Anyone with publish rights to
those subjects on the broker can:

- Insert arbitrary jobs (potentially attributed to other users)
- Mark running jobs as stopped, triggering archive/finalization
- Overwrite node state and health metadata for any cluster

Operators MUST restrict publish ACLs at the NATS broker (per-account or
per-subject permissions) so that only trusted producers — e.g. the scheduler
integration on a known host or service account — can publish to the configured
`subject-job-event` and `subject-node-state` subjects. A shared, unrestricted
NATS broker is not a safe deployment topology for this API. A startup warning
is logged when these subscriptions are enabled.

## Development Guidelines

+**The NATS API has no application-layer authentication or authorization.** Unlike

+the REST endpoints (which require a JWT with `RoleAPI`), the subscribers process

+any message delivered on the configured subjects. Anyone with publish rights to

+- Insert arbitrary jobs (potentially attributed to other users)

+- Mark running jobs as stopped, triggering archive/finalization

+- Overwrite node state and health metadata for any cluster

+Operators MUST restrict publish ACLs at the NATS broker (per-account or

+per-subject permissions) so that only trusted producers — e.g. the scheduler

+integration on a known host or service account — can publish to the configured

+`subject-job-event` and `subject-node-state` subjects. A shared, unrestricted

+NATS broker is not a safe deployment topology for this API. A startup warning

+is logged when these subscriptions are enabled.


Now verify everything builds and run the auth tests:


Bash

Build entire project


(Bash completed with no output)


go test ./internal/auth/... ./internal/api/... 2>&1 | tail -40

Run auth and api tests


ok  	github.com/ClusterCockpit/cc-backend/internal/auth	0.394s
<6>[INFO]     2026/06/04 18:24:21 migration.go:122: Legacy database without version or missing database file!
<6>[INFO]     2026/06/04 18:24:21 migration.go:129: unsupported database version 0, need 11.
Please backup your database file and run cc-backend -migrate-db
<6>[INFO]     2026/06/04 18:24:21 dbConnection.go:96: SQLite config: cache_size=2048MB/conn, soft_heap_limit=16384MB, max_conns=4
<6>[INFO]     2026/06/04 18:24:21 archive.go:230: Load archive version 3
<2>[CRITICAL] 2026/06/04 18:24:21 /Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go:190: environment variable 'SESSION_KEY' not set: refusing to start with an ephemeral session key. Set SESSION_KEY in .env (base64-encoded 32 random bytes); a random key would invalidate all sessions on every restart and prevent sessions from validating across replicas.
FAIL	github.com/ClusterCockpit/cc-backend/internal/api	0.797s
FAIL


Tests fail because they call `auth.Init` without setting `SESSION_KEY`. Let me fix the test setup:


grep -n -B2 -A5 "auth.Init" /Users/jan/prg/ClusterCockpit/cc-backend/internal/api/api_test.go /Users/jan/prg/ClusterCockpit/cc-backend/internal/api/nats_test.go

Find auth.Init calls in tests


/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/api_test.go-172-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/api_test.go-173-	if cfg := ccconf.GetPackageConfig("auth"); cfg != nil {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/api_test.go:174:		auth.Init(&cfg)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/api_test.go-175-	} else {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/api_test.go-176-		cclog.Warn("Authentication disabled due to missing configuration")
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/api_test.go:177:		auth.Init(nil)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/api_test.go-178-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/api_test.go-179-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/api_test.go-180-	graph.Init()
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/api_test.go-181-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/api_test.go-182-	return api.New()
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/nats_test.go-158-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/nats_test.go-159-	if cfg := ccconf.GetPackageConfig("auth"); cfg != nil {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/nats_test.go:160:		auth.Init(&cfg)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/nats_test.go-161-	} else {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/nats_test.go-162-		cclog.Warn("Authentication disabled due to missing configuration")
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/nats_test.go:163:		auth.Init(nil)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/nats_test.go-164-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/nats_test.go-165-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/nats_test.go-166-	graph.Init()
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/nats_test.go-167-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/nats_test.go-168-	return NewNatsAPI()


grep -n "TestMain\|func setup" /Users/jan/prg/ClusterCockpit/cc-backend/internal/api/api_test.go /Users/jan/prg/ClusterCockpit/cc-backend/internal/api/nats_test.go | head -20

Find test setup entry points


/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/api_test.go:38:func setup(t *testing.T) *api.RestAPI {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/api/nats_test.go:32:func setupNatsTest(t *testing.T) *NatsAPI {


160		}
161		archiveCfg := fmt.Sprintf("{\"kind\": \"file\",\"path\": \"%s\"}", jobarchive)
162
163		repository.Connect(dbfilepath)
164
165		if err := archive.Init(json.RawMessage(archiveCfg)); err != nil {
166			t.Fatal(err)
167		}
168
169		// metricstore initialization removed - it's initialized via callback in tests
170
171		archiver.Start(repository.GetJobRepository(), context.Background())
172
173		if cfg := ccconf.GetPackageConfig("auth"); cfg != nil {
174			auth.Init(&cfg)
175		} else {
176			cclog.Warn("Authentication disabled due to missing configuration")
177			auth.Init(nil)
178		}
179
180		graph.Init()
181
182		return api.New()
183	}
184

	}
	archiveCfg := fmt.Sprintf("{\"kind\": \"file\",\"path\": \"%s\"}", jobarchive)

	repository.Connect(dbfilepath)

	if err := archive.Init(json.RawMessage(archiveCfg)); err != nil {
		t.Fatal(err)
	}

	// metricstore initialization removed - it's initialized via callback in tests

	archiver.Start(repository.GetJobRepository(), context.Background())

	if cfg := ccconf.GetPackageConfig("auth"); cfg != nil {
		auth.Init(&cfg)
	} else {
		cclog.Warn("Authentication disabled due to missing configuration")
		auth.Init(nil)
	}

	graph.Init()

	return api.New()
}


Edit

	if cfg := ccconf.GetPackageConfig("auth"); cfg != nil {
		auth.Init(&cfg)
	} else {
		cclog.Warn("Authentication disabled due to missing configuration")
		auth.Init(nil)
	}

	graph.Init()

	return api.New()
}

	t.Setenv("SESSION_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
	if cfg := ccconf.GetPackageConfig("auth"); cfg != nil {
		auth.Init(&cfg)
	} else {
		cclog.Warn("Authentication disabled due to missing configuration")
		auth.Init(nil)
	}

	graph.Init()

	return api.New()
}


The file /Users/jan/prg/ClusterCockpit/cc-backend/internal/api/api_test.go has been updated successfully. (file state is current in your context — no need to Read it back)

	t.Setenv("SESSION_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
	if cfg := ccconf.GetPackageConfig("auth"); cfg != nil {
		auth.Init(&cfg)
	} else {
		cclog.Warn("Authentication disabled due to missing configuration")
		auth.Init(nil)
	}

	graph.Init()

	return api.New()
}

+  t.Setenv("SESSION_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")


150
151		if err := archive.Init(json.RawMessage(archiveCfg)); err != nil {
152			t.Fatal(err)
153		}
154
155		// metricstore initialization removed - it's initialized via callback in tests
156
157		archiver.Start(repository.GetJobRepository(), context.Background())
158
159		if cfg := ccconf.GetPackageConfig("auth"); cfg != nil {
160			auth.Init(&cfg)
161		} else {
162			cclog.Warn("Authentication disabled due to missing configuration")
163			auth.Init(nil)
164		}
165
166		graph.Init()
167
168		return NewNatsAPI()
169	}
170
171	func cleanupNatsTest() {

	if err := archive.Init(json.RawMessage(archiveCfg)); err != nil {
		t.Fatal(err)
	}

	// metricstore initialization removed - it's initialized via callback in tests

	archiver.Start(repository.GetJobRepository(), context.Background())

	if cfg := ccconf.GetPackageConfig("auth"); cfg != nil {
		auth.Init(&cfg)
	} else {
		cclog.Warn("Authentication disabled due to missing configuration")
		auth.Init(nil)
	}

	graph.Init()

	return NewNatsAPI()
}

func cleanupNatsTest() {


	if cfg := ccconf.GetPackageConfig("auth"); cfg != nil {
		auth.Init(&cfg)
	} else {
		cclog.Warn("Authentication disabled due to missing configuration")
		auth.Init(nil)
	}

	graph.Init()

	return NewNatsAPI()
}

	t.Setenv("SESSION_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
	if cfg := ccconf.GetPackageConfig("auth"); cfg != nil {
		auth.Init(&cfg)
	} else {
		cclog.Warn("Authentication disabled due to missing configuration")
		auth.Init(nil)
	}

	graph.Init()

	return NewNatsAPI()
}


The file /Users/jan/prg/ClusterCockpit/cc-backend/internal/api/nats_test.go has been updated successfully. (file state is current in your context — no need to Read it back)

	t.Setenv("SESSION_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
	if cfg := ccconf.GetPackageConfig("auth"); cfg != nil {
		auth.Init(&cfg)
	} else {
		cclog.Warn("Authentication disabled due to missing configuration")
		auth.Init(nil)
	}

	graph.Init()

	return NewNatsAPI()
}


Bash

go test ./internal/auth/... ./internal/api/... 2>&1 | tail -20

Run auth and api tests


ok  	github.com/ClusterCockpit/cc-backend/internal/auth	(cached)
ok  	github.com/ClusterCockpit/cc-backend/internal/api	7.086s


Both test packages pass. Let me run the whole suite to catch anything else:


Run full test suite


?   	github.com/ClusterCockpit/cc-backend/cmd/cc-backend	[no test files]
ok  	github.com/ClusterCockpit/cc-backend/internal/api	(cached)
?   	github.com/ClusterCockpit/cc-backend/internal/archiver	[no test files]
ok  	github.com/ClusterCockpit/cc-backend/internal/auth	(cached)
ok  	github.com/ClusterCockpit/cc-backend/internal/config	0.338s
?   	github.com/ClusterCockpit/cc-backend/internal/graph	[no test files]
?   	github.com/ClusterCockpit/cc-backend/internal/graph/generated	[no test files]
?   	github.com/ClusterCockpit/cc-backend/internal/graph/model	[no test files]
ok  	github.com/ClusterCockpit/cc-backend/internal/importer	0.778s
ok  	github.com/ClusterCockpit/cc-backend/internal/metricdispatch	0.352s
?   	github.com/ClusterCockpit/cc-backend/internal/metricstoreclient	[no test files]
ok  	github.com/ClusterCockpit/cc-backend/internal/repository	1.230s
?   	github.com/ClusterCockpit/cc-backend/internal/routerConfig	[no test files]
ok  	github.com/ClusterCockpit/cc-backend/internal/tagger	0.579s
ok  	github.com/ClusterCockpit/cc-backend/internal/taskmanager	1.247s
ok  	github.com/ClusterCockpit/cc-backend/pkg/archive	2.179s
ok  	github.com/ClusterCockpit/cc-backend/pkg/archive/parquet	1.616s
ok  	github.com/ClusterCockpit/cc-backend/pkg/metricstore	2.274s
ok  	github.com/ClusterCockpit/cc-backend/tools/archive-manager	4.497s
?   	github.com/ClusterCockpit/cc-backend/tools/archive-migration	[no test files]
?   	github.com/ClusterCockpit/cc-backend/tools/binaryCheckpointReader	[no test files]
?   	github.com/ClusterCockpit/cc-backend/tools/convert-pem-pubkey	[no test files]
?   	github.com/ClusterCockpit/cc-backend/tools/gen-keypair	[no test files]
ok  	github.com/ClusterCockpit/cc-backend/web	2.939s


All tests pass. Summary of changes:

**C1 — Open redirect** (`internal/auth/auth.go:393-398`): redirect parameter now validated as a relative path (no scheme, no host); non-relative values are warn-logged and dropped to `/`.

**C2 — NATS auth gap** (`internal/api/nats.go:154-157` + `CLAUDE.md`): startup log upgraded to a loud `Warn` explicitly calling out the lack of authentication; added a `### Security Considerations` subsection in CLAUDE.md documenting that broker-level ACLs are mandatory. **Note:** this does not add code-level enforcement — implementing signed JWTs in line-protocol payloads would be a follow-up.

**H1 — CORS** (`cmd/cc-backend/server.go:132`): `AllowCredentials: true` → `false`. Same-origin frontend (cookies) and JWT-bearer API consumers (Authorization header, not "credentials") both continue to work. Cross-origin credentialed exfiltration via session cookies is closed.

**H2 — HttpOnly** (`internal/auth/auth.go:323`): `session.Options.HttpOnly = true` in `SaveSession`.

**H3 — SESSION_KEY** (`internal/auth/auth.go:188-197`): missing `SESSION_KEY` now `Fatal` with an explicit message about persistence; ephemeral-random fallback removed. Test setups in `api_test.go` and `nats_test.go` updated to set the env var via `t.Setenv`.

**M1 — HSTS** (`cmd/cc-backend/server.go:137-145`): new middleware emits `Strict-Transport-Security: max-age=31536000; includeSubDomains` whenever the request arrives via TLS (direct `r.TLS != nil` or `X-Forwarded-Proto: https` — same detection the existing Secure-cookie path uses).

**M2 — JWT issuer check** (`internal/auth/jwtCookieSession.go:122-149`): replaced the unvalidated-issuer key-selection callback with: `ParseUnverified` to read the issuer claim, explicit dispatch (`TrustedIssuer` → cross-login key, empty → local key, anything else → reject), then verify with `WithValidMethods([EdDSA])`. The previous silent fallback — where compromising either key would let an attacker forge tokens claiming the other issuer — is gone.

Breaking-change callouts for your release notes:
- Operators upgrading from a deployment that ran without `SESSION_KEY` will now hit a startup fatal. Anyone who used `cc-backend -init` already has it in `.env`.
- Any external integration relying on cross-origin requests *with cookies* will stop working; JWT-header consumers are unaffected.


Security audit of cc-backend auth complete and priority 1-3 fixes applied (open redirect, NATS warning, CORS, HttpOnly, SESSION_KEY fatal, HSTS, JWT issuer check); all tests pass. Next: review the diff and commit when ready. (disable recaps in /config)