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 32def97..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;
@@ -168,8 +169,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;
@@ -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/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: [
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;
+}