From e566e575ed5d670d892c5628f5ba128b282a022d Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:59:08 +0100 Subject: [PATCH 1/3] test(metrics): isolate large summary seed persistence across reopen Release qualification crashed in historical baseline SQLite ingestion during the workloads-summary seed. Preserve a metrics-only diagnostic for the same 84,000-row batch shape, with row-count and integrity checks before and after reopen, without the HTTP or reflection fixtures. This does not reproduce or fix the unexplained crash. Validation: ten focused runs on Go 1.26.7 and one on Go 1.26.8 passed; a one-repeat race run passed. The three-repeat race run timed out at 180 seconds during its final integrity check and remains retained evidence. Omitting the final seed batch makes the row-count assertion fail. Change-source: pulse-maintainer --- pkg/metrics/store_large_seed_test.go | 94 ++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 pkg/metrics/store_large_seed_test.go diff --git a/pkg/metrics/store_large_seed_test.go b/pkg/metrics/store_large_seed_test.go new file mode 100644 index 000000000..e3e585436 --- /dev/null +++ b/pkg/metrics/store_large_seed_test.go @@ -0,0 +1,94 @@ +package metrics + +import ( + "fmt" + "path/filepath" + "testing" + "time" +) + +// Exercise the ingestion shape from the workloads-summary benchmark without +// its HTTP, reflection or monitor fixtures. A historical benchmark crashed in +// SQLite during the second synchronous seed; this is a diagnostic invariant, +// not a reproducer or a claim that the unexplained crash has been repaired. +func TestStoreLargeSummarySeedSurvivesReopen(t *testing.T) { + cfg := DefaultConfig(t.TempDir()) + cfg.DBPath = filepath.Join(filepath.Dir(cfg.DBPath), "summary-seed.db") + cfg.FlushInterval = time.Hour + cfg.WriteBufferSize = 10_000 + store, err := NewStore(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if store != nil { + if err := store.Close(); err != nil { + t.Error(err) + } + } + }) + base := time.Now().Add(-4 * time.Hour).UTC().Truncate(time.Second) + for _, group := range []struct { + kind string + count int + }{{"vm", 30}, {"container", 20}, {"dockerContainer", 20}} { + batch := make([]WriteMetric, 0, group.count*5*240) + for r := 0; r < group.count; r++ { + for _, metric := range []string{"cpu", "memory", "disk", "netin", "netout"} { + for p := 0; p < 240; p++ { + batch = append(batch, WriteMetric{ + ResourceType: group.kind, ResourceID: fmt.Sprintf("%s-%d", group.kind, r), + MetricType: metric, Value: float64((r + p) % 100), + Timestamp: base.Add(time.Duration(p) * time.Minute), Tier: TierMinute, + }) + } + } + } + store.WriteBatchSync(batch) + } + check := func() { + t.Helper() + var count int + if err := store.db.QueryRow("SELECT COUNT(*) FROM metrics WHERE tier = ?", string(TierMinute)).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 84_000 { + t.Fatalf("persisted minute rows = %d, want 84000", count) + } + rows, err := store.db.Query("PRAGMA integrity_check") + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var results int + for rows.Next() { + var result string + if err := rows.Scan(&result); err != nil { + t.Fatal(err) + } + if result != "ok" { + t.Errorf("integrity_check: %s", result) + } + results++ + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if results != 1 { + t.Fatalf("integrity_check returned %d rows, want one ok", results) + } + } + check() + if err := store.Close(); err != nil { + t.Fatal(err) + } + store = nil + store, err = NewStore(cfg) + if err != nil { + t.Fatal(err) + } + if err := store.WaitForMaintenance(10 * time.Second); err != nil { + t.Fatal(err) + } + check() +} From 64dba483d05ca8f30fb85e44a650b5a9d02748c9 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:48:17 +0100 Subject: [PATCH 2/3] fix(release): bind forward 6.4.4 checkpoint to release train The held regression candidate needs an honest forward beta above published 6.4.3-rc.1. Bind only 6.4.4 to release/v6.4 without capturing patch 40 or weakening candidate checks. Exercise the actual release and rehearsal branch-policy shell and retain historical rollback mapping. Change-source: pulse-maintainer --- docs/release-control/control_plane.json | 5 +++ .../v6/internal/RELEASE_PROMOTION_POLICY.md | 13 ++++++ scripts/release_control/control_plane.py | 13 +++++- .../control_plane_audit_test.py | 45 +++++++++++++++++++ 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/docs/release-control/control_plane.json b/docs/release-control/control_plane.json index 888734ae0..2300c5bb1 100644 --- a/docs/release-control/control_plane.json +++ b/docs/release-control/control_plane.json @@ -23,6 +23,11 @@ "prerelease_branch": "release/v6.4", "stable_branch": "release/v6.4" }, + { + "version_prefix": "6.4.4", + "prerelease_branch": "release/v6.4", + "stable_branch": "release/v6.4" + }, { "version_prefix": "6.5.", "prerelease_branch": "release/v6.5", diff --git a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md index 24f102232..7c079ed54 100644 --- a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md +++ b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md @@ -282,6 +282,19 @@ without the other lanes changing the candidate underneath it. moving `main` can no longer invalidate the compiler's exact-SHA binding between dispatch and compilation, which is what failed run 33579042375. Earlier `6.4.x` versions keep their historical `main` mapping. +8. The forward regression checkpoint `v6.4.4-beta.1` uses the same + `release/v6.4` line, with an explicit `6.4.4` mapping for beta, RC and + eventual stable. This is a maturity reset, not new feature scope: a + `6.4.3-beta.N` would sort below the published `v6.4.3-rc.1`. + `v6.4.4-beta.1` advances both that preview and stable `v6.4.1`; + `v6.4.1` remains the rollback target. Later qualification proceeds through + `v6.4.4-rc.N` and exact same-version stable promotion, including a fresh + 72-hour clean RC soak. Beta time does not count. Mapping is preparation, + not readiness or publication authority: failed candidate checks still + require repair or an evidence-based disposition under the existing gates. + Land the mapping and resolver contract on canonical main and backport it + to the release line before taking a fresh bound packet. Unlisted patches + and new product work retain their existing mapping and scope. ## Paid Pro Artifact Lineage diff --git a/scripts/release_control/control_plane.py b/scripts/release_control/control_plane.py index 1f3aa8188..74a2c2062 100644 --- a/scripts/release_control/control_plane.py +++ b/scripts/release_control/control_plane.py @@ -442,7 +442,18 @@ def legacy_release_line_for_version( reverse=True, ) for line in legacy_release_lines: - if normalized_version.startswith(line["version_prefix"]): + prefix = line["version_prefix"] + # A complete patch version binds only that version, not e.g. 6.4.40. + # Trailing-dot prefixes continue to bind the whole minor/major line. + if prefix.endswith("."): + matches = normalized_version.startswith(prefix) + else: + matches = ( + normalized_version == prefix + or normalized_version.startswith(prefix + "-") + or normalized_version.startswith(prefix + "+") + ) + if matches: return line return None diff --git a/scripts/release_control/control_plane_audit_test.py b/scripts/release_control/control_plane_audit_test.py index 094e5e1e3..4b4a2c8c3 100644 --- a/scripts/release_control/control_plane_audit_test.py +++ b/scripts/release_control/control_plane_audit_test.py @@ -1,3 +1,8 @@ +import os +from pathlib import Path +import re +import tempfile +import textwrap import shlex import subprocess import sys @@ -239,6 +244,46 @@ class ControlPlaneAuditTest(unittest.TestCase): "pulse/release-5.1.25", ) + def test_forward_patch_train_uses_actual_control_plane(self) -> None: + for version in ("6.4.4-beta.1", "v6.4.4-beta.2", "6.4.4-rc.1", + "6.4.4", "v6.4.4+build.1", "6.4.3-rc.1", "6.4.3"): + with self.subTest(version=version): + self.assertEqual(release_branch_for_version(version), "release/v6.4") + for version in ("6.4.1", "6.4.2", "6.4.5-beta.1", "6.4.40-beta.1", + "6.4.30", "6.3.20", "6.6.0-beta.1"): + with self.subTest(version=version): + self.assertEqual(release_branch_for_version(version), "main") + self.assertEqual(release_branch_for_version("6.5.10-beta.1"), "release/v6.5") + + def test_forward_patch_workflow_branch_contract(self) -> None: + # Execute the real branch-policy shell only, never dispatch a workflow. + for workflow in ("create-release.yml", "release-dry-run.yml"): + content = (REPO_ROOT / ".github/workflows" / workflow).read_text() + match = re.search( + r"(?ms)^ - name: Resolve required release branch\n" + r".*?^ run: \|\n((?: [^\n]*\n|\n)+)", content + ) + self.assertIsNotNone(match) + script = textwrap.dedent(match.group(1)) + for branch in ("main", "release/v6.4"): + with self.subTest(workflow=workflow, branch=branch), tempfile.TemporaryDirectory() as tmp: + output = os.path.join(tmp, "output") + result = subprocess.run( + ["bash", "-euo", "pipefail", "-c", script], + cwd=REPO_ROOT, capture_output=True, text=True, + env={**os.environ, "GITHUB_OUTPUT": output, + "VERSION_INPUT": "6.4.4-beta.1", + "WORKFLOW_OUTPUT_1": "6.4.4-beta.1", + "WORKFLOW_OUTPUT_2": branch}, + ) + rejects = workflow == "create-release.yml" and branch == "main" + self.assertEqual(result.returncode, 1 if rejects else 0, result.stderr) + if rejects: + self.assertIn("must run from release/v6.4", result.stdout) + else: + self.assertRegex(Path(output).read_text(), + r"^required_branch<<([^\n]+)\nrelease/v6.4\n\1\n$") + def test_audit_flags_stale_active_target(self) -> None: report = audit_control_plane_payload( VALID_PAYLOAD, From 8729bca778f7883ce0b4e9ef64f5df52c589236b Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:58:48 +0100 Subject: [PATCH 3/3] Sign demo-mode visitors in instead of showing them demo/demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Try the live demo" on pulserelay.pro landed on a login form with the demo credentials printed above it. The demo runtime is read-only and its credentials are public, so making a first-time visitor type them was pure friction at the moment of curiosity. When the security status reports demo mode, the login page now submits demo/demo itself, once per browser tab, showing "Signing you in to the demo…" in the demo banner while the request is in flight. The marker lives in sessionStorage under SESSION_STORAGE_KEYS.DEMO_AUTO_LOGIN. The logout handler sets the same marker to "suppressed" right after it clears session storage, so a visitor who signs out lands on the form (and stays there across a reload) rather than being signed straight back in; a new tab signs in again. SSO callbacks (?oidc / ?saml), first-run setup and a non-demo runtime never trigger it, and a rejected sign-in falls back to the normal form with the server's error. The credential request moves out of the submit handler into submitCredentials so the form and the automatic path share one code path and one set of error messages. Verified on a demo-mode backend (DEMO_MODE=true, mock data, demo/demo) behind Vite at 1280x800 and 390x844: automatic sign-in from a fresh context, Logout to the form with the marker suppressed, reload keeps the form, manual sign-in from the form, second fresh context signs in again, and a stubbed 401 shows the form with the error. Login and useAppRuntimeState test files pass (41 tests). Contract-Neutral: demo-mode login convenience on the existing /api/login flow; no public API, config, or contract surface changes --- frontend-modern/browser-verification.json | 37 +++++--- frontend-modern/src/components/Login.tsx | 86 +++++++++++++++---- .../src/components/__tests__/Login.test.tsx | 79 ++++++++++++++++- frontend-modern/src/useAppRuntimeState.ts | 7 +- frontend-modern/src/utils/localStorage.ts | 4 + 5 files changed, 181 insertions(+), 32 deletions(-) diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 5e8b35ad1..00f6b678a 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,35 +1,44 @@ { "version": 1, - "base_sha": "4fd68c72b26f033f5b1b0fcb7d7b2b0d67e85ec8", - "verified_at": "2026-09-06T14:12:06.315693Z", + "base_sha": "f4dc1e69a0e214dd0085c7696f42444b8fd91fa3", + "verified_at": "2026-09-06T16:58:13Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/alerts/deliveryDiagnosisPresentation.ts" + "frontend-modern/src/components/Login.tsx", + "frontend-modern/src/useAppRuntimeState.ts", + "frontend-modern/src/utils/localStorage.ts" ], "content_sha256": { - "frontend-modern/src/features/alerts/deliveryDiagnosisPresentation.ts": "bc3eadf8f790517b377430a42eb67e8fdfcadb53bb13b87cd971d6a9ab91d607" + "frontend-modern/src/components/Login.tsx": "621f0b97d775aacaa521a3c72ed02db3fde5ad3eda06d55950a32107723eb44c", + "frontend-modern/src/useAppRuntimeState.ts": "5a1d343d92c441e1302dab129be7256dd9a287c6a60fb0e17c6f9fa13fe20900", + "frontend-modern/src/utils/localStorage.ts": "182ed45685228781fd32725db50611b1115f9ee4fb1af23aeedcf41ca707bdbc" }, "routes": [ - "/qualification (isolated real OverviewTab, not installed /alerts)" + "/" ], "viewports": [ { - "width": 1440, - "height": 1000 - }, - { - "width": 900, - "height": 1000 + "width": 1280, + "height": 800 }, { "width": 390, - "height": 1000 + "height": 844 } ], "states": [ - "Scripted ready and cooldown diagnoses with lastNotified render Dispatch requested; cooldown says next eligible. Missing timestamp remains Notification pending. No Notified label." + "demo-mode login page with the \"Signing you in to the demo\u2026\" status while the automatic sign-in is in flight", + "application shell after the automatic demo sign-in (Demo instance banner, Proxmox overview)", + "login form with printed demo credentials after an explicit Logout, session marker \"suppressed\"", + "login form still shown after a reload following the Logout", + "login form with the \"Invalid username or password\" error after a rejected automatic sign-in (401 stub), Sign in button enabled" ], "interactions": [ - "Loaded real Overview in Chromium via scripts/check-alert-dispatch-copy.mjs; asserted status text ranges fit each viewport and no page errors. Inspected desktop and phone screenshots. No delivery actions invoked; no backend receipt claimed. Screenshots retained in lane outcome evidence." + "load / in a fresh browser context at 1280x800 and at 390x844; automatic POST /api/login with demo/demo, no typing", + "click the Logout control in the app header at both widths", + "reload the page after Logout", + "type demo/demo into the form and submit after Logout", + "open / in a second fresh context: automatic sign-in again", + "stub /api/login with 401 and load /: form fallback" ] } diff --git a/frontend-modern/src/components/Login.tsx b/frontend-modern/src/components/Login.tsx index 8e3b01786..d9981c83d 100644 --- a/frontend-modern/src/components/Login.tsx +++ b/frontend-modern/src/components/Login.tsx @@ -1,8 +1,17 @@ -import { Component, createSignal, Show, For, onMount, lazy, Suspense } from 'solid-js'; +import { + Component, + createEffect, + createSignal, + Show, + For, + onMount, + lazy, + Suspense, +} from 'solid-js'; import { logger } from '@/utils/logger'; import { PulseBrandMark } from '@/components/Brand/PulseBrandMark'; import { apiClient, apiFetchJSON } from '@/utils/apiClient'; -import { STORAGE_KEYS } from '@/utils/localStorage'; +import { SESSION_STORAGE_KEYS, STORAGE_KEYS } from '@/utils/localStorage'; import { TROUBLESHOOTING_DOC_URL } from '@/utils/docsLinks'; import Globe from 'lucide-solid/icons/globe'; import Key from 'lucide-solid/icons/key'; @@ -18,6 +27,10 @@ interface LoginProps { import type { SecurityStatus, SSOProviderInfo } from '@/types/config'; +// The public demo's credentials. They are shown on the login page, so there +// is nothing to protect by making the visitor type them. +const DEMO_CREDENTIALS = { username: 'demo', password: 'demo' } as const; + function getBrowserStorage(kind: 'localStorage' | 'sessionStorage'): Storage | undefined { if (typeof window === 'undefined') return undefined; try { @@ -68,8 +81,12 @@ export const Login: Component = (props) => { const [oidcLoading] = createSignal(false); const [oidcError, setOidcError] = createSignal(''); const [oidcMessage, setOidcMessage] = createSignal(''); + const [demoAutoLogin, setDemoAutoLogin] = createSignal(false); const ssoProviders = () => authStatus()?.ssoProviders || []; + const demoModeEnabled = () => + authStatus()?.presentationPolicy?.demoMode === true || + authStatus()?.sessionCapabilities?.demoMode === true; const resolveSSOError = (reason?: string | null) => { switch (reason) { @@ -214,6 +231,15 @@ export const Login: Component = (props) => { return; } + await submitCredentials(usernameValue, passwordValue, rememberLogin); + }; + + const submitCredentials = async ( + usernameValue: string, + passwordValue: string, + rememberLogin: boolean, + ) => { + setLoading(true); try { // Use the new login endpoint for better feedback const response = await apiClient.fetch('/api/login', { @@ -280,6 +306,26 @@ export const Login: Component = (props) => { } }; + // Demo mode: the public demo is read-only and its credentials are printed on + // this page anyway, so sign the visitor in instead of making them type + // demo/demo. Once per browser tab, and never straight after a sign-out. + createEffect(() => { + if (loadingAuth() || !demoModeEnabled() || showFirstRunSetup()) return; + const params = new URLSearchParams(window.location.search); + if (params.has('oidc') || params.has('saml')) return; + const storage = getBrowserStorage('sessionStorage'); + if (storage?.getItem(SESSION_STORAGE_KEYS.DEMO_AUTO_LOGIN)) return; + try { + storage?.setItem(SESSION_STORAGE_KEYS.DEMO_AUTO_LOGIN, 'attempted'); + } catch (_err) { + // If the marker cannot be stored the sign-in still runs once for this render. + } + setDemoAutoLogin(true); + void submitCredentials(DEMO_CREDENTIALS.username, DEMO_CREDENTIALS.password, false).finally( + () => setDemoAutoLogin(false), + ); + }); + // Debug logging logger.debug('[Login] Render', { loadingAuth: loadingAuth(), @@ -323,9 +369,8 @@ export const Login: Component = (props) => { oidcLoading, oidcError, oidcMessage, - demoModeEnabled: - authStatus()?.presentationPolicy?.demoMode === true || - authStatus()?.sessionCapabilities?.demoMode === true, + demoModeEnabled: demoModeEnabled(), + demoAutoLogin, showLocalLogin: shouldShowLocalLogin(), ssoProviders: ssoProviders(), }} @@ -369,6 +414,7 @@ const LoginForm: Component<{ oidcError: () => string; oidcMessage: () => string; demoModeEnabled: boolean; + demoAutoLogin: () => boolean; showLocalLogin: boolean; ssoProviders: SSOProviderInfo[]; }> = (props) => { @@ -386,6 +432,7 @@ const LoginForm: Component<{ oidcError, oidcMessage, demoModeEnabled, + demoAutoLogin, showLocalLogin, ssoProviders, } = props; @@ -414,16 +461,25 @@ const LoginForm: Component<{
Demo Mode
-
- Login with{' '} - - demo - {' '} - /{' '} - - demo - -
+ + Login with{' '} + + demo + {' '} + /{' '} + + demo + +
+ } + > +
+ Signing you in to the demo… +
+ diff --git a/frontend-modern/src/components/__tests__/Login.test.tsx b/frontend-modern/src/components/__tests__/Login.test.tsx index 0a7439005..8ec2e7d48 100644 --- a/frontend-modern/src/components/__tests__/Login.test.tsx +++ b/frontend-modern/src/components/__tests__/Login.test.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest'; import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; import { Login } from '@/components/Login'; import loginSource from '@/components/Login.tsx?raw'; -import { STORAGE_KEYS } from '@/utils/localStorage'; +import { SESSION_STORAGE_KEYS, STORAGE_KEYS } from '@/utils/localStorage'; // Mock fetch globally const mockFetch = vi.fn(); @@ -182,7 +182,10 @@ describe('Login', () => { expect(mockFetch).not.toHaveBeenCalledWith('/api/security/status'); }); - it('shows demo credentials when session capabilities mark the runtime as demo mode', async () => { + it('shows demo credentials when the visitor has signed out of the demo', async () => { + // A sign-out marks the tab so the page does not sign the visitor straight + // back in; the printed credentials are the way back. + window.sessionStorage.setItem(SESSION_STORAGE_KEYS.DEMO_AUTO_LOGIN, 'suppressed'); const mockOnLogin = vi.fn(); const securityStatus = { hasAuthentication: true, @@ -196,6 +199,78 @@ describe('Login', () => { expect(await screen.findByText('Demo Mode')).toBeInTheDocument(); expect(screen.getAllByText('demo')).toHaveLength(2); + expect(mockFetch).not.toHaveBeenCalledWith('/api/login', expect.anything()); + expect(mockOnLogin).not.toHaveBeenCalled(); + }); + + it('signs the visitor in with the demo credentials when the runtime is in demo mode', async () => { + const mockOnLogin = vi.fn(); + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + const securityStatus = { + hasAuthentication: true, + hideLocalLogin: false, + presentationPolicy: { demoMode: true }, + }; + + render(() => ( + + )); + + await waitFor(() => expect(mockOnLogin).toHaveBeenCalledOnce()); + const loginCall = mockFetch.mock.calls.find(([url]) => url === '/api/login'); + expect(loginCall).toBeDefined(); + expect(JSON.parse((loginCall?.[1] as RequestInit).body as string)).toEqual({ + username: 'demo', + password: 'demo', + rememberMe: false, + }); + expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.DEMO_AUTO_LOGIN)).toBe('attempted'); + }); + + it('falls back to the form when the demo sign-in is rejected', async () => { + const mockOnLogin = vi.fn(); + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ success: false, message: 'Invalid username or password' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }), + ); + const securityStatus = { + hasAuthentication: true, + hideLocalLogin: false, + presentationPolicy: { demoMode: true }, + }; + + render(() => ( + + )); + + expect(await screen.findByText('Invalid username or password')).toBeInTheDocument(); + expect(screen.getAllByText('demo')).toHaveLength(2); + expect(screen.getByRole('button', { name: /sign in to pulse/i })).toBeEnabled(); + expect(mockOnLogin).not.toHaveBeenCalled(); + }); + + it('does not sign in to the demo twice in one browser tab', async () => { + window.sessionStorage.setItem(SESSION_STORAGE_KEYS.DEMO_AUTO_LOGIN, 'attempted'); + const mockOnLogin = vi.fn(); + const securityStatus = { + hasAuthentication: true, + hideLocalLogin: false, + presentationPolicy: { demoMode: true }, + }; + + render(() => ( + + )); + + expect(await screen.findByText('Demo Mode')).toBeInTheDocument(); + expect(mockFetch).not.toHaveBeenCalledWith('/api/login', expect.anything()); }); it('restores the remembered username without storing a password', async () => { diff --git a/frontend-modern/src/useAppRuntimeState.ts b/frontend-modern/src/useAppRuntimeState.ts index 2f2344433..8e8b8c7f8 100644 --- a/frontend-modern/src/useAppRuntimeState.ts +++ b/frontend-modern/src/useAppRuntimeState.ts @@ -14,7 +14,7 @@ import { PRIMARY_PLATFORM_NAV_IDS, type PlatformNavigationVisibility, } from '@/features/platformNavigation/platformNavigationModel'; -import { STORAGE_KEYS } from '@/utils/localStorage'; +import { SESSION_STORAGE_KEYS, STORAGE_KEYS } from '@/utils/localStorage'; import type { VersionInfo } from '@/api/updates'; import type { Organization } from '@/api/orgs'; import { OrgsAPI } from '@/api/orgs'; @@ -828,6 +828,11 @@ export const useAppRuntimeState = () => { ]; keysToRemove.forEach((key) => localStorage.removeItem(key)); sessionStorage.clear(); + try { + sessionStorage.setItem(SESSION_STORAGE_KEYS.DEMO_AUTO_LOGIN, 'suppressed'); + } catch (_err) { + // Storage may be unavailable; the demo login page then simply signs in again. + } localStorage.setItem('just_logged_out', 'true'); aiChatStore.setEnabled(false); diff --git a/frontend-modern/src/utils/localStorage.ts b/frontend-modern/src/utils/localStorage.ts index fe9161e82..2090b6ae4 100644 --- a/frontend-modern/src/utils/localStorage.ts +++ b/frontend-modern/src/utils/localStorage.ts @@ -142,6 +142,10 @@ export type LowPriorityNoticeOwner = 'github-star' | 'release-update'; export const SESSION_STORAGE_KEYS = { LOW_PRIORITY_NOTICE_OWNER: 'pulse-low-priority-notice-owner', + // Demo mode signs the visitor in once per browser tab. The value is + // 'attempted' after the login page has tried, or 'suppressed' after an + // explicit sign-out, so a visitor who signed out lands on the form. + DEMO_AUTO_LOGIN: 'pulse-demo-auto-login', } as const; /**