From 355b39e3b5b9f0b585cc3e73efbd7722cdc4bab3 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 11:40:47 -0700 Subject: [PATCH] Fix: launch PIN re-triggered the first-run setup wizard every visit (#842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression from the #832 server-side launch gate (Beckid). On a PIN-required, unverified session the gate 401'd /api/setup/status — which the frontend checks BEFORE the PIN screen. The 401 left setup_complete undefined, so `!undefined` relaunched the full setup wizard on every visit (cancel → PIN screen worked, which was the tell). Two-layer fix: - Allowlist /api/setup/status in the launch gate (it's a harmless boolean, no secrets) so the frontend gets the real answer even while locked. - Make the frontend fail-safe: only launch the wizard on a definitive setup_complete === false from an OK response — never on a 401/locked/ambiguous one. Test: locked session still 401s data endpoints but /api/setup/status returns {setup_complete:true}; added a gate-allowlist assertion. 21 gate tests pass. --- core/security/launch_lock.py | 3 +++ tests/test_launch_lock_gate.py | 6 ++++++ webui/static/init.js | 14 ++++++++++---- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/core/security/launch_lock.py b/core/security/launch_lock.py index 8b8ce52a..2a0a75af 100644 --- a/core/security/launch_lock.py +++ b/core/security/launch_lock.py @@ -30,6 +30,9 @@ from __future__ import annotations _ALLOWED_GET = frozenset({ '/api/profiles', # profile picker list (multi-profile launch) '/api/profiles/current', # how the frontend detects the lock state + '/api/setup/status', # #842: first-run check runs before the PIN screen; + # blocking it made the frontend think setup wasn't + # done and re-launch the wizard on every visit. }) # POST endpoints that drive selection + unlock. Selecting a profile only sets diff --git a/tests/test_launch_lock_gate.py b/tests/test_launch_lock_gate.py index 1e897815..9dc062c6 100644 --- a/tests/test_launch_lock_gate.py +++ b/tests/test_launch_lock_gate.py @@ -77,6 +77,12 @@ def test_locked_allows_unlock_flow(): assert L('/api/profiles/logout', 'POST') is False +def test_locked_allows_setup_status(): + # #842: the first-run check runs before the PIN screen. Blocking it made the + # frontend think setup was incomplete and relaunch the wizard every visit. + assert L('/api/setup/status', 'GET') is False + + def test_locked_allows_keyauthed_public_api(): # The public REST API carries its own @require_api_key, so a launch-locked # UI must not break a legitimate headless key holder. diff --git a/webui/static/init.js b/webui/static/init.js index fe4779c5..57af415c 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -2072,10 +2072,16 @@ document.addEventListener('DOMContentLoaded', async function () { if (!forceSetup) { try { const setupResp = await fetch('/api/setup/status'); - const setupData = await setupResp.json(); - if (!setupData.setup_complete) { - showWizard = true; - localStorage.removeItem('soulsync_setup_complete'); + // Fail-safe (#842): only launch the wizard when the server DEFINITIVELY + // says setup isn't done. A non-OK response (e.g. 401 while the launch + // PIN is locked) must NOT trigger the wizard — otherwise a PIN-gated + // returning user gets the full setup flow every visit. + if (setupResp.ok) { + const setupData = await setupResp.json(); + if (setupData.setup_complete === false) { + showWizard = true; + localStorage.removeItem('soulsync_setup_complete'); + } } } catch (e) { console.warn('Setup status check failed, continuing normal init:', e);