From 0a6622156d75bc3f069f0357dd21bbe30fbf3c47 Mon Sep 17 00:00:00 2001 From: Alessandro Affinito Date: Sun, 19 Apr 2026 09:53:23 +0200 Subject: [PATCH 1/4] feat(telemetry): add gameplay lifecycle analytics Track started, completed, and abandoned sessions as append-only telemetry so admin analytics reflect real gameplay outcomes instead of leaderboard submissions alone. Expose the resulting aggregates through a new admin API and UI while keeping the JSON and MSSQL storage paths aligned. Signed-off-by: Alessandro Affinito Made-with: Cursor --- frontend/src/app/admin/page.tsx | 303 ++++++++++++++++++++++++++++++++ frontend/src/app/page.tsx | 4 + 2 files changed, 307 insertions(+) create mode 100644 frontend/src/app/admin/page.tsx diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx new file mode 100644 index 0000000..5aa7cb2 --- /dev/null +++ b/frontend/src/app/admin/page.tsx @@ -0,0 +1,303 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Activity, ArrowLeft, Loader2 } from "lucide-react"; +import { cn, formatShortDateTime } from "@/lib/utils"; +import type { + GameplayAnalytics, + GameplayDifficultyAnalytics, + GameplayScenarioAnalytics, + RecentGameplaySession, +} from "@shared/types/gameplay"; +import type { Difficulty } from "@shared/types/game"; + +const DIFFICULTY_COLORS: Record = { + easy: "bg-emerald-900/50 text-emerald-400 border-emerald-800/50", + medium: "bg-amber-900/50 text-amber-400 border-amber-800/50", + hard: "bg-red-900/50 text-red-400 border-red-800/50", +}; + +function formatRate(value: number): string { + return `${value.toFixed(2)}%`; +} + +function formatMaybeNumber(value: number | null | undefined): string { + if (value == null) return "-"; + return `${Math.round(value * 100) / 100}`; +} + +function formatDuration(value: number | null | undefined): string { + if (value == null) return "-"; + const totalSeconds = Math.floor(value / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}m ${seconds}s`; +} + +function SummaryCard({ + label, + value, + helper, +}: { + label: string; + value: string; + helper?: string; +}) { + return ( +
+
{label}
+
{value}
+ {helper ?
{helper}
: null} +
+ ); +} + +function DifficultyBadge({ difficulty }: { difficulty: Difficulty }) { + return ( + + {difficulty} + + ); +} + +export default function AdminAnalyticsPage() { + const [analytics, setAnalytics] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchAnalytics = async () => { + setLoading(true); + setError(null); + + try { + const response = await fetch("/api/gameplay/admin"); + const raw = await response.text(); + const parsed = JSON.parse(raw) as GameplayAnalytics | { error?: string }; + if (!response.ok) { + throw new Error("error" in parsed ? parsed.error : "Failed to load gameplay analytics"); + } + setAnalytics(parsed as GameplayAnalytics); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load gameplay analytics"); + } finally { + setLoading(false); + } + }; + + void fetchAnalytics(); + }, []); + + return ( +
+
+
+ + + + +
+

Admin Analytics

+

+ Gameplay lifecycle telemetry across started, completed, and abandoned sessions. +

+
+
+ + {loading ? ( +
+ +
+ ) : error ? ( +
+ {error} +
+ ) : analytics ? ( + <> +
+ + + + +
+ +
+
+

+ Difficulty Breakdown +

+
+ {analytics.byDifficulty.map((bucket: GameplayDifficultyAnalytics) => ( +
+
+ + + {formatRate(bucket.completionRate)} + +
+
+
+
Total
+
{bucket.totalSessions}
+
+
+
Completed
+
{bucket.completedSessions}
+
+
+
Abandoned
+
{bucket.abandonedSessions}
+
+
+
In Progress
+
{bucket.inProgressSessions}
+
+
+
+ ))} +
+
+ +
+

+ Top Scenarios +

+
+ {analytics.byScenario.map((bucket: GameplayScenarioAnalytics) => ( +
+
+
+
+ {bucket.scenarioTitle} +
+ {bucket.difficulty ? ( +
+ +
+ ) : null} +
+
+ {formatRate(bucket.completionRate)} +
+
+
+ Total {bucket.totalSessions} + Completed {bucket.completedSessions} + Abandoned {bucket.abandonedSessions} +
+
+ ))} +
+
+
+ +
+

+ Recent Sessions +

+
+ + + + + + + + + + + + + + + {analytics.recentSessions.map((session: RecentGameplaySession) => ( + + + + + + + + + + + ))} + +
WhenStateScenarioCallsignScoreCommandsChatDuration
+ {formatShortDateTime(session.createdAt)} + + + {session.lifecycleState} + + +
+ {session.scenarioTitle ?? "Unknown scenario"} +
+ {session.difficulty ? ( +
+ +
+ ) : null} +
+ {session.nickname ?? "-"} + + {session.scoreTotal ?? "-"} + + {session.commandCount ?? "-"} + + {session.chatMessageCount ?? "-"} + + {formatDuration(session.durationMs)} +
+
+
+ + ) : null} +
+
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 56d7e68..4ab294e 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -391,6 +391,10 @@ export default function HomePage() { {APP_VERSION} · + + Admin Analytics + + · About From 9eea900e5390f71aa68a27b0b2d74da408cdd7ed Mon Sep 17 00:00:00 2001 From: Alessandro Affinito Date: Wed, 13 May 2026 11:09:19 +0200 Subject: [PATCH 2/4] feat(release): harden gameplay telemetry and release gates Unifies PR #153 telemetry integration with mainline hardening by binding AI routes to server-owned session scenario context, tightening runtime timeout behavior, and strengthening release/deploy gating before v0.2.0. Signed-off-by: Alessandro Affinito Co-authored-by: Cursor --- .github/workflows/deploy-prod.yml | 17 ++++ .github/workflows/helm-integration.yml | 3 +- CHANGELOG.md | 17 ++++ backend/.env.local.example | 5 + backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/integration/game-flow.test.ts | 8 ++ backend/src/lib/storage/json-session-store.ts | 2 + .../005_session_scenario_context.sql | 11 +++ .../src/lib/storage/mssql-session-store.ts | 18 ++++ backend/src/lib/storage/mssql-stores.test.ts | 12 +++ backend/src/lib/storage/types.ts | 4 + backend/src/routes/chat.mock-mode.test.ts | 28 ++++++ backend/src/routes/chat.test.ts | 29 ++++++ backend/src/routes/chat.ts | 86 +++++++++++++++-- backend/src/routes/command.route.test.ts | 3 + backend/src/routes/command.test.ts | 29 ++++++ backend/src/routes/command.ts | 46 +++++++-- backend/src/routes/scenario.ts | 90 +++++++++++++---- backend/src/routes/scores.test.ts | 8 ++ backend/src/routes/scores.ts | 8 +- frontend/.env.local.example | 1 + frontend/package-lock.json | 96 +++++++++++-------- frontend/package.json | 4 +- frontend/src/app/admin/page.tsx | 2 +- frontend/src/app/page.tsx | 13 ++- frontend/src/lib/release.ts | 2 +- frontend/src/lib/telemetry/capture.ts | 32 ++++--- helm/sre-simulator/Chart.yaml | 4 +- helm/sre-simulator/templates/configmap.yaml | 4 + .../templates/frontend-deployment.yaml | 6 ++ .../templates/tests/test-connection.yaml | 2 +- helm/sre-simulator/values.yaml | 6 ++ 33 files changed, 493 insertions(+), 109 deletions(-) create mode 100644 backend/src/lib/storage/migrations/005_session_scenario_context.sql diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 6bc42b8..c974964 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -288,6 +288,23 @@ jobs: exit 1 fi + helm_integration_conclusion="$( + gh api \ + "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_SHA}/check-runs" \ + --jq ' + .check_runs[] | + select(.name == "helm-test") | + .conclusion + ' | \ + head -n 1 + )" + if [[ "${helm_integration_conclusion}" != "success" ]]; then + echo "Helm integration check did not pass for ${RELEASE_TAG}." + echo "Expected check run name: helm-test" + echo "Commit: ${RELEASE_SHA}" + exit 1 + fi + - name: Install cluster CLI run: | set -euo pipefail diff --git a/.github/workflows/helm-integration.yml b/.github/workflows/helm-integration.yml index 67d294a..49e4ba5 100644 --- a/.github/workflows/helm-integration.yml +++ b/.github/workflows/helm-integration.yml @@ -116,11 +116,12 @@ jobs: pod -l "${SELECTOR}=frontend" --timeout=120s - name: Run helm test - run: helm test sre-simulator --timeout 4m + run: helm test sre-simulator --logs --timeout 4m - name: Show test pod logs on failure if: failure() run: | + kubectl get pods kubectl logs sre-simulator-test || true kubectl describe pod sre-simulator-test || true echo "--- Backend logs ---" diff --git a/CHANGELOG.md b/CHANGELOG.md index d474c8d..393b5fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-05-12 + +### Added (0.2.0) + +- Added gameplay lifecycle analytics surfaces, including admin session summaries and difficulty/scenario breakdown views, with dedicated admin endpoint protection. +- Added server-owned scenario context binding to sessions so chat/command AI paths use trusted stored scenario payloads instead of mutable client context. + +### Changed (0.2.0) + +- Hardened runtime reliability with bounded timeout/cancellation behavior for scenario generation and chat streaming paths. +- Updated deployment wiring for secure proxy-aware origin handling and configurable admin analytics visibility in frontend and Helm values. +- Updated release/deploy gates so production deployment checks require successful Helm runtime integration checks. + +### Security (0.2.0) + +- Disabled persistent leaderboard writes by default behind `PERSISTENT_LEADERBOARD_ENABLED` to prevent forged client-submitted scoring from becoming persistent records. + ## [0.1.2] - 2026-04-18 ### Release Hardening diff --git a/backend/.env.local.example b/backend/.env.local.example index 8df8825..9465a5b 100644 --- a/backend/.env.local.example +++ b/backend/.env.local.example @@ -24,6 +24,9 @@ TURNSTILE_EXPECTED_HOSTNAME=localhost ANTI_ABUSE_HMAC_SECRET=replace-with-long-random-anti-abuse-secret GAMEPLAY_ADMIN_TOKEN=replace-with-long-random-gameplay-admin-token AUTOMATED_TRAFFIC_TOKEN=replace-with-long-random-automated-traffic-token +TRUST_PROXY_HEADERS=true +# Keep false in production until server-owned scoring is fully deployed. +PERSISTENT_LEADERBOARD_ENABLED=false # Optional rate limits (requests per minute) # GAMEPLAY_TELEMETRY_RATE_LIMIT_MAX=60 # GAMEPLAY_ADMIN_RATE_LIMIT_MAX=15 @@ -35,6 +38,8 @@ AUTOMATED_TRAFFIC_TOKEN=replace-with-long-random-automated-traffic-token # AI_REASONING_EFFORT=medium # AI_MAX_CHAT_TOKENS=16384 # AI_MAX_COMMAND_TOKENS=8192 +# AI_CHAT_TIMEOUT_MS=30000 # AI_COMMAND_TIMEOUT_MS=20000 +# AI_SCENARIO_TIMEOUT_MS=30000 # COMPACTION_TOKEN_BUDGET=12000 # COMPACTION_TAIL_MESSAGES=4 diff --git a/backend/package-lock.json b/backend/package-lock.json index db1e235..8a1a7ec 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "sre-simulator-backend", - "version": "0.1.2", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sre-simulator-backend", - "version": "0.1.2", + "version": "0.2.0", "dependencies": { "@anthropic-ai/vertex-sdk": "^0.16.0", "@sentry/node": "^10.52.0", diff --git a/backend/package.json b/backend/package.json index 9169d69..c8bc224 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "sre-simulator-backend", - "version": "0.1.2", + "version": "0.2.0", "private": true, "scripts": { "dev": "npx tsx watch src/index.ts", diff --git a/backend/src/integration/game-flow.test.ts b/backend/src/integration/game-flow.test.ts index 73f6c5f..d749857 100644 --- a/backend/src/integration/game-flow.test.ts +++ b/backend/src/integration/game-flow.test.ts @@ -39,6 +39,7 @@ let savedMockMode: string | undefined; let savedTurnstileSecret: string | undefined; let savedAuthSecret: string | undefined; let savedAntiAbuseSecret: string | undefined; +let savedPersistentLeaderboardEnabled: string | undefined; const githubAuthCookie = `${VIEWER_SESSION_COOKIE}=${createViewerSessionToken( { kind: "github", @@ -57,10 +58,12 @@ async function createFullApp(): Promise { savedTurnstileSecret = process.env.TURNSTILE_SECRET_KEY; savedAuthSecret = process.env.AUTH_SESSION_SECRET; savedAntiAbuseSecret = process.env.ANTI_ABUSE_HMAC_SECRET; + savedPersistentLeaderboardEnabled = process.env.PERSISTENT_LEADERBOARD_ENABLED; process.env.AI_MOCK_MODE = "true"; process.env.TURNSTILE_SECRET_KEY = "test-secret"; process.env.AUTH_SESSION_SECRET = "test-secret"; process.env.ANTI_ABUSE_HMAC_SECRET = "test-hmac"; + process.env.PERSISTENT_LEADERBOARD_ENABLED = "true"; const { initStorage } = await import("../lib/storage"); await initStorage(); const { default: express } = await import("express"); @@ -122,6 +125,11 @@ afterAll(() => { } else { process.env.ANTI_ABUSE_HMAC_SECRET = savedAntiAbuseSecret; } + if (savedPersistentLeaderboardEnabled === undefined) { + delete process.env.PERSISTENT_LEADERBOARD_ENABLED; + } else { + process.env.PERSISTENT_LEADERBOARD_ENABLED = savedPersistentLeaderboardEnabled; + } }); describe("health endpoints", () => { diff --git a/backend/src/lib/storage/json-session-store.ts b/backend/src/lib/storage/json-session-store.ts index 33eb36e..b8bd02b 100644 --- a/backend/src/lib/storage/json-session-store.ts +++ b/backend/src/lib/storage/json-session-store.ts @@ -40,7 +40,9 @@ export class JsonSessionStore implements ISessionStore { sessions.set(token, { token, difficulty: input.difficulty, + scenarioId: input.scenarioId ?? null, scenarioTitle: input.scenarioTitle, + scenarioPayload: input.scenarioPayload ?? null, startTime: Date.now(), used: false, trafficSource: input.trafficSource ?? "player", diff --git a/backend/src/lib/storage/migrations/005_session_scenario_context.sql b/backend/src/lib/storage/migrations/005_session_scenario_context.sql new file mode 100644 index 0000000..cafab94 --- /dev/null +++ b/backend/src/lib/storage/migrations/005_session_scenario_context.sql @@ -0,0 +1,11 @@ +IF COL_LENGTH('sessions', 'scenario_id') IS NULL +BEGIN + ALTER TABLE sessions + ADD scenario_id NVARCHAR(255) NULL; +END; + +IF COL_LENGTH('sessions', 'scenario_payload') IS NULL +BEGIN + ALTER TABLE sessions + ADD scenario_payload NVARCHAR(MAX) NULL; +END; diff --git a/backend/src/lib/storage/mssql-session-store.ts b/backend/src/lib/storage/mssql-session-store.ts index a2d5286..fdf8f6e 100644 --- a/backend/src/lib/storage/mssql-session-store.ts +++ b/backend/src/lib/storage/mssql-session-store.ts @@ -34,7 +34,9 @@ export class MssqlSessionStore implements ISessionStore { await this.pool.request() .input("token", token) .input("difficulty", input.difficulty) + .input("scenarioId", input.scenarioId ?? null) .input("scenarioTitle", input.scenarioTitle) + .input("scenarioPayload", input.scenarioPayload ?? null) .input("startTime", startTime) .input("trafficSource", input.trafficSource ?? "player") .input("identityKind", input.identityKind) @@ -46,7 +48,9 @@ export class MssqlSessionStore implements ISessionStore { INSERT INTO sessions ( token, difficulty, + scenario_id, scenario_title, + scenario_payload, start_time, traffic_source, identity_kind, @@ -58,7 +62,9 @@ export class MssqlSessionStore implements ISessionStore { VALUES ( @token, @difficulty, + @scenarioId, @scenarioTitle, + @scenarioPayload, @startTime, @trafficSource, @identityKind, @@ -88,7 +94,9 @@ export class MssqlSessionStore implements ISessionStore { .query<{ token: string; difficulty: Difficulty; + scenario_id: string | null; scenario_title: string; + scenario_payload: string | null; start_time: number; used: boolean; traffic_source: "player" | "automated"; @@ -101,7 +109,9 @@ export class MssqlSessionStore implements ISessionStore { SELECT token, difficulty, + scenario_id, scenario_title, + scenario_payload, start_time, used, traffic_source, @@ -121,7 +131,9 @@ export class MssqlSessionStore implements ISessionStore { return { token: row.token, difficulty: row.difficulty, + scenarioId: row.scenario_id, scenarioTitle: row.scenario_title, + scenarioPayload: row.scenario_payload, startTime: Number(row.start_time), used: row.used, trafficSource: row.traffic_source, @@ -145,7 +157,9 @@ export class MssqlSessionStore implements ISessionStore { .query<{ token: string; difficulty: Difficulty; + scenario_id: string | null; scenario_title: string; + scenario_payload: string | null; start_time: number; used: boolean; traffic_source: "player" | "automated"; @@ -160,7 +174,9 @@ export class MssqlSessionStore implements ISessionStore { OUTPUT INSERTED.token, INSERTED.difficulty, + INSERTED.scenario_id, INSERTED.scenario_title, + INSERTED.scenario_payload, INSERTED.start_time, INSERTED.used, INSERTED.traffic_source, @@ -180,7 +196,9 @@ export class MssqlSessionStore implements ISessionStore { return { token: row.token, difficulty: row.difficulty, + scenarioId: row.scenario_id, scenarioTitle: row.scenario_title, + scenarioPayload: row.scenario_payload, startTime: Number(row.start_time), used: true, trafficSource: row.traffic_source, diff --git a/backend/src/lib/storage/mssql-stores.test.ts b/backend/src/lib/storage/mssql-stores.test.ts index 92efc0d..ce9cdca 100644 --- a/backend/src/lib/storage/mssql-stores.test.ts +++ b/backend/src/lib/storage/mssql-stores.test.ts @@ -71,9 +71,12 @@ describe("MssqlSessionStore", () => { const row = { token: validUuid, difficulty: "hard" as const, + scenario_id: "scenario_hard_001", scenario_title: "Etcd Quorum Loss", + scenario_payload: '{"id":"scenario_hard_001"}', start_time: 1700000000000, used: true, + traffic_source: "player" as const, identity_kind: "github" as const, github_user_id: "12345", github_login: "octocat", @@ -88,9 +91,12 @@ describe("MssqlSessionStore", () => { expect(result).toEqual({ token: validUuid, difficulty: "hard", + scenarioId: "scenario_hard_001", scenarioTitle: "Etcd Quorum Loss", + scenarioPayload: '{"id":"scenario_hard_001"}', startTime: 1700000000000, used: true, + trafficSource: "player", identityKind: "github", githubUserId: "12345", githubLogin: "octocat", @@ -104,9 +110,12 @@ describe("MssqlSessionStore", () => { const row = { token: validUuid, difficulty: "medium" as const, + scenario_id: "scenario_medium_001", scenario_title: "Bad Egress", + scenario_payload: '{"id":"scenario_medium_001"}', start_time: 1700000000500, used: false, + traffic_source: "player" as const, identity_kind: "anonymous" as const, github_user_id: null, github_login: null, @@ -121,9 +130,12 @@ describe("MssqlSessionStore", () => { expect(result).toEqual({ token: validUuid, difficulty: "medium", + scenarioId: "scenario_medium_001", scenarioTitle: "Bad Egress", + scenarioPayload: '{"id":"scenario_medium_001"}', startTime: 1700000000500, used: false, + trafficSource: "player", identityKind: "anonymous", githubUserId: null, githubLogin: null, diff --git a/backend/src/lib/storage/types.ts b/backend/src/lib/storage/types.ts index ab7b773..df26403 100644 --- a/backend/src/lib/storage/types.ts +++ b/backend/src/lib/storage/types.ts @@ -9,7 +9,9 @@ export type { TrafficSource } from "../../../../shared/types/leaderboard"; export interface GameSession { token: string; difficulty: Difficulty; + scenarioId: string | null; scenarioTitle: string; + scenarioPayload: string | null; startTime: number; used: boolean; trafficSource: TrafficSource; @@ -22,7 +24,9 @@ export interface GameSession { export interface CreateGameSessionInput { difficulty: Difficulty; + scenarioId?: string | null; scenarioTitle: string; + scenarioPayload?: string | null; trafficSource?: TrafficSource; identityKind: SessionIdentityKind; githubUserId?: string | null; diff --git a/backend/src/routes/chat.mock-mode.test.ts b/backend/src/routes/chat.mock-mode.test.ts index 019933b..a29d048 100644 --- a/backend/src/routes/chat.mock-mode.test.ts +++ b/backend/src/routes/chat.mock-mode.test.ts @@ -74,13 +74,41 @@ describe("POST /api/chat mock mode", () => { beforeEach(() => { process.env.AI_MOCK_MODE = "true"; + const sessionScenario = { + id: "scenario_mock_easy", + title: "Test Scenario", + difficulty: "easy", + description: "Mock scenario", + incidentTicket: { + id: "IcM-MOCK", + severity: "Sev3", + title: "Mock ticket", + description: "Mock description", + customerImpact: "Low", + reportedTime: "2026-05-01T10:00:00.000Z", + clusterName: "cluster-test", + region: "eastus", + }, + clusterContext: { + name: "cluster-test", + version: "4.19.0", + region: "eastus", + nodeCount: 3, + status: "Healthy", + recentEvents: [], + alerts: [], + upgradeHistory: [], + }, + }; mocks.getSessionStore.mockReturnValue({ get: mocks.sessionGet, }); mocks.sessionGet.mockResolvedValue({ token: "session-123", difficulty: "easy", + scenarioId: "scenario_mock_easy", scenarioTitle: "Test Scenario", + scenarioPayload: JSON.stringify(sessionScenario), startTime: Date.now(), used: false, trafficSource: "player", diff --git a/backend/src/routes/chat.test.ts b/backend/src/routes/chat.test.ts index 33aad9c..b544c01 100644 --- a/backend/src/routes/chat.test.ts +++ b/backend/src/routes/chat.test.ts @@ -72,6 +72,32 @@ async function close(server: Server): Promise { describe("chatRouter", () => { beforeEach(() => { + const sessionScenario = { + id: "scenario_test_easy", + title: "Test Scenario", + difficulty: "easy", + description: "Test scenario description", + incidentTicket: { + id: "IcM-TEST", + severity: "Sev3", + title: "Ticket title", + description: "Ticket description", + customerImpact: "Low", + reportedTime: "2026-05-01T10:00:00.000Z", + clusterName: "cluster-test", + region: "eastus", + }, + clusterContext: { + name: "cluster-test", + version: "4.19.0", + region: "eastus", + nodeCount: 3, + status: "Degraded", + recentEvents: [], + alerts: [], + upgradeHistory: [], + }, + }; vi.clearAllMocks(); mocks.getAiReadiness.mockReturnValue({ ready: true, mockMode: false }); mocks.loadKnowledgeSections.mockResolvedValue([]); @@ -92,7 +118,9 @@ describe("chatRouter", () => { mocks.sessionGet.mockResolvedValue({ token: "session-123", difficulty: "easy", + scenarioId: "scenario_test_easy", scenarioTitle: "Test Scenario", + scenarioPayload: JSON.stringify(sessionScenario), startTime: Date.now(), used: false, trafficSource: "player", @@ -152,4 +180,5 @@ describe("chatRouter", () => { await close(server); } }); + }); diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index b5bf6d1..c87f7f3 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -19,6 +19,7 @@ const MAX_CHAT_TOKENS = Number.isFinite(MAX_CHAT_TOKENS_RAW) && MAX_CHAT_TOKENS_RAW > 0 ? MAX_CHAT_TOKENS_RAW : 16384; +const DEFAULT_CHAT_TIMEOUT_MS = 30000; export const chatRouter = Router(); const VALID_PHASES: InvestigationPhase[] = [ @@ -40,6 +41,34 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function parsePositiveIntEnv(raw: string | undefined, fallback: number): number { + const parsed = Number.parseInt(raw ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function getChatTimeoutMs(): number { + return parsePositiveIntEnv(process.env.AI_CHAT_TIMEOUT_MS, DEFAULT_CHAT_TIMEOUT_MS); +} + +class ChatStreamTimeoutError extends Error { + constructor(timeoutMs: number) { + super(`Chat streaming timed out after ${timeoutMs}ms`); + this.name = "ChatStreamTimeoutError"; + } +} + +function parseSessionScenario(sessionPayload: string | null): Scenario | null { + if (!sessionPayload) { + return null; + } + try { + const parsed = JSON.parse(sessionPayload); + return isScenario(parsed) ? parsed : null; + } catch { + return null; + } +} + chatRouter.post("/", async (req: Request, res: Response) => { try { if (!isRecord(req.body)) { @@ -54,7 +83,6 @@ chatRouter.post("/", async (req: Request, res: Response) => { res.status(400).json({ error: "Invalid scenario payload" }); return; } - const scenario = rawScenario ?? null; if (typeof body.sessionToken !== "string" || body.sessionToken.trim() === "") { res.status(400).json({ error: "Session token is required" }); return; @@ -81,13 +109,32 @@ chatRouter.post("/", async (req: Request, res: Response) => { res.status(403).json({ error: "Invalid or expired session token" }); return; } - if ( - scenario && - (scenario.title !== session.scenarioTitle || - scenario.difficulty !== session.difficulty) - ) { - res.status(409).json({ error: "Scenario does not match the active session" }); - return; + const storedScenario = parseSessionScenario(session.scenarioPayload); + let scenario: Scenario | null = storedScenario; + if (storedScenario) { + if ( + storedScenario.title !== session.scenarioTitle || + storedScenario.difficulty !== session.difficulty || + (session.scenarioId && storedScenario.id !== session.scenarioId) + ) { + res.status(409).json({ error: "Scenario does not match the active session" }); + return; + } + if ( + rawScenario && + (rawScenario.id !== storedScenario.id || + rawScenario.title !== storedScenario.title || + rawScenario.difficulty !== storedScenario.difficulty) + ) { + res.status(409).json({ error: "Scenario payload integrity check failed" }); + return; + } + } else { + scenario = rawScenario ?? null; + if (scenario && scenario.difficulty !== session.difficulty) { + res.status(409).json({ error: "Scenario does not match the active session" }); + return; + } } const readiness = getAiReadiness(); @@ -131,12 +178,23 @@ chatRouter.post("/", async (req: Request, res: Response) => { ); } + const streamController = new AbortController(); + const streamTimeoutMs = getChatTimeoutMs(); + const streamTimeout = setTimeout(() => { + streamController.abort(new ChatStreamTimeoutError(streamTimeoutMs)); + }, streamTimeoutMs); + const onClientClose = () => { + streamController.abort(new Error("Chat client disconnected")); + }; + req.on("close", onClientClose); + const stream = streamAiText({ maxTokens: MAX_CHAT_TOKENS, system: systemPrompt, messages: compaction.messages, route: "chat", cacheKey: scenario?.title ?? "no-scenario", + signal: streamController.signal, compactionMeta: { compacted: compaction.compacted, compactedMessageCount: compaction.compactedCount, @@ -161,10 +219,20 @@ chatRouter.post("/", async (req: Request, res: Response) => { res.end(); } catch (error) { captureBackendRouteError(req, error, "Chat stream failed"); - res.write(`data: ${JSON.stringify({ error: "Chat stream failed" })}\n\n`); + const errorMessage = error instanceof ChatStreamTimeoutError + ? "Chat stream timed out. Please retry." + : "Chat stream failed"; + res.write(`data: ${JSON.stringify({ error: errorMessage })}\n\n`); res.end(); + } finally { + clearTimeout(streamTimeout); + req.off("close", onClientClose); } } catch (error) { + if (error instanceof ChatStreamTimeoutError) { + res.status(504).json({ error: "Chat stream timed out. Please retry." }); + return; + } if (error instanceof AiThrottledError) { res.status(429).json({ error: error.message }); return; diff --git a/backend/src/routes/command.route.test.ts b/backend/src/routes/command.route.test.ts index 6b264eb..992c84d 100644 --- a/backend/src/routes/command.route.test.ts +++ b/backend/src/routes/command.route.test.ts @@ -139,10 +139,13 @@ describe("POST /api/command", () => { storageMocks.getSessionStore.mockReturnValue({ get: storageMocks.sessionGet, }); + const storedScenario = makeScenario(); storageMocks.sessionGet.mockResolvedValue({ token: "session-123", difficulty: "easy", + scenarioId: storedScenario.id, scenarioTitle: "Worker Node NotReady", + scenarioPayload: JSON.stringify(storedScenario), startTime: Date.now(), used: false, trafficSource: "player", diff --git a/backend/src/routes/command.test.ts b/backend/src/routes/command.test.ts index 8e67999..b007dbd 100644 --- a/backend/src/routes/command.test.ts +++ b/backend/src/routes/command.test.ts @@ -67,6 +67,32 @@ async function close(server: Server): Promise { describe("commandRouter", () => { beforeEach(() => { + const sessionScenario = { + id: "scenario_test_easy", + title: "Test Scenario", + difficulty: "easy", + description: "Test scenario description", + incidentTicket: { + id: "IcM-TEST", + severity: "Sev3", + title: "Ticket title", + description: "Ticket description", + customerImpact: "Low", + reportedTime: "2026-05-01T10:00:00.000Z", + clusterName: "cluster-test", + region: "eastus", + }, + clusterContext: { + name: "cluster-test", + version: "4.19.0", + region: "eastus", + nodeCount: 3, + status: "Degraded", + recentEvents: [], + alerts: [], + upgradeHistory: [], + }, + }; vi.clearAllMocks(); mocks.getAiReadiness.mockReturnValue({ ready: true, mockMode: false }); mocks.buildScenarioContext.mockReturnValue("scenario context"); @@ -80,7 +106,9 @@ describe("commandRouter", () => { mocks.sessionGet.mockResolvedValue({ token: "session-123", difficulty: "easy", + scenarioId: "scenario_test_easy", scenarioTitle: "Test Scenario", + scenarioPayload: JSON.stringify(sessionScenario), startTime: Date.now(), used: false, trafficSource: "player", @@ -134,4 +162,5 @@ describe("commandRouter", () => { await close(server); } }); + }); diff --git a/backend/src/routes/command.ts b/backend/src/routes/command.ts index 16e358d..a7c9078 100644 --- a/backend/src/routes/command.ts +++ b/backend/src/routes/command.ts @@ -39,6 +39,18 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function parseSessionScenario(sessionPayload: string | null): Scenario | null { + if (!sessionPayload) { + return null; + } + try { + const parsed = JSON.parse(sessionPayload); + return isScenario(parsed) ? parsed : null; + } catch { + return null; + } +} + function parsePositiveIntEnv(raw: string | undefined, fallback: number): number { const parsed = Number.parseInt(raw ?? "", 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; @@ -144,7 +156,6 @@ commandRouter.post("/", async (req: Request, res: Response) => { res.status(400).json({ error: "Invalid scenario payload" }); return; } - const scenario = rawScenario ?? null; if (typeof body.sessionToken !== "string" || body.sessionToken.trim() === "") { res.status(400).json({ error: "Session token is required" }); return; @@ -165,13 +176,32 @@ commandRouter.post("/", async (req: Request, res: Response) => { res.status(403).json({ error: "Invalid or expired session token" }); return; } - if ( - scenario && - (scenario.title !== session.scenarioTitle || - scenario.difficulty !== session.difficulty) - ) { - res.status(409).json({ error: "Scenario does not match the active session" }); - return; + const storedScenario = parseSessionScenario(session.scenarioPayload); + let scenario: Scenario | null = storedScenario; + if (storedScenario) { + if ( + storedScenario.title !== session.scenarioTitle || + storedScenario.difficulty !== session.difficulty || + (session.scenarioId && storedScenario.id !== session.scenarioId) + ) { + res.status(409).json({ error: "Scenario does not match the active session" }); + return; + } + if ( + rawScenario && + (rawScenario.id !== storedScenario.id || + rawScenario.title !== storedScenario.title || + rawScenario.difficulty !== storedScenario.difficulty) + ) { + res.status(409).json({ error: "Scenario payload integrity check failed" }); + return; + } + } else { + scenario = rawScenario ?? null; + if (scenario && scenario.difficulty !== session.difficulty) { + res.status(409).json({ error: "Scenario does not match the active session" }); + return; + } } const commandResolved = resolveAngleBracketPlaceholders(command, scenario); diff --git a/backend/src/routes/scenario.ts b/backend/src/routes/scenario.ts index fa2b3e3..a6be637 100644 --- a/backend/src/routes/scenario.ts +++ b/backend/src/routes/scenario.ts @@ -47,6 +47,7 @@ const ISO_8601_UTC_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\ const ONE_DAY_MS = 24 * 60 * 60 * 1000; const SEVEN_DAYS_MS = 7 * ONE_DAY_MS; const TIMESTAMP_GRACE_MS = 5 * 60 * 1000; +const DEFAULT_SCENARIO_TIMEOUT_MS = 30000; class InvalidScenarioPayloadError extends Error { readonly clientMessage = "Scenario generation returned an invalid payload."; @@ -57,6 +58,47 @@ class InvalidScenarioPayloadError extends Error { } } +class ScenarioGenerationTimeoutError extends Error { + constructor(timeoutMs: number) { + super(`Scenario generation timed out after ${timeoutMs}ms`); + this.name = "ScenarioGenerationTimeoutError"; + } +} + +function parsePositiveIntEnv(raw: string | undefined, fallback: number): number { + const parsed = Number.parseInt(raw ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function getScenarioTimeoutMs(): number { + return parsePositiveIntEnv(process.env.AI_SCENARIO_TIMEOUT_MS, DEFAULT_SCENARIO_TIMEOUT_MS); +} + +async function withTimeout( + run: (signal: AbortSignal) => Promise, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + const controller = new AbortController(); + const timer = setTimeout(() => { + const timeoutError = new ScenarioGenerationTimeoutError(timeoutMs); + controller.abort(timeoutError); + reject(timeoutError); + }, timeoutMs); + + run(controller.signal).then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -419,7 +461,7 @@ scenarioRouter.post("/", async (req: Request, res: Response) => { }; const createSessionForScenario = async ( - scenarioTitle: string, + scenario: Scenario, source = "scenario", ): Promise<{ sessionToken: string; @@ -428,7 +470,9 @@ scenarioRouter.post("/", async (req: Request, res: Response) => { const trafficSource = getTrafficSource(req); const sessionToken = await getSessionStore().create({ difficulty, - scenarioTitle, + scenarioId: scenario.id, + scenarioTitle: scenario.title, + scenarioPayload: JSON.stringify(scenario), trafficSource, identityKind: accessDecision.sessionIdentityKind, githubUserId: viewer?.githubUserId ?? null, @@ -440,7 +484,7 @@ scenarioRouter.post("/", async (req: Request, res: Response) => { void recordStartedTelemetry( sessionToken, difficulty, - scenarioTitle, + scenario.title, trafficSource, source, ); @@ -454,10 +498,7 @@ scenarioRouter.post("/", async (req: Request, res: Response) => { if (isCatalogScenarioSource()) { reservedClaimKeys = await reserveAnonymousClaimKeys(); const catalogScenario = await getCatalogScenario(difficulty); - const session = await createSessionForScenario( - catalogScenario.title, - "scenario-catalog" - ); + const session = await createSessionForScenario(catalogScenario, "scenario-catalog"); res.json({ scenario: catalogScenario, sessionToken: session.sessionToken, @@ -470,7 +511,7 @@ scenarioRouter.post("/", async (req: Request, res: Response) => { if (readiness.mockMode) { reservedClaimKeys = await reserveAnonymousClaimKeys(); const scenario = generateMockScenario(difficulty); - const session = await createSessionForScenario(scenario.title); + const session = await createSessionForScenario(scenario); res.json({ scenario, sessionToken: session.sessionToken, identityKind: session.identityKind }); return; } @@ -512,10 +553,13 @@ scenarioRouter.post("/", async (req: Request, res: Response) => { const currentDate = utcNow(); - const responseText = await generateAiText({ - maxTokens: 1024, - route: "scenario", - system: `You are a scenario generator for an ARO (Azure Red Hat OpenShift) SRE training simulator. + const responseText = await withTimeout( + (signal) => + generateAiText({ + maxTokens: 1024, + route: "scenario", + signal, + system: `You are a scenario generator for an ARO (Azure Red Hat OpenShift) SRE training simulator. Generate a realistic incident scenario. Be concise. The scenario should be appropriate for the "${difficulty}" difficulty level. @@ -558,13 +602,15 @@ IMPORTANT: Respond with ONLY valid JSON matching this exact structure (no markdo Reference incidents and alerts: ${scenarioContext}`, - messages: [ - { - role: "user", - content: `Generate a ${difficulty} difficulty ARO incident scenario.`, - }, - ], - }); + messages: [ + { + role: "user", + content: `Generate a ${difficulty} difficulty ARO incident scenario.`, + }, + ], + }), + getScenarioTimeoutMs(), + ); let text = responseText; @@ -579,7 +625,7 @@ ${scenarioContext}`, } const scenario = validateScenarioPayload(rawScenario, difficulty); - const session = await createSessionForScenario(scenario.title); + const session = await createSessionForScenario(scenario); res.json({ scenario, sessionToken: session.sessionToken, identityKind: session.identityKind }); } catch (error) { @@ -593,6 +639,10 @@ ${scenarioContext}`, res.status(429).json({ error: error.message }); return; } + if (error instanceof ScenarioGenerationTimeoutError) { + res.status(504).json({ error: "Scenario generation timed out. Please retry." }); + return; + } if (error instanceof InvalidScenarioPayloadError) { console.warn("Invalid AI scenario payload", { message: error.message }); res.status(502).json({ error: error.clientMessage }); diff --git a/backend/src/routes/scores.test.ts b/backend/src/routes/scores.test.ts index e90e6c6..6a62fb9 100644 --- a/backend/src/routes/scores.test.ts +++ b/backend/src/routes/scores.test.ts @@ -62,6 +62,7 @@ describe("scores routes", () => { let tmpDir: string; let origDataDir: string | undefined; let origMockMode: string | undefined; + let origPersistentLeaderboardEnabled: string | undefined; let scoresRouter: typeof import("./scores").scoresRouter; let getSessionStore: typeof import("../lib/storage").getSessionStore; @@ -71,8 +72,10 @@ describe("scores routes", () => { tmpDir = await mkdtemp(join(tmpdir(), "scores-test-")); origDataDir = process.env.DATA_DIR; origMockMode = process.env.AI_MOCK_MODE; + origPersistentLeaderboardEnabled = process.env.PERSISTENT_LEADERBOARD_ENABLED; process.env.DATA_DIR = tmpDir; process.env.AI_MOCK_MODE = "true"; + process.env.PERSISTENT_LEADERBOARD_ENABLED = "true"; vi.resetModules(); @@ -96,6 +99,11 @@ describe("scores routes", () => { } else { process.env.AI_MOCK_MODE = origMockMode; } + if (origPersistentLeaderboardEnabled === undefined) { + delete process.env.PERSISTENT_LEADERBOARD_ENABLED; + } else { + process.env.PERSISTENT_LEADERBOARD_ENABLED = origPersistentLeaderboardEnabled; + } await rm(tmpDir, { recursive: true, force: true }); }); diff --git a/backend/src/routes/scores.ts b/backend/src/routes/scores.ts index a620087..a4b112a 100644 --- a/backend/src/routes/scores.ts +++ b/backend/src/routes/scores.ts @@ -12,6 +12,7 @@ import { } from "../../../shared/types/scoring"; export const scoresRouter = Router(); +const PERSISTENT_LEADERBOARD_ENABLED = process.env.PERSISTENT_LEADERBOARD_ENABLED === "true"; const VALID_DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"]; const SCORE_DIMENSIONS = [ @@ -172,7 +173,12 @@ scoresRouter.post("/", async (req: Request, res: Response) => { const durationMs = Math.max(0, Date.now() - session.startTime); - if (!session.persistentScoreEligible || session.identityKind !== "github" || !session.githubUserId) { + if ( + !PERSISTENT_LEADERBOARD_ENABLED || + !session.persistentScoreEligible || + session.identityKind !== "github" || + !session.githubUserId + ) { res.status(200).json({ saved: false, mode: "ephemeral", diff --git a/frontend/.env.local.example b/frontend/.env.local.example index 703ed96..f875bbd 100644 --- a/frontend/.env.local.example +++ b/frontend/.env.local.example @@ -12,6 +12,7 @@ NEXT_PUBLIC_TURNSTILE_SITE_KEY=replace-with-turnstile-site-key # NEXT_PUBLIC_SENTRY_ENVIRONMENT=development # NEXT_PUBLIC_SENTRY_REPLAY_SESSION_SAMPLE_RATE=0 # NEXT_PUBLIC_SENTRY_REPLAY_ON_ERROR_SAMPLE_RATE=0 +# NEXT_PUBLIC_ADMIN_ANALYTICS_ENABLED=false # Google Cloud region where Claude is available on Vertex AI # Common regions: us-east5, us-central1, europe-west1 CLOUD_ML_REGION=us-east5 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fd7541b..6848eb8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,17 +1,17 @@ { "name": "frontend", - "version": "0.1.2", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.1.2", + "version": "0.2.0", "dependencies": { "@sentry/nextjs": "^10.52.0", "clsx": "^2.1.1", "lucide-react": "^1.14.0", - "next": "16.2.4", + "next": "^16.2.6", "react": "19.2.6", "react-dom": "19.2.6", "react-markdown": "^10.1.0", @@ -1167,9 +1167,9 @@ } }, "node_modules/@next/env": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", - "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz", + "integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -1183,9 +1183,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", - "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz", + "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==", "cpu": [ "arm64" ], @@ -1199,9 +1199,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", - "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz", + "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==", "cpu": [ "x64" ], @@ -1215,12 +1215,15 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", - "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz", + "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1231,12 +1234,15 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", - "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz", + "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1247,12 +1253,15 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", - "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz", + "integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1263,12 +1272,15 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", - "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz", + "integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1279,9 +1291,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", - "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz", + "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==", "cpu": [ "arm64" ], @@ -1295,9 +1307,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", - "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz", + "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==", "cpu": [ "x64" ], @@ -9068,12 +9080,12 @@ "peer": true }, "node_modules/next": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", - "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz", + "integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==", "license": "MIT", "dependencies": { - "@next/env": "16.2.4", + "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -9087,14 +9099,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.4", - "@next/swc-darwin-x64": "16.2.4", - "@next/swc-linux-arm64-gnu": "16.2.4", - "@next/swc-linux-arm64-musl": "16.2.4", - "@next/swc-linux-x64-gnu": "16.2.4", - "@next/swc-linux-x64-musl": "16.2.4", - "@next/swc-win32-arm64-msvc": "16.2.4", - "@next/swc-win32-x64-msvc": "16.2.4", + "@next/swc-darwin-arm64": "16.2.6", + "@next/swc-darwin-x64": "16.2.6", + "@next/swc-linux-arm64-gnu": "16.2.6", + "@next/swc-linux-arm64-musl": "16.2.6", + "@next/swc-linux-x64-gnu": "16.2.6", + "@next/swc-linux-x64-musl": "16.2.6", + "@next/swc-win32-arm64-msvc": "16.2.6", + "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { diff --git a/frontend/package.json b/frontend/package.json index 916ff05..f2bf5e3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.1.2", + "version": "0.2.0", "private": true, "scripts": { "dev": "next dev", @@ -14,7 +14,7 @@ "@sentry/nextjs": "^10.52.0", "clsx": "^2.1.1", "lucide-react": "^1.14.0", - "next": "16.2.4", + "next": "^16.2.6", "react": "19.2.6", "react-dom": "19.2.6", "react-markdown": "^10.1.0", diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 5aa7cb2..8e4b25f 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -243,7 +243,7 @@ export default function AdminAnalyticsPage() { {analytics.recentSessions.map((session: RecentGameplaySession) => ( diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 4ab294e..9d163cb 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -41,6 +41,7 @@ export default function HomePage() { const clearViewer = useGameStore((s) => s.clearViewer); const [loading, setLoading] = useState(null); const [error, setError] = useState(null); + const adminAnalyticsEnabled = process.env.NEXT_PUBLIC_ADMIN_ANALYTICS_ENABLED === "true"; const [authConfigured, setAuthConfigured] = useState(false); const [sessionReady, setSessionReady] = useState(false); const [sessionLoadError, setSessionLoadError] = useState(false); @@ -390,10 +391,14 @@ export default function HomePage() { > {APP_VERSION} - · - - Admin Analytics - + {adminAnalyticsEnabled ? ( + <> + · + + Admin Analytics + + + ) : null} · About diff --git a/frontend/src/lib/release.ts b/frontend/src/lib/release.ts index fbfece7..d60e23a 100644 --- a/frontend/src/lib/release.ts +++ b/frontend/src/lib/release.ts @@ -1,4 +1,4 @@ -export const APP_VERSION = "v0.1.2"; +export const APP_VERSION = "v0.2.0"; const GITHUB_RELEASES_URL = "https://github.com/tuxerrante/SRESimulator/releases"; diff --git a/frontend/src/lib/telemetry/capture.ts b/frontend/src/lib/telemetry/capture.ts index b8f8b7b..b3bdc8b 100644 --- a/frontend/src/lib/telemetry/capture.ts +++ b/frontend/src/lib/telemetry/capture.ts @@ -75,19 +75,23 @@ export function captureFrontendError( context: FrontendTelemetryContext, safeMessage?: string, ): void { - Sentry.withScope((scope) => { - scope.setTag("feature", context.feature); - if (context.phase) scope.setTag("phase", context.phase); - if (context.difficulty) scope.setTag("difficulty", context.difficulty); - if (context.requestId) scope.setTag("requestId", context.requestId); - if (context.actorRef) scope.setTag("actorRef", context.actorRef); - if (context.gameSessionRef) scope.setTag("gameSessionRef", context.gameSessionRef); + try { + Sentry.withScope((scope) => { + scope.setTag("feature", context.feature); + if (context.phase) scope.setTag("phase", context.phase); + if (context.difficulty) scope.setTag("difficulty", context.difficulty); + if (context.requestId) scope.setTag("requestId", context.requestId); + if (context.actorRef) scope.setTag("actorRef", context.actorRef); + if (context.gameSessionRef) scope.setTag("gameSessionRef", context.gameSessionRef); - Sentry.captureException( - buildSafeFrontendError( - error, - safeMessage ?? defaultFrontendSentryMessage(context.feature), - ), - ); - }); + Sentry.captureException( + buildSafeFrontendError( + error, + safeMessage ?? defaultFrontendSentryMessage(context.feature), + ), + ); + }); + } catch { + // Frontend telemetry failures must never break user-visible flows. + } } diff --git a/helm/sre-simulator/Chart.yaml b/helm/sre-simulator/Chart.yaml index d12f21f..0db84d3 100644 --- a/helm/sre-simulator/Chart.yaml +++ b/helm/sre-simulator/Chart.yaml @@ -4,5 +4,5 @@ name: sre-simulator description: >- ARO SRE Simulator - Break-Fix training game for Azure Red Hat OpenShift type: application -version: 0.1.2 -appVersion: "0.1.2" +version: 0.2.0 +appVersion: "0.2.0" diff --git a/helm/sre-simulator/templates/configmap.yaml b/helm/sre-simulator/templates/configmap.yaml index abcb36f..aa284d8 100644 --- a/helm/sre-simulator/templates/configmap.yaml +++ b/helm/sre-simulator/templates/configmap.yaml @@ -34,3 +34,7 @@ data: {{- end }} {{- end }} AI_AZURE_OPENAI_API_VERSION: "{{ .Values.ai.azureOpenai.apiVersion }}" + TRUST_PROXY_HEADERS: "{{ ternary "true" "false" .Values.backend.trustProxyHeaders }}" + PERSISTENT_LEADERBOARD_ENABLED: "{{ ternary "true" "false" .Values.backend.persistentLeaderboardEnabled }}" + AI_CHAT_TIMEOUT_MS: "{{ .Values.backend.aiChatTimeoutMs }}" + AI_SCENARIO_TIMEOUT_MS: "{{ .Values.backend.aiScenarioTimeoutMs }}" diff --git a/helm/sre-simulator/templates/frontend-deployment.yaml b/helm/sre-simulator/templates/frontend-deployment.yaml index 7c39cf8..306dd58 100644 --- a/helm/sre-simulator/templates/frontend-deployment.yaml +++ b/helm/sre-simulator/templates/frontend-deployment.yaml @@ -27,8 +27,14 @@ spec: env: - name: BACKEND_INTERNAL_BASE_URL value: "http://{{ include "sre-simulator.fullname" . }}-backend:{{ .Values.backend.port }}" + - name: PUBLIC_APP_ORIGIN + value: {{ include "sre-simulator.publicOrigin" . | quote }} + - name: TRUST_PROXY_HEADERS + value: {{ ternary "true" "false" .Values.frontend.trustProxyHeaders | quote }} - name: NEXT_PUBLIC_SENTRY_ENABLED value: {{ ternary "true" "false" .Values.frontend.sentry.enabled | quote }} + - name: NEXT_PUBLIC_ADMIN_ANALYTICS_ENABLED + value: {{ ternary "true" "false" .Values.frontend.adminAnalyticsEnabled | quote }} - name: NEXT_PUBLIC_SENTRY_DSN value: {{ .Values.frontend.sentry.dsn | quote }} - name: NEXT_PUBLIC_SENTRY_ENVIRONMENT diff --git a/helm/sre-simulator/templates/tests/test-connection.yaml b/helm/sre-simulator/templates/tests/test-connection.yaml index a4a7153..0c46d8a 100644 --- a/helm/sre-simulator/templates/tests/test-connection.yaml +++ b/helm/sre-simulator/templates/tests/test-connection.yaml @@ -7,7 +7,7 @@ metadata: {{- include "sre-simulator.helmTest.selectorLabels" . | nindent 4 }} annotations: helm.sh/hook: test - helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded,hook-failed + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded spec: restartPolicy: Never containers: diff --git a/helm/sre-simulator/values.yaml b/helm/sre-simulator/values.yaml index 94baa64..3d9a377 100644 --- a/helm/sre-simulator/values.yaml +++ b/helm/sre-simulator/values.yaml @@ -30,6 +30,8 @@ frontend: environment: production replaySessionSampleRate: "0" replayOnErrorSampleRate: "0" + trustProxyHeaders: true + adminAnalyticsEnabled: false backend: image: @@ -56,6 +58,10 @@ backend: dsn: "" environment: production release: "" + trustProxyHeaders: true + persistentLeaderboardEnabled: false + aiChatTimeoutMs: 30000 + aiScenarioTimeoutMs: 30000 ai: provider: vertex From 72a82cdace94ec0b36e9cb5ddebda52f6dbf7b8a Mon Sep 17 00:00:00 2001 From: Alessandro Affinito Date: Wed, 13 May 2026 11:25:20 +0200 Subject: [PATCH 3/4] fix(security): restore strict session scenario fallback checks Fail closed when stored session scenario payloads are invalid, enforce title+difficulty parity in legacy fallback paths, and keep proxy-header trust opt-in by default to preserve request identity guarantees. Signed-off-by: Alessandro Affinito Co-authored-by: Cursor --- backend/.env.local.example | 2 +- backend/src/routes/chat.test.ts | 46 +++++++++++++++++++++++++++++ backend/src/routes/chat.ts | 28 +++++++++++++----- backend/src/routes/command.test.ts | 47 ++++++++++++++++++++++++++++++ backend/src/routes/command.ts | 34 +++++++++++++++------ helm/sre-simulator/values.yaml | 4 +-- 6 files changed, 142 insertions(+), 19 deletions(-) diff --git a/backend/.env.local.example b/backend/.env.local.example index 9465a5b..adf9ee0 100644 --- a/backend/.env.local.example +++ b/backend/.env.local.example @@ -24,7 +24,7 @@ TURNSTILE_EXPECTED_HOSTNAME=localhost ANTI_ABUSE_HMAC_SECRET=replace-with-long-random-anti-abuse-secret GAMEPLAY_ADMIN_TOKEN=replace-with-long-random-gameplay-admin-token AUTOMATED_TRAFFIC_TOKEN=replace-with-long-random-automated-traffic-token -TRUST_PROXY_HEADERS=true +TRUST_PROXY_HEADERS=false # Keep false in production until server-owned scoring is fully deployed. PERSISTENT_LEADERBOARD_ENABLED=false # Optional rate limits (requests per minute) diff --git a/backend/src/routes/chat.test.ts b/backend/src/routes/chat.test.ts index b544c01..c1023ab 100644 --- a/backend/src/routes/chat.test.ts +++ b/backend/src/routes/chat.test.ts @@ -181,4 +181,50 @@ describe("chatRouter", () => { } }); + it("rejects invalid stored session scenario payloads", async () => { + mocks.sessionGet.mockResolvedValueOnce({ + token: "session-123", + difficulty: "easy", + scenarioId: "scenario_test_easy", + scenarioTitle: "Test Scenario", + scenarioPayload: "{", + startTime: Date.now(), + used: false, + trafficSource: "player", + identityKind: "anonymous", + githubUserId: null, + githubLogin: null, + anonymousClaimKey: null, + persistentScoreEligible: false, + }); + + const app = express(); + app.use(express.json()); + app.use("/api/chat", chatRouter); + const server = await new Promise((resolve) => { + const listeningServer = app.listen(0, "127.0.0.1", () => resolve(listeningServer)); + }); + + try { + const { port } = server.address() as AddressInfo; + const response = await fetch(`http://127.0.0.1:${port}/api/chat`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + sessionToken: "session-123", + messages: [{ role: "user", content: "hello" }], + scenario: null, + currentPhase: "reading", + }), + }); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: "Session scenario context is unavailable", + }); + } finally { + await close(server); + } + }); + }); diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index c87f7f3..d6a1d5f 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -57,15 +57,20 @@ class ChatStreamTimeoutError extends Error { } } -function parseSessionScenario(sessionPayload: string | null): Scenario | null { - if (!sessionPayload) { - return null; +interface ParsedSessionScenario { + scenario: Scenario | null; + hasPayload: boolean; +} + +function parseSessionScenario(sessionPayload: string | null): ParsedSessionScenario { + if (!sessionPayload || sessionPayload.trim() === "") { + return { scenario: null, hasPayload: false }; } try { const parsed = JSON.parse(sessionPayload); - return isScenario(parsed) ? parsed : null; + return { scenario: isScenario(parsed) ? parsed : null, hasPayload: true }; } catch { - return null; + return { scenario: null, hasPayload: true }; } } @@ -109,8 +114,13 @@ chatRouter.post("/", async (req: Request, res: Response) => { res.status(403).json({ error: "Invalid or expired session token" }); return; } - const storedScenario = parseSessionScenario(session.scenarioPayload); + const parsedSessionScenario = parseSessionScenario(session.scenarioPayload); + const storedScenario = parsedSessionScenario.scenario; let scenario: Scenario | null = storedScenario; + if (parsedSessionScenario.hasPayload && !storedScenario) { + res.status(409).json({ error: "Session scenario context is unavailable" }); + return; + } if (storedScenario) { if ( storedScenario.title !== session.scenarioTitle || @@ -131,7 +141,11 @@ chatRouter.post("/", async (req: Request, res: Response) => { } } else { scenario = rawScenario ?? null; - if (scenario && scenario.difficulty !== session.difficulty) { + if ( + scenario && + (scenario.difficulty !== session.difficulty || + scenario.title !== session.scenarioTitle) + ) { res.status(409).json({ error: "Scenario does not match the active session" }); return; } diff --git a/backend/src/routes/command.test.ts b/backend/src/routes/command.test.ts index b007dbd..5394b96 100644 --- a/backend/src/routes/command.test.ts +++ b/backend/src/routes/command.test.ts @@ -163,4 +163,51 @@ describe("commandRouter", () => { } }); + it("rejects invalid stored session scenario payloads", async () => { + mocks.sessionGet.mockResolvedValueOnce({ + token: "session-123", + difficulty: "easy", + scenarioId: "scenario_test_easy", + scenarioTitle: "Test Scenario", + scenarioPayload: "{", + startTime: Date.now(), + used: false, + trafficSource: "player", + identityKind: "anonymous", + githubUserId: null, + githubLogin: null, + anonymousClaimKey: null, + persistentScoreEligible: false, + }); + + const app = express(); + app.use(express.json()); + app.use("/api/command", commandRouter); + const server = await new Promise((resolve) => { + const listeningServer = app.listen(0, "127.0.0.1", () => resolve(listeningServer)); + }); + + try { + const { port } = server.address() as AddressInfo; + const response = await fetch(`http://127.0.0.1:${port}/api/command`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + sessionToken: "session-123", + command: "oc get pods", + type: "oc", + scenario: null, + commandHistory: [], + }), + }); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: "Session scenario context is unavailable", + }); + } finally { + await close(server); + } + }); + }); diff --git a/backend/src/routes/command.ts b/backend/src/routes/command.ts index a7c9078..cd83a09 100644 --- a/backend/src/routes/command.ts +++ b/backend/src/routes/command.ts @@ -39,15 +39,20 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function parseSessionScenario(sessionPayload: string | null): Scenario | null { - if (!sessionPayload) { - return null; +interface ParsedSessionScenario { + scenario: Scenario | null; + hasPayload: boolean; +} + +function parseSessionScenario(sessionPayload: string | null): ParsedSessionScenario { + if (!sessionPayload || sessionPayload.trim() === "") { + return { scenario: null, hasPayload: false }; } try { const parsed = JSON.parse(sessionPayload); - return isScenario(parsed) ? parsed : null; + return { scenario: isScenario(parsed) ? parsed : null, hasPayload: true }; } catch { - return null; + return { scenario: null, hasPayload: true }; } } @@ -143,6 +148,7 @@ export function resolveCommandHistoryPlaceholders( } commandRouter.post("/", async (req: Request, res: Response) => { + let requestScenario: Scenario | null = null; try { if (!isRecord(req.body)) { res.status(400).json({ error: "Invalid request body" }); @@ -176,8 +182,13 @@ commandRouter.post("/", async (req: Request, res: Response) => { res.status(403).json({ error: "Invalid or expired session token" }); return; } - const storedScenario = parseSessionScenario(session.scenarioPayload); + const parsedSessionScenario = parseSessionScenario(session.scenarioPayload); + const storedScenario = parsedSessionScenario.scenario; let scenario: Scenario | null = storedScenario; + if (parsedSessionScenario.hasPayload && !storedScenario) { + res.status(409).json({ error: "Session scenario context is unavailable" }); + return; + } if (storedScenario) { if ( storedScenario.title !== session.scenarioTitle || @@ -198,11 +209,16 @@ commandRouter.post("/", async (req: Request, res: Response) => { } } else { scenario = rawScenario ?? null; - if (scenario && scenario.difficulty !== session.difficulty) { + if ( + scenario && + (scenario.difficulty !== session.difficulty || + scenario.title !== session.scenarioTitle) + ) { res.status(409).json({ error: "Scenario does not match the active session" }); return; } } + requestScenario = scenario; const commandResolved = resolveAngleBracketPlaceholders(command, scenario); const commandHistoryResolved = resolveCommandHistoryPlaceholders(commandHistory, scenario); @@ -267,9 +283,9 @@ commandRouter.post("/", async (req: Request, res: Response) => { ? (requestBody.type as (typeof VALID_COMMAND_TYPES)[number]) : "oc"; const fallbackCommandRaw = typeof requestBody.command === "string" ? requestBody.command : ""; - const fallbackScenario = isScenario(requestBody.scenario) + const fallbackScenario = requestScenario ?? (isScenario(requestBody.scenario) ? requestBody.scenario - : null; + : null); const fallbackCommand = resolveAngleBracketPlaceholders( fallbackCommandRaw, fallbackScenario, diff --git a/helm/sre-simulator/values.yaml b/helm/sre-simulator/values.yaml index 3d9a377..a0824d0 100644 --- a/helm/sre-simulator/values.yaml +++ b/helm/sre-simulator/values.yaml @@ -30,7 +30,7 @@ frontend: environment: production replaySessionSampleRate: "0" replayOnErrorSampleRate: "0" - trustProxyHeaders: true + trustProxyHeaders: false adminAnalyticsEnabled: false backend: @@ -58,7 +58,7 @@ backend: dsn: "" environment: production release: "" - trustProxyHeaders: true + trustProxyHeaders: false persistentLeaderboardEnabled: false aiChatTimeoutMs: 30000 aiScenarioTimeoutMs: 30000 From d6daea5da36fac0e1b5e68a3e59a480157299039 Mon Sep 17 00:00:00 2001 From: Alessandro Affinito Date: Wed, 13 May 2026 11:34:20 +0200 Subject: [PATCH 4/4] fix(review): close remaining copilot findings on pr 197 Align Helm env templating with valid quote rendering, tighten legacy scenario fallback parity with session IDs, and make admin analytics prompt for a token so secured backend reads succeed from the UI. Signed-off-by: Alessandro Affinito Co-authored-by: Cursor --- backend/src/routes/chat.ts | 3 +- backend/src/routes/command.ts | 3 +- frontend/src/app/admin/page.tsx | 31 ++++++++++++++++++++- helm/sre-simulator/templates/configmap.yaml | 4 +-- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index d6a1d5f..496574e 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -144,7 +144,8 @@ chatRouter.post("/", async (req: Request, res: Response) => { if ( scenario && (scenario.difficulty !== session.difficulty || - scenario.title !== session.scenarioTitle) + scenario.title !== session.scenarioTitle || + (session.scenarioId && scenario.id !== session.scenarioId)) ) { res.status(409).json({ error: "Scenario does not match the active session" }); return; diff --git a/backend/src/routes/command.ts b/backend/src/routes/command.ts index cd83a09..6521002 100644 --- a/backend/src/routes/command.ts +++ b/backend/src/routes/command.ts @@ -212,7 +212,8 @@ commandRouter.post("/", async (req: Request, res: Response) => { if ( scenario && (scenario.difficulty !== session.difficulty || - scenario.title !== session.scenarioTitle) + scenario.title !== session.scenarioTitle || + (session.scenarioId && scenario.id !== session.scenarioId)) ) { res.status(409).json({ error: "Scenario does not match the active session" }); return; diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 8e4b25f..02d3217 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -72,12 +72,41 @@ export default function AdminAnalyticsPage() { const [error, setError] = useState(null); useEffect(() => { + const loadStoredToken = (): string | null => { + if (typeof window === "undefined") { + return null; + } + const token = window.localStorage.getItem("gameplayAdminToken"); + return token && token.trim() ? token.trim() : null; + }; + + const storeToken = (token: string): void => { + if (typeof window === "undefined") { + return; + } + window.localStorage.setItem("gameplayAdminToken", token); + }; + const fetchAnalytics = async () => { setLoading(true); setError(null); try { - const response = await fetch("/api/gameplay/admin"); + let token = loadStoredToken(); + let response = await fetch("/api/gameplay/admin", { + headers: token ? { "x-gameplay-admin-token": token } : undefined, + }); + if (response.status === 401 && typeof window !== "undefined") { + const promptedToken = window.prompt("Enter gameplay admin token"); + if (promptedToken && promptedToken.trim()) { + token = promptedToken.trim(); + storeToken(token); + response = await fetch("/api/gameplay/admin", { + headers: { "x-gameplay-admin-token": token }, + }); + } + } + const raw = await response.text(); const parsed = JSON.parse(raw) as GameplayAnalytics | { error?: string }; if (!response.ok) { diff --git a/helm/sre-simulator/templates/configmap.yaml b/helm/sre-simulator/templates/configmap.yaml index aa284d8..dce5d7a 100644 --- a/helm/sre-simulator/templates/configmap.yaml +++ b/helm/sre-simulator/templates/configmap.yaml @@ -34,7 +34,7 @@ data: {{- end }} {{- end }} AI_AZURE_OPENAI_API_VERSION: "{{ .Values.ai.azureOpenai.apiVersion }}" - TRUST_PROXY_HEADERS: "{{ ternary "true" "false" .Values.backend.trustProxyHeaders }}" - PERSISTENT_LEADERBOARD_ENABLED: "{{ ternary "true" "false" .Values.backend.persistentLeaderboardEnabled }}" + TRUST_PROXY_HEADERS: {{ ternary "true" "false" .Values.backend.trustProxyHeaders | quote }} + PERSISTENT_LEADERBOARD_ENABLED: {{ ternary "true" "false" .Values.backend.persistentLeaderboardEnabled | quote }} AI_CHAT_TIMEOUT_MS: "{{ .Values.backend.aiChatTimeoutMs }}" AI_SCENARIO_TIMEOUT_MS: "{{ .Values.backend.aiScenarioTimeoutMs }}"