Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG/pages/proto-home-v5.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>` reader shipped WITHOUT a "Continue in NodeBench" CTA because
its receiving route (`nodebenchai.com/events/<slug>/wiki`) 404'd at the time. PR-D built
Expand Down
6 changes: 4 additions & 2 deletions public/proto/home-v5.html
Original file line number Diff line number Diff line change
Expand Up @@ -8084,14 +8084,16 @@ <h2 id="kbd-title">Keyboard shortcuts</h2>
};

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();
Expand Down
99 changes: 99 additions & 0 deletions tests/e2e/scratchnode-live-route-honesty.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
});
});
Loading