diff --git a/CLAUDE.md b/CLAUDE.md index 44c18fb..545831e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ node dist/server.js curl http://localhost:3000/health ``` -443 tests across unit, API, WebSocket, and regression suites. Run `npm test` or `pnpm test`. CI runs `type-check` then `build` then `test` across Node 18/20/22. +446 tests across unit, API, WebSocket, and regression suites. Run `npm test` or `pnpm test`. CI runs `type-check` then `build` then `test` across Node 18/20/22. ## Architecture @@ -112,12 +112,12 @@ Two Dockerfiles: `Dockerfile` (MCP server only), `Dockerfile.canvas` (canvas wit ### Security posture (as of 1.6.3) - `src/security.ts`: helmet, CORS allowlist, timing-safe API key auth, prototype pollution guard, 3-tier rate limiting, WS challenge-response auth, Mermaid input size cap -- 443/443 tests passing; 4 regression tests cover previously crash-able sync paths +- 446/446 tests passing; 4 regression tests cover previously crash-able sync paths - Docker: non-root user, resource limits, hardened `.dockerignore` ### Before running `npm publish` - [ ] Bump `version` in `package.json` to match `CHANGELOG.md` entry (currently `1.0.1`) -- [ ] Run `npm test` — must be 443/443 +- [ ] Run `npm test` — must be 446/446 - [ ] Run `npm run build` — must be zero TS errors - [ ] Run `shipguard scan .` — must be 0 CRITICAL findings - [ ] Verify `CHANGELOG.md` has an entry for the version being published diff --git a/Dockerfile b/Dockerfile index e2c4886..bce8518 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,6 +43,6 @@ ENV EXCALIDRAW_DB_PATH=/app/data/excalidraw.db CMD ["node", "dist/index.js"] -LABEL org.opencontainers.image.source="https://github.com/sanjibdevnathlabs/mcp-excalidraw-local" +LABEL org.opencontainers.image.source="https://github.com/celstnblacc/excalidraw-mcp-sentinel" LABEL org.opencontainers.image.description="MCP Excalidraw Server - Model Context Protocol for AI agents (with SQLite persistence & multi-tenancy)" LABEL org.opencontainers.image.licenses="MIT" diff --git a/Dockerfile.canvas b/Dockerfile.canvas index 140a01e..990db10 100644 --- a/Dockerfile.canvas +++ b/Dockerfile.canvas @@ -61,6 +61,6 @@ EXPOSE 3000 CMD ["node", "dist/server.js"] -LABEL org.opencontainers.image.source="https://github.com/sanjibdevnathlabs/mcp-excalidraw-local" +LABEL org.opencontainers.image.source="https://github.com/celstnblacc/excalidraw-mcp-sentinel" LABEL org.opencontainers.image.description="MCP Excalidraw Canvas Server - Web UI and REST API (with SQLite persistence & multi-tenancy)" LABEL org.opencontainers.image.licenses="MIT" diff --git a/README.md b/README.md index 50c4a73..bb2e320 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,13 @@ Run a live Excalidraw canvas and control it from any AI agent. This repo provide - **SQLite Persistence**: Elements survive restarts, with versioning and search - **Multi-Tenancy**: Isolated canvases per workspace, auto-detected - **Security Hardened**: Helmet, rate limiting, API key auth, prototype pollution guard, WS challenge-response -- **369 Tests**: Full test coverage across unit, API, WebSocket, and regression tests +- **446 Tests**: Full test coverage across unit, API, WebSocket, and regression tests ## Why this fork? Forked from [celstnblacc/excalidraw-mcp-sentinel](https://github.com/celstnblacc/excalidraw-mcp-sentinel) (itself a fork of [yctimlin/mcp_excalidraw](https://github.com/yctimlin/mcp_excalidraw)) with production hardening: -- **443 tests** (upstream has none) — unit, API, WebSocket, and regression +- **446 tests** (upstream has none) — unit, API, WebSocket, and regression - **Security middleware** (`src/security.ts`): helmet, CORS allowlist, timing-safe API key auth, prototype pollution guard, input sanitization - **3-tier rate limiting**: general, destructive, and write-burst ceilings - **WebSocket challenge-response authentication** diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 870faad..c19ce9f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -88,13 +88,26 @@ function App(): JSX.Element { const knownContainerIdsRef = useRef>(new Set()) const CONTAINER_TYPES = new Set(['rectangle', 'ellipse', 'diamond']) + // Seed knownContainerIdsRef before updateScene to prevent re-injection on load/sync + const seedKnownContainers = (elements: readonly { type: string; id: string }[]): void => { + for (const el of elements) { + if (CONTAINER_TYPES.has(el.type)) { + knownContainerIdsRef.current.add(el.id) + } + } + } + // Custom font size input state const [customFontSize, setCustomFontSize] = useState('') // Draggable widget state — default near top menu const [widgetPos, setWidgetPos] = useState<{x: number, y: number}>(() => { - const saved = localStorage.getItem('font-widget-pos') - return saved ? JSON.parse(saved) : { x: window.innerWidth * 0.55, y: 90 } + try { + const saved = localStorage.getItem('font-widget-pos') + return saved ? JSON.parse(saved) : { x: window.innerWidth * 0.55, y: 90 } + } catch { + return { x: window.innerWidth * 0.55, y: 90 } + } }) const [isDragging, setIsDragging] = useState(false) const dragOffset = useRef<{x: number, y: number}>({x: 0, y: 0}) @@ -162,10 +175,11 @@ function App(): JSX.Element { }) } - // Clean up debounce timer on unmount + // Clean up timers on unmount useEffect(() => { return () => { if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current) + if (pendingTitleTimerRef.current) clearTimeout(pendingTitleTimerRef.current) } }, []) @@ -310,11 +324,7 @@ function App(): JSX.Element { const finalElements = prepareElementsForScene(result.elements, convertToExcalidrawElements as any) // Seed known containers BEFORE updateScene so onChange doesn't re-inject titles - for (const el of finalElements) { - if (CONTAINER_TYPES.has(el.type)) { - knownContainerIdsRef.current.add(el.id) - } - } + seedKnownContainers(finalElements) excalidrawAPI?.updateScene({ elements: finalElements }) @@ -543,11 +553,7 @@ function App(): JSX.Element { if (Array.isArray(data.elements) && data.elements.length > 0) { const finalElements = prepareElementsForScene(data.elements, convertToExcalidrawElements as any) // Seed known containers before updateScene - for (const el of finalElements) { - if (CONTAINER_TYPES.has(el.type)) { - knownContainerIdsRef.current.add(el.id) - } - } + seedKnownContainers(finalElements) api.updateScene({ elements: finalElements, captureUpdate: CaptureUpdateAction.NEVER @@ -593,11 +599,7 @@ function App(): JSX.Element { case 'initial_elements': if (data.elements && data.elements.length > 0) { const initFinalElements = prepareElementsForScene(data.elements, convertToExcalidrawElements as any) - for (const el of initFinalElements) { - if (CONTAINER_TYPES.has(el.type)) { - knownContainerIdsRef.current.add(el.id) - } - } + seedKnownContainers(initFinalElements) api.updateScene({ elements: initFinalElements, captureUpdate: CaptureUpdateAction.NEVER @@ -1118,11 +1120,7 @@ function App(): JSX.Element { const result: ApiResponse = await elemRes.json() if (result.success && result.elements && result.elements.length > 0) { const switchedElements = prepareElementsForScene(result.elements, convertToExcalidrawElements as any) - for (const el of switchedElements) { - if (CONTAINER_TYPES.has(el.type)) { - knownContainerIdsRef.current.add(el.id) - } - } + seedKnownContainers(switchedElements) excalidrawAPI?.updateScene({ elements: switchedElements }) } diff --git a/src/index.ts b/src/index.ts index e47df03..33b1022 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1151,7 +1151,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) const subtitleElement: ServerElement = { id: subtitleId, - type: 'text' as any, + type: 'text', x: element.x + 10, y: element.y + (containerHeight * 0.45), width: containerWidth - 20, diff --git a/tests/backend/api.test.ts b/tests/backend/api.test.ts index 55629b4..44fc173 100644 --- a/tests/backend/api.test.ts +++ b/tests/backend/api.test.ts @@ -587,3 +587,69 @@ describe('canvasStatus in mutation responses', () => { expect(res.body.canvasStatus).toHaveProperty('scope'); }); }); + +// Regression: textAlign/verticalAlign/containerId must survive REST round-trip +// These fields were silently stripped by Zod before the fix (ElementSharedFieldsSchema +// did not declare them, so .parse() dropped them). +describe('Text alignment fields — REST round-trip regression', () => { + it('POST /api/elements preserves textAlign and verticalAlign', async () => { + const res = await request(app) + .post('/api/elements') + .send({ + type: 'text', + x: 10, y: 20, width: 100, height: 30, + text: 'Hello', + textAlign: 'center', + verticalAlign: 'middle', + }); + + expect(res.status).toBe(200); + const el = res.body.element; + expect(el.textAlign).toBe('center'); + expect(el.verticalAlign).toBe('middle'); + }); + + it('POST /api/elements preserves containerId on bound text', async () => { + // Create container first + const containerRes = await request(app) + .post('/api/elements') + .send({ type: 'rectangle', x: 0, y: 0, width: 200, height: 100 }); + expect(containerRes.status).toBe(200); + const containerId = containerRes.body.element?.id; + expect(containerId).toBeTruthy(); + + // Create bound text referencing the container + const textRes = await request(app) + .post('/api/elements') + .send({ + type: 'text', + x: 10, y: 10, width: 180, height: 20, + text: 'Title', + textAlign: 'center', + verticalAlign: 'top', + containerId, + }); + + expect(textRes.status).toBe(200); + const textEl = textRes.body.element; + expect(textEl.containerId).toBe(containerId); + expect(textEl.textAlign).toBe('center'); + expect(textEl.verticalAlign).toBe('top'); + }); + + it('PUT /api/elements/:id preserves textAlign on update', async () => { + const createRes = await request(app) + .post('/api/elements') + .send({ type: 'text', x: 0, y: 0, width: 100, height: 30, text: 'Hi', textAlign: 'left' }); + expect(createRes.status).toBe(200); + const id = createRes.body.element?.id; + expect(id).toBeTruthy(); + + const updateRes = await request(app) + .put(`/api/elements/${id}`) + .send({ id, type: 'text', x: 0, y: 0, textAlign: 'center' }); + + expect(updateRes.status).toBe(200); + expect(updateRes.body.element?.textAlign).toBe('center'); + }); +});