From 5e458db6ee603bcb71451eb6c15abcf2f6769d57 Mon Sep 17 00:00:00 2001 From: bnz183 Date: Tue, 16 Jun 2026 21:02:42 +0200 Subject: [PATCH 1/2] fix: clear pre-existing Studio editor lint errors Drop unused initializers in insertFileLink and scope an eslint-disable around the intentional latest-ref slash-extension pattern. No behavior change; build, unit tests, and e2e all pass. Co-Authored-By: Claude Opus 4.8 --- apps/studio/src/editor/EditorToolbar.tsx | 4 ++-- apps/studio/src/editor/SourceDraftEditor.tsx | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/studio/src/editor/EditorToolbar.tsx b/apps/studio/src/editor/EditorToolbar.tsx index 32def97..a51ca99 100644 --- a/apps/studio/src/editor/EditorToolbar.tsx +++ b/apps/studio/src/editor/EditorToolbar.tsx @@ -168,8 +168,8 @@ export function EditorToolbar({ } function insertFileLink(currentEditor: Editor) { - let path = ""; - let filename = "Document"; + let path: string; + let filename: string; if (latestUpload?.kind === "pdf") { path = latestUpload.publicPath; diff --git a/apps/studio/src/editor/SourceDraftEditor.tsx b/apps/studio/src/editor/SourceDraftEditor.tsx index f3e2e00..6c703d6 100644 --- a/apps/studio/src/editor/SourceDraftEditor.tsx +++ b/apps/studio/src/editor/SourceDraftEditor.tsx @@ -72,6 +72,10 @@ export function SourceDraftEditor({ const bodyVersion = useRef(body); const slashHandlerRef = useRef<(command: SlashCommandId) => void>(() => {}); + // Build the slash extension once and dispatch to the latest handler through + // slashHandlerRef, so the Tiptap extension is not rebuilt on every render. + // The React Compiler flags this intentional latest-ref pattern. + /* eslint-disable react-hooks/preserve-manual-memoization, react-hooks/refs */ const slashExtension = useMemo( () => createSlashCommandsExtension( @@ -109,6 +113,7 @@ export function SourceDraftEditor({ ), [], ); + /* eslint-enable react-hooks/preserve-manual-memoization, react-hooks/refs */ const editor = useEditor({ extensions: [ From ce286a777793710773200234c1829096ce73ddee Mon Sep 17 00:00:00 2001 From: bnz183 Date: Tue, 16 Jun 2026 22:22:10 +0200 Subject: [PATCH 2/2] fix: close demo write hole and harden disabled toolbar controls P1: add requireNonDemo guard so demo sessions and forced-demo deployments cannot trigger real writes; apply it to /api/setup/generate-config (which writes sourcedraft.config.json). The guard blocks genuine demo only, so a real authenticated user can still generate config before a publisher is configured. Centralize client publish gating (canSubmitPublish/isRealPublish): a real publish requires a connected, non-demo Studio; demo only simulates. P2: route toolbar disabled state and click handling through a single isToolbarButtonEnabled predicate so a disabled control can never run its command even if the native disabled state is bypassed. Adds regression tests: requireNonDemo (server), publish gating and isToolbarButtonEnabled (unit), and source-mode/disabled toolbar e2e. Co-Authored-By: Claude Opus 4.8 --- apps/studio/e2e/smoke.spec.ts | 26 +++++ apps/studio/server/auth.ts | 30 ++++++ apps/studio/server/index.ts | 2 + apps/studio/server/requireNonDemo.test.ts | 106 +++++++++++++++++++ apps/studio/src/components/PublishGate.tsx | 3 +- apps/studio/src/editor/EditorToolbar.tsx | 10 +- apps/studio/src/editor/toolbarButton.test.ts | 20 ++++ apps/studio/src/editor/toolbarButton.ts | 14 +++ apps/studio/src/lib/publishGate.test.ts | 67 ++++++++++++ apps/studio/src/lib/publishGate.ts | 32 ++++++ 10 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 apps/studio/server/requireNonDemo.test.ts create mode 100644 apps/studio/src/editor/toolbarButton.test.ts create mode 100644 apps/studio/src/editor/toolbarButton.ts create mode 100644 apps/studio/src/lib/publishGate.test.ts create mode 100644 apps/studio/src/lib/publishGate.ts diff --git a/apps/studio/e2e/smoke.spec.ts b/apps/studio/e2e/smoke.spec.ts index 5c12b77..c520e5f 100644 --- a/apps/studio/e2e/smoke.spec.ts +++ b/apps/studio/e2e/smoke.spec.ts @@ -193,4 +193,30 @@ test.describe("Studio smoke", () => { await expect(source).toBeVisible(); await expect(source).toHaveValue(//u); }); + + test("formatting controls cannot run in source mode", async ({ page }) => { + await enterDemoMode(page); + await page.getByRole("button", { name: "New article" }).click(); + const toolbar = page.getByRole("toolbar", { name: "Editor formatting" }); + await expect(toolbar.getByRole("button", { name: "Bold", exact: true })).toBeEnabled(); + + await page.getByRole("button", { name: "Source", exact: true }).click(); + + // In source mode the rich-text controls are not rendered, so none of them + // can execute against the hidden editor. + for (const name of ["Bold", "Underline", "Insert internal link"]) { + await expect(toolbar.getByRole("button", { name, exact: true })).toHaveCount(0); + } + }); + + test("undo and redo stay disabled with nothing to undo", async ({ page }) => { + await enterDemoMode(page); + await page.getByRole("button", { name: "New article" }).click(); + const toolbar = page.getByRole("toolbar", { name: "Editor formatting" }); + const undo = toolbar.getByRole("button", { name: "Undo", exact: true }); + await expect(undo).toBeDisabled(); + // Forcing a click past the native disabled state must not run the command. + await undo.click({ force: true }); + await expect(undo).toBeDisabled(); + }); }); diff --git a/apps/studio/server/auth.ts b/apps/studio/server/auth.ts index 2e599da..f7f4293 100644 --- a/apps/studio/server/auth.ts +++ b/apps/studio/server/auth.ts @@ -220,6 +220,36 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo res.status(401).json({ ok: false, error: "Authentication required." }); } +/** + * A request is a "hard" demo request when the deployment is forced into demo + * mode or the session itself was created through demo entry. This is stricter + * than {@link isRequestDemoSession}: a real (non-demo) authenticated user whose + * publisher is not configured yet is NOT treated as demo, so they can still run + * legitimate setup writes such as config generation. + */ +export function isHardDemoRequest(req: Request): boolean { + return isDemoModeForced() || isDemoSession(getSessionToken(req)); +} + +/** + * Guards routes that mutate real files or configuration. Demo mode must stay + * read/demo-only, so demo sessions (and forced-demo deployments) are rejected + * before any real write happens. Pair with {@link requireAuth}, which rejects + * unauthenticated requests first. + */ +export function requireNonDemo(req: Request, res: Response, next: NextFunction): void { + if (isHardDemoRequest(req)) { + res.status(403).json({ + ok: false, + error: + "This action is disabled in demo mode. Demo mode never changes real files or configuration.", + }); + return; + } + + next(); +} + export async function login( req: Request, password: string, diff --git a/apps/studio/server/index.ts b/apps/studio/server/index.ts index 184479a..19ef0a9 100644 --- a/apps/studio/server/index.ts +++ b/apps/studio/server/index.ts @@ -12,6 +12,7 @@ import { login, logout, requireAuth, + requireNonDemo, } from "./auth.js"; import { loadPublicConfig, loadPublishEnv } from "./config.js"; import { listDemoPostsHandler, loadDemoPost } from "./demoPosts.js"; @@ -138,6 +139,7 @@ app.post( writeLimiter, requireSameSiteRequest, requireAuth, + requireNonDemo, (_req, res) => { const result = runGenerateConfig(); if (!result.ok) { diff --git a/apps/studio/server/requireNonDemo.test.ts b/apps/studio/server/requireNonDemo.test.ts new file mode 100644 index 0000000..e2094f2 --- /dev/null +++ b/apps/studio/server/requireNonDemo.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; +import type { NextFunction, Request, Response } from "express"; +import { + createSession, + isHardDemoRequest, + isRequestDemoSession, + requireNonDemo, +} from "./auth.js"; + +const SESSION_COOKIE = "sourcedraft_session"; +const ENV_KEYS = [ + "SOURCEDRAFT_DEMO_MODE", + "CMS_PUBLISHER", + "GITHUB_TOKEN", + "GITHUB_OWNER", + "GITHUB_REPO", +] as const; + +const original = new Map(); + +function mockRequest(cookie?: string): Request { + return { headers: cookie ? { cookie } : {} } as Request; +} + +function mockResponse() { + const result: { statusCode?: number; body?: { ok?: boolean; error?: string } } = + {}; + const res = { + status(code: number) { + result.statusCode = code; + return this; + }, + json(payload: { ok?: boolean; error?: string }) { + result.body = payload; + return this; + }, + } as unknown as Response; + return { res, result }; +} + +function run(req: Request) { + const { res, result } = mockResponse(); + let nextCalled = false; + const next: NextFunction = () => { + nextCalled = true; + }; + requireNonDemo(req, res, next); + return { nextCalled, result }; +} + +describe("requireNonDemo", () => { + afterEach(() => { + for (const key of ENV_KEYS) { + const value = original.get(key); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + original.clear(); + }); + + function clearEnv(): void { + for (const key of ENV_KEYS) { + original.set(key, process.env[key]); + delete process.env[key]; + } + } + + it("rejects forced demo deployments before any real write", () => { + clearEnv(); + process.env.SOURCEDRAFT_DEMO_MODE = "true"; + + const { nextCalled, result } = run(mockRequest()); + assert.equal(nextCalled, false); + assert.equal(result.statusCode, 403); + assert.equal(result.body?.ok, false); + }); + + it("rejects demo sessions", () => { + clearEnv(); + const token = createSession({ demo: true }); + + const { nextCalled, result } = run(mockRequest(`${SESSION_COOKIE}=${token}`)); + assert.equal(nextCalled, false); + assert.equal(result.statusCode, 403); + }); + + it("allows a real authenticated session even before a publisher is configured", () => { + clearEnv(); + const token = createSession(); + const req = mockRequest(`${SESSION_COOKIE}=${token}`); + + // With no publisher configured, the broad demo-routing check still reports + // demo for read fallbacks, but the write guard must NOT block a real user + // who is trying to set up their config. + assert.equal(isRequestDemoSession(req), true); + assert.equal(isHardDemoRequest(req), false); + + const { nextCalled, result } = run(req); + assert.equal(nextCalled, true); + assert.equal(result.statusCode, undefined); + }); +}); diff --git a/apps/studio/src/components/PublishGate.tsx b/apps/studio/src/components/PublishGate.tsx index 34a5366..64408a8 100644 --- a/apps/studio/src/components/PublishGate.tsx +++ b/apps/studio/src/components/PublishGate.tsx @@ -1,6 +1,7 @@ import type { PublishMode } from "@sourcedraft/publishers"; import type { ValidationIssue } from "@sourcedraft/core"; import type { ArticleFormState } from "../lib/articleForm"; +import { canSubmitPublish } from "../lib/publishGate"; import { PublishChecklist } from "./PublishChecklist"; type PublishGateProps = { @@ -126,7 +127,7 @@ export function PublishGate({ onPublishModeChange, onPublish, }: PublishGateProps) { - const canPublish = ready && !publishing && (githubReady || demoMode); + const canPublish = canSubmitPublish({ ready, publishing, githubReady, demoMode }); const reason = disabledReason(ready, githubReady, publishing, demoMode); return ( diff --git a/apps/studio/src/editor/EditorToolbar.tsx b/apps/studio/src/editor/EditorToolbar.tsx index a51ca99..1994410 100644 --- a/apps/studio/src/editor/EditorToolbar.tsx +++ b/apps/studio/src/editor/EditorToolbar.tsx @@ -3,6 +3,7 @@ import { useEditorState, type Editor } from "@tiptap/react"; import type { PostSummary } from "../lib/posts.js"; import { InternalLinkPicker } from "../components/InternalLinkPicker.js"; import { editorDocToBody } from "./markdownRoundtrip.js"; +import { isToolbarButtonEnabled } from "./toolbarButton.js"; export type LatestMediaUpload = { publicPath: string; @@ -422,11 +423,14 @@ export function EditorToolbar({ aria-label={button.ariaLabel} aria-pressed={button.active ?? undefined} title={button.title ?? button.label} - disabled={button.disabled || editorMode !== "rich"} + disabled={!isToolbarButtonEnabled(button.disabled, editorMode)} onMouseDown={(event) => { event.preventDefault(); }} onClick={() => { + if (!isToolbarButtonEnabled(button.disabled, editorMode)) { + return; + } runEditorAction(button.action); }} > @@ -440,10 +444,14 @@ export function EditorToolbar({ aria-label="Insert internal link" title="Internal link" aria-expanded={internalLinkOpen} + disabled={!isToolbarButtonEnabled(false, editorMode)} onMouseDown={(event) => { event.preventDefault(); }} onClick={() => { + if (!isToolbarButtonEnabled(false, editorMode)) { + return; + } setInternalLinkOpen(true); }} > diff --git a/apps/studio/src/editor/toolbarButton.test.ts b/apps/studio/src/editor/toolbarButton.test.ts new file mode 100644 index 0000000..12bf72a --- /dev/null +++ b/apps/studio/src/editor/toolbarButton.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { isToolbarButtonEnabled } from "./toolbarButton.js"; + +describe("isToolbarButtonEnabled", () => { + it("enables a non-disabled button in rich mode", () => { + assert.equal(isToolbarButtonEnabled(false, "rich"), true); + assert.equal(isToolbarButtonEnabled(undefined, "rich"), true); + }); + + it("disables an individually-disabled button even in rich mode", () => { + assert.equal(isToolbarButtonEnabled(true, "rich"), false); + }); + + it("disables every control in source mode", () => { + assert.equal(isToolbarButtonEnabled(false, "source"), false); + assert.equal(isToolbarButtonEnabled(undefined, "source"), false); + assert.equal(isToolbarButtonEnabled(true, "source"), false); + }); +}); diff --git a/apps/studio/src/editor/toolbarButton.ts b/apps/studio/src/editor/toolbarButton.ts new file mode 100644 index 0000000..263e641 --- /dev/null +++ b/apps/studio/src/editor/toolbarButton.ts @@ -0,0 +1,14 @@ +export type EditorMode = "rich" | "source"; + +/** + * A toolbar control may only run its command when it is not individually + * disabled and the editor is in rich mode. Centralizing the rule keeps the + * `disabled` attribute and the click handler in sync, so a disabled button can + * never execute its action even if the native disabled state is bypassed. + */ +export function isToolbarButtonEnabled( + disabled: boolean | undefined, + editorMode: EditorMode, +): boolean { + return disabled !== true && editorMode === "rich"; +} diff --git a/apps/studio/src/lib/publishGate.test.ts b/apps/studio/src/lib/publishGate.test.ts new file mode 100644 index 0000000..a6ec6b2 --- /dev/null +++ b/apps/studio/src/lib/publishGate.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { canSubmitPublish, isRealPublish } from "./publishGate.js"; + +describe("publish gating", () => { + it("treats only a connected, non-demo Studio as a real publish", () => { + assert.equal(isRealPublish({ githubReady: true, demoMode: false }), true); + assert.equal(isRealPublish({ githubReady: true, demoMode: true }), false); + assert.equal(isRealPublish({ githubReady: false, demoMode: false }), false); + }); + + it("allows demo mode to submit (simulation only), never a real publish", () => { + const inputs = { + ready: true, + publishing: false, + githubReady: false, + demoMode: true, + }; + assert.equal(canSubmitPublish(inputs), true); + assert.equal(isRealPublish(inputs), false); + }); + + it("allows a configured non-demo Studio to publish for real", () => { + assert.equal( + canSubmitPublish({ + ready: true, + publishing: false, + githubReady: true, + demoMode: false, + }), + true, + ); + }); + + it("blocks publish when not connected and not in demo mode", () => { + assert.equal( + canSubmitPublish({ + ready: true, + publishing: false, + githubReady: false, + demoMode: false, + }), + false, + ); + }); + + it("blocks publish for invalid or in-flight articles", () => { + assert.equal( + canSubmitPublish({ + ready: false, + publishing: false, + githubReady: true, + demoMode: false, + }), + false, + ); + assert.equal( + canSubmitPublish({ + ready: true, + publishing: true, + githubReady: true, + demoMode: false, + }), + false, + ); + }); +}); diff --git a/apps/studio/src/lib/publishGate.ts b/apps/studio/src/lib/publishGate.ts new file mode 100644 index 0000000..3a29e84 --- /dev/null +++ b/apps/studio/src/lib/publishGate.ts @@ -0,0 +1,32 @@ +export type PublishGateInputs = { + ready: boolean; + publishing: boolean; + githubReady: boolean; + demoMode: boolean; +}; + +/** + * A real publish mutates the connected repository, so it is only allowed for a + * configured, connected, non-demo Studio. Demo mode never publishes for real — + * it only simulates. The Studio shell already renders the publish bar for + * authenticated sessions; the server is the authoritative boundary and routes + * demo sessions to simulation while rejecting unauthenticated requests. + */ +export function isRealPublish( + inputs: Pick, +): boolean { + return inputs.githubReady && !inputs.demoMode; +} + +/** + * Whether the publish/simulate button may be submitted: the article must be + * valid, not already in flight, and either a real connected blog (non-demo) or + * demo mode (simulation only). + */ +export function canSubmitPublish(inputs: PublishGateInputs): boolean { + if (!inputs.ready || inputs.publishing) { + return false; + } + + return isRealPublish(inputs) || inputs.demoMode; +}