diff --git a/CHANGELOG/pages/proto-home-v5.md b/CHANGELOG/pages/proto-home-v5.md index 3dcd8c98..7e49337e 100644 --- a/CHANGELOG/pages/proto-home-v5.md +++ b/CHANGELOG/pages/proto-home-v5.md @@ -2,6 +2,23 @@ Append-only lane for the ScratchNode live-event prototype and production static surface. +## 2026-06-03 — Host public-write hardening: FAQ-promote + wiki-publish require a verified host (scratchnode/002) +`snPromoteFaq` and `snPublishWiki` were the last two host-only PUBLIC-write actions still +reading the host key via the weak `_snReadHostOwnerKey()` (which falls back through +localStorage to a bare `sessionId`). Every other host action already used +`_snRequireVerifiedHostOwnerKey()`. The backend `requireHost` always rejected a bare +sessionId, so this was never an exploit — but the frontend would still ATTEMPT the mutation +and surface a confusing raw-backend-error toast to a non-host. Both now call +`_snRequireVerifiedHostOwnerKey('sn-manage-event-output')` and early-return with a clear +"Host verification required" toast when the session is not a verified host — the failure +mode is made impossible, not hidden. CI-locked by 3 new honesty tests +(guest-cannot-write, SN-LIVE-007/008 private-stays-private, public-send control); the +existing publish-wiki recap test now establishes the realistic verified-host precondition. +Permission-consistency only — send/render path and the public/private boundary untouched. +e2e: output-contract green; honesty 27/27. + +**Commit**: `this commit`. **Author**: Homen Shum + Claude. + ## 2026-06-03 — Re-add the wiki reader's "Continue in NodeBench" CTA (route now real) The public `/wiki/` reader shipped WITHOUT a "Continue in NodeBench" CTA because its receiving route (`nodebenchai.com/events//wiki`) 404'd at the time. PR-D built diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index eebb91df..c93fc025 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -8084,14 +8084,16 @@

Keyboard shortcuts

}; window.snPromoteFaq = function(answerId) { - const ownerKey = _snReadHostOwnerKey(); + const ownerKey = _snRequireVerifiedHostOwnerKey('sn-manage-event-output'); + if (!ownerKey) return; client.mutation('events:promoteAnswerToFaq', { eventId, answerId, ownerKey }) .then(() => toast('Promoted to FAQ', 'This answer is ready for the event wiki.')) .catch((e) => toast('Promotion blocked', e.message || String(e))); }; window.snPublishWiki = function() { - const ownerKey = _snReadHostOwnerKey(); + const ownerKey = _snRequireVerifiedHostOwnerKey('sn-manage-event-output'); + if (!ownerKey) return; client.mutation('events:publishWiki', { eventId, ownerKey }) .then((res) => { window._sn_public_wiki_url = getPublicWikiUrl(); diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index 0d7d061d..030f539b 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -757,8 +757,16 @@ test.describe("ScratchNode live route honesty", () => { page, }) => { await fulfillScratchNodePage(page); + // A verified host is the realistic precondition for publishing (scratchnode/002 + // boundary fix: snPublishWiki now early-returns for non-verified sessions). + await page.addInitScript(() => { + localStorage.setItem("sn_host_owner_key_v2", "hk1:liveEvents:1:nonce:1770000000000:abcdefabcdefabcdefabcdefabcdef12"); + }); await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + await expect + .poll(() => page.evaluate(() => (window as any)._sn_live?.hostVerified === true), { timeout: 5_000 }) + .toBe(true); // Host publishes the wiki (the real success path) — should open the recap // moment so they learn the public address, not just a toast. @@ -817,4 +825,95 @@ test.describe("ScratchNode live route honesty", () => { await expect(nudge).toHaveAttribute("data-show", "false"); expect(await page.evaluate(() => localStorage.getItem("sn_mem_nudge_off"))).toBe("1"); }); + + test("guest cannot trigger host-only public-write mutations (promoteFaq / publishWiki)", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + // Guest session: no sn_host_owner_key_v2 was set, so _sn_live.hostVerified is false. + // The two PUBLIC-write actions must early-return via _snRequireVerifiedHostOwnerKey, + // NOT fall back to sessionId (the scratchnode/002 boundary fix). Backend requireHost + // would also reject, but the frontend must not even attempt the mutation. + await page.evaluate(() => { + const w = window as any; + if (typeof w.snPromoteFaq === "function") w.snPromoteFaq("liveEventAnswers:1"); + if (typeof w.snPublishWiki === "function") w.snPublishWiki(); + }); + + const calledMutations = await page.evaluate(() => + ((window as any).__snMockMutations || []).map((c: any) => c.name), + ); + expect(calledMutations).not.toContain("events:promoteAnswerToFaq"); + expect(calledMutations).not.toContain("events:publishWiki"); + await expect(page.locator(".toast")).toContainText("Host verification required"); + }); + + test("SN-LIVE-007/-008 private send saves privately and creates NO public message", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const result = await page.evaluate(() => { + const w = window as any; + const countBefore = + typeof w.getPrivateNoteHandoffCount === "function" + ? w.getPrivateNoteHandoffCount() + : (w._privateNotes_v5 || []).length; + // Enter private mode, then send a uniquely-identifiable private note. + if (document.body.getAttribute("data-mode") !== "private") w.toggleLock(); + const input = document.getElementById("ci") as HTMLInputElement; + input.value = "PRIV-secret-latency-budget-xyz"; + input.dispatchEvent(new Event("input", { bubbles: true })); + w.sendComposerMessage(); + const countAfter = + typeof w.getPrivateNoteHandoffCount === "function" + ? w.getPrivateNoteHandoffCount() + : (w._privateNotes_v5 || []).length; + return { countBefore, countAfter }; + }); + + // SN-LIVE-007: the private text must NEVER reach a public eventMessages write. + const sentPublicly = await page.evaluate(() => + ((window as any).__snMockMutations || []).some( + (c: any) => + c.name === "events:sendMessage" && + JSON.stringify(c.args || {}).includes("secret-latency-budget-xyz"), + ), + ); + expect(sentPublicly).toBe(false); + // SN-LIVE-008: the private note IS captured privately (notebook count grew). + expect(result.countAfter).toBeGreaterThan(result.countBefore); + }); + + test("public send DOES create a public message (boundary control)", async ({ page }) => { + await fulfillScratchNodePage(page); + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + await page.evaluate(() => { + const w = window as any; + if (document.body.getAttribute("data-mode") === "private") w.toggleLock(); + const input = document.getElementById("ci") as HTMLInputElement; + input.value = "PUBLIC-hello-everyone-xyz"; + input.dispatchEvent(new Event("input", { bubbles: true })); + w.sendComposerMessage(); + }); + + await expect + .poll( + () => + page.evaluate(() => + ((window as any).__snMockMutations || []).some( + (c: any) => c.name === "events:sendMessage", + ), + ), + { timeout: 5000 }, + ) + .toBe(true); + }); });