From 97e1029ca026a9ea01e519d64a9f036d8f6a28d5 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:59:29 +0300 Subject: [PATCH] fix: tour -- mock settingsCard, guard applyGlobalPromptQuiet at its root Round 6's sweep of raw API.* calls that bypass Store entirely (a layer none of the prior five rounds checked) found the most easily-triggered gap yet: models.js's "Export settings..." button calls API.settingsCard directly, a real unmocked POST /api/settings-card -- and it's on the FIRST screen the tour's own "models-modal" step shows, no sidebar navigation needed (unlike round 5's Combo Sweep panel, which needed a click into Settings first). Fixed by mocking API.settingsCard with a real Blob (the function resolves a Blob and feeds it straight to URL.createObjectURL, so a JSON mock like every other entry uses would break the button instead of blocking it) -- a 1x1 transparent PNG is enough for the card to render. Also closed the secondary finding: applyGlobalPromptQuiet has TWO internal closure-scoped callers (setEditorSetting's anchorEnabled toggle, applyPromptTemplate) besides its exported Store property -- the same "block the export, miss the internal call" shape as commit()'s original vulnerability, so a patchStore() block-list entry alone couldn't have closed it. It reaches a real, unmocked API.parsePrompt call. Fixed with the same root-cause pattern as commit()'s guard: a window.__FUNPACK_TOUR__ check directly inside the function, which also covers scheduleGlobalPromptApply transitively since that just debounces into a call to this same function. Verified live: API.settingsCard resolves a real 68-byte PNG Blob (not an error); toggling anchorEnabled and calling scheduleGlobalPromptApply directly, waited past both debounce windows, zero prompt/expand or settings-card requests in the network log; full tour sequence still completes with zero thrown errors. Co-Authored-By: Claude Sonnet 5 --- app/legacy/store.js | 8 ++++++++ app/legacy/tour.js | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/app/legacy/store.js b/app/legacy/store.js index 615f0e66..810841c5 100644 --- a/app/legacy/store.js +++ b/app/legacy/store.js @@ -636,6 +636,14 @@ } async function applyGlobalPromptQuiet(text) { + // Same shape as commit()'s guard above, and needed for the same reason: + // this has TWO internal closure-scoped callers (setEditorSetting's + // anchorEnabled toggle, applyPromptTemplate) besides its exported + // property, so blocking Store.applyGlobalPromptQuiet from tour.js alone + // would miss both. It reaches a real, unmocked API.parsePrompt network + // call, so it needs its own gate here rather than relying on + // scheduleSaveSilent()/commit() downstream to cover it. + if (window.__FUNPACK_TOUR__) return false; if (!state.project) return false; const trimmed = String(text || "").trim(); if (!trimmed) return false; diff --git a/app/legacy/tour.js b/app/legacy/tour.js index 1e918518..6f1456e0 100644 --- a/app/legacy/tour.js +++ b/app/legacy/tour.js @@ -337,6 +337,20 @@ API.exportClip = ok({}); API.exportClipsCombined = ok({ job_id: "tour-demo" }); API.interrupt = ok({}); + // models.js's "Export settings..." button calls API.settingsCard + // directly -- it never goes through window.Store, so it was never a + // candidate for patchStore()'s blocklist, and it's a real, unmocked + // POST /api/settings-card one click away on the models-modal tour step + // (the FIRST screen that step shows, no sidebar navigation needed). + // The real function resolves a Blob (models.js feeds it straight to + // URL.createObjectURL), so the mock needs to be a real Blob too, not + // JSON -- a 1x1 transparent PNG is enough for the card to render. + API.settingsCard = () => Promise.resolve(new Blob( + [Uint8Array.from(atob( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" + ), (c) => c.charCodeAt(0))], + { type: "image/png" } + )); } function patchStore(Store) {