feat(security): harden canvas server with auth, rate-limiting, and validation

- Add security.ts: helmet, CORS allowlist, timing-safe API key auth, prototype
  pollution guard, Mermaid input limits, rate limiting (general/destructive/burst)
- WS auth challenge-response with 5 s timeout and close code 4001
- Fix sync crash: array check before logger access (500 → 400)
- Fix sync/v2: validate element type before write (invalid → 400)
- Upgrade zod 3.22.4 → 3.25.5 (fixes ERR_PACKAGE_PATH_NOT_EXPORTED on startup)
- Extract ElementSharedFieldsSchema; move VALID_ELEMENT_TYPES to module level
- Docker: resource limits, .dockerignore hardening
- Add .project-hooks/pre-commit; expand test coverage (369 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
newblacc
2026-03-29 16:06:04 +02:00
co-authored by Claude Sonnet 4.6
parent a1977d86f9
commit 5539235004
29 changed files with 1287 additions and 664 deletions
+8
View File
@@ -30,6 +30,7 @@ coverage
.nyc_output .nyc_output
*.test.ts *.test.ts
*.spec.ts *.spec.ts
tests
# CI/CD # CI/CD
.github .github
@@ -49,6 +50,13 @@ docker-compose*.yml
.env.local .env.local
.env.*.local .env.*.local
# Sensitive key material
*.pem
*.key
*.p12
*.pfx
*.crt
# Misc # Misc
tmp tmp
temp temp
+17 -17
View File
@@ -17,7 +17,7 @@ jobs:
outputs: outputs:
should_test: ${{ steps.filter.outputs.should_test }} should_test: ${{ steps.filter.outputs.should_test }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -42,16 +42,16 @@ jobs:
if: needs.check-changes.outputs.should_test == 'true' if: needs.check-changes.outputs.should_test == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: '20.x' node-version: '20.x'
- name: Cache node_modules - name: Cache node_modules
id: cache-nm id: cache-nm
uses: actions/cache@v4 uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with: with:
path: node_modules path: node_modules
key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }} key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
@@ -64,7 +64,7 @@ jobs:
run: npm run build run: npm run build
- name: Upload build output - name: Upload build output
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v4.6.2
with: with:
name: dist name: dist
path: dist/ path: dist/
@@ -75,15 +75,15 @@ jobs:
needs: setup needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: '20.x' node-version: '20.x'
- name: Restore node_modules from cache - name: Restore node_modules from cache
uses: actions/cache/restore@v4 uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with: with:
path: node_modules path: node_modules
key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }} key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
@@ -96,15 +96,15 @@ jobs:
needs: setup needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: '20.x' node-version: '20.x'
- name: Restore node_modules from cache - name: Restore node_modules from cache
uses: actions/cache/restore@v4 uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with: with:
path: node_modules path: node_modules
key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }} key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
@@ -117,21 +117,21 @@ jobs:
needs: setup needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: '20.x' node-version: '20.x'
- name: Restore node_modules from cache - name: Restore node_modules from cache
uses: actions/cache/restore@v4 uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with: with:
path: node_modules path: node_modules
key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }} key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
- name: Download build output - name: Download build output
uses: actions/download-artifact@v4 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with: with:
name: dist name: dist
path: dist/ path: dist/
@@ -148,7 +148,7 @@ jobs:
- name: Cache Playwright browsers - name: Cache Playwright browsers
id: cache-pw id: cache-pw
uses: actions/cache@v4 uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with: with:
path: ~/.cache/ms-playwright path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
@@ -168,7 +168,7 @@ jobs:
- name: Upload Playwright report - name: Upload Playwright report
if: failure() if: failure()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v4.6.2
with: with:
name: playwright-report name: playwright-report
path: playwright-report/ path: playwright-report/
+12 -12
View File
@@ -27,7 +27,7 @@ jobs:
outputs: outputs:
should_build: ${{ steps.filter.outputs.should_build }} should_build: ${{ steps.filter.outputs.should_build }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -52,21 +52,21 @@ jobs:
if: needs.check-changes.outputs.should_build == 'true' if: needs.check-changes.outputs.should_build == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Log in to Docker Hub - name: Log in to Docker Hub
if: github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true' if: github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true'
uses: docker/login-action@v3 uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata - name: Extract metadata
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@902fa8ec7d6ecbea8d2b4be1c9f4f0fc4a38bf1a # v5.7.0
with: with:
images: ${{ env.IMAGE_NAME_MCP }} images: ${{ env.IMAGE_NAME_MCP }}
tags: | tags: |
@@ -74,7 +74,7 @@ jobs:
type=sha,prefix=sha- type=sha,prefix=sha-
- name: Build MCP Server image - name: Build MCP Server image
uses: docker/build-push-action@v5 uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v5.5.0
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@@ -90,21 +90,21 @@ jobs:
if: needs.check-changes.outputs.should_build == 'true' if: needs.check-changes.outputs.should_build == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Log in to Docker Hub - name: Log in to Docker Hub
if: github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true' if: github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true'
uses: docker/login-action@v3 uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata - name: Extract metadata
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@902fa8ec7d6ecbea8d2b4be1c9f4f0fc4a38bf1a # v5.7.0
with: with:
images: ${{ env.IMAGE_NAME_CANVAS }} images: ${{ env.IMAGE_NAME_CANVAS }}
tags: | tags: |
@@ -112,7 +112,7 @@ jobs:
type=sha,prefix=sha- type=sha,prefix=sha-
- name: Build Canvas Server image - name: Build Canvas Server image
uses: docker/build-push-action@v5 uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v5.5.0
with: with:
context: . context: .
file: ./Dockerfile.canvas file: ./Dockerfile.canvas
@@ -128,7 +128,7 @@ jobs:
if: needs.build-mcp.result == 'success' && needs.build-canvas.result == 'success' if: needs.build-mcp.result == 'success' && needs.build-canvas.result == 'success'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Build and test Canvas image locally - name: Build and test Canvas image locally
run: | run: |
+2 -2
View File
@@ -24,10 +24,10 @@ jobs:
id-token: write id-token: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: '20.x' node-version: '20.x'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
+12 -12
View File
@@ -26,7 +26,7 @@ jobs:
should_release: ${{ steps.bump.outputs.should_release }} should_release: ${{ steps.bump.outputs.should_release }}
prev_tag: ${{ steps.bump.outputs.prev_tag }} prev_tag: ${{ steps.bump.outputs.prev_tag }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -128,12 +128,12 @@ jobs:
steps: steps:
- name: Generate release bot token - name: Generate release bot token
id: app-token id: app-token
uses: actions/create-github-app-token@v1 uses: actions/create-github-app-token@c1a285145b9d317df6ced56c550f5b5e3e8cd3f9 # v1.11.6
with: with:
app-id: ${{ secrets.APP_ID }} app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }} private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ steps.app-token.outputs.token }} token: ${{ steps.app-token.outputs.token }}
@@ -156,7 +156,7 @@ jobs:
git push origin "v${{ needs.check.outputs.new_version }}" git push origin "v${{ needs.check.outputs.new_version }}"
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@da05d552573ad5aba36ea0be2ddfef1a7e5c4d12 # v2.2.2
with: with:
token: ${{ steps.app-token.outputs.token }} token: ${{ steps.app-token.outputs.token }}
tag_name: v${{ needs.check.outputs.new_version }} tag_name: v${{ needs.check.outputs.new_version }}
@@ -180,19 +180,19 @@ jobs:
needs: release needs: release
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: main ref: main
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: '20.x' node-version: '20.x'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
- name: Cache node_modules - name: Cache node_modules
id: cache-nm id: cache-nm
uses: actions/cache@v4 uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with: with:
path: node_modules path: node_modules
key: node-modules-${{ runner.os }}-node20.x-${{ hashFiles('package-lock.json') }} key: node-modules-${{ runner.os }}-node20.x-${{ hashFiles('package-lock.json') }}
@@ -233,21 +233,21 @@ jobs:
needs: release needs: release
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: v${{ needs.release.outputs.version }} ref: v${{ needs.release.outputs.version }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push MCP Server image - name: Build and push MCP Server image
uses: docker/build-push-action@v5 uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v5.5.0
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@@ -260,7 +260,7 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
- name: Build and push Canvas Server image - name: Build and push Canvas Server image
uses: docker/build-push-action@v5 uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v5.5.0
with: with:
context: . context: .
file: ./Dockerfile.canvas file: ./Dockerfile.canvas
+6
View File
@@ -7,6 +7,12 @@ public/dist/
# Environment files # Environment files
.env .env
.env.*
*.key
*.pem
*.p12
*.pfx
secrets.json
# Logs # Logs
*.log *.log
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Project-level pre-commit hook for mcp-excalidraw-local.
# ShipGuard SAST and secret detection are handled by the global hook.
# This hook runs the project test suite.
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT"
echo "→ Running tests (vitest)..."
npm test --silent
+36
View File
@@ -0,0 +1,36 @@
# Changelog
All notable changes to this project are documented here.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
## [Unreleased]
## [1.6.3] - 2026-03-29
### Security
- Added `security.ts` middleware module: helmet headers, explicit CORS allowlist, API key auth
with timing-safe comparison, prototype pollution guard, Mermaid input size limits
- WebSocket authentication: challenge-response (`auth_required``hello + apiKey`) with 5 s
timeout and close code 4001 on failure; origin verification via `verifyClient`
- Rate limiting on all `/api/*` routes: 100 req/15 min general, 10 req/min destructive, 10 req/min
sync write burst
- `sanitizeSearchQuery` now throws typed `InvalidSearchQueryError` instead of generic `Error`
- Docker: added `deploy.resources.limits` (canvas 1 CPU/512M, mcp 0.5 CPU/256M) to
`docker-compose.yml`; extended `.dockerignore` with `tests/` and sensitive key file patterns
### Fixed
- `POST /api/elements/sync`: array validation now runs before logger access, preventing a
`TypeError` crash (500) on null/non-array input — now returns 400
- `POST /api/elements/sync/v2`: element type validated against `EXCALIDRAW_ELEMENT_TYPES`
before write; invalid types return 400 instead of being persisted silently
- Upgraded `zod` from 3.22.4 to 3.25.5 to resolve `ERR_PACKAGE_PATH_NOT_EXPORTED` crash at
MCP server startup caused by `zod-to-json-schema` peer dependency mismatch
### Changed
- `ElementSharedFieldsSchema` extracted from `CreateElementSchema`/`UpdateElementSchema` to
eliminate 25-field duplication; both schemas now use `.extend()`
- `VALID_ELEMENT_TYPES` moved to module-level constant (was allocated per-request)
- `resolveHelloTenantAndProject` parameter typed as `HelloMessage` (was `any`)
- `getAllFilesObject()` helper extracted; `sendFilesAdded()` and `GET /api/files` share it
- `sendLegacyInitialWsMessages` renamed to `sendAuthlessInitialMessages`
- `.project-hooks/pre-commit` added to run vitest on every commit
+3
View File
@@ -51,6 +51,9 @@ USER nodejs
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
# HOST=0.0.0.0 is correct inside Docker: the container binds all interfaces,
# but external access is gated by the published port mapping in docker-compose.yml.
# For local dev without Docker, the server defaults to localhost (127.0.0.1).
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
ENV EXCALIDRAW_DB_PATH=/app/data/excalidraw.db ENV EXCALIDRAW_DB_PATH=/app/data/excalidraw.db
+50 -6
View File
@@ -449,6 +449,31 @@ This fork extends [yctimlin/mcp_excalidraw](https://github.com/yctimlin/mcp_exca
| `EXCALIDRAW_DB_PATH` | Path to the SQLite database file | `~/.excalidraw-mcp/excalidraw.db` | | `EXCALIDRAW_DB_PATH` | Path to the SQLite database file | `~/.excalidraw-mcp/excalidraw.db` |
| `EXCALIDRAW_EXPORT_DIR` | Allowed directory for file exports | `process.cwd()` | | `EXCALIDRAW_EXPORT_DIR` | Allowed directory for file exports | `process.cwd()` |
| `EXPRESS_SERVER_URL` | Canvas server URL (only if running canvas separately) | `http://localhost:3000` | | `EXPRESS_SERVER_URL` | Canvas server URL (only if running canvas separately) | `http://localhost:3000` |
| `EXCALIDRAW_API_KEY` | Shared secret for API key auth on all `/api/*` routes. When unset, auth is disabled (dev mode). | _(unset — auth off)_ |
| `ALLOWED_ORIGINS` | Comma-separated list of allowed CORS + WebSocket origins | `http://localhost:3000,http://127.0.0.1:3000` |
| `EXCALIDRAW_RATE_LIMIT_GENERAL_MAX` | Override the general API rate-limit ceiling (requests per 15-minute window) | `100` |
| `EXCALIDRAW_RATE_LIMIT_DESTRUCTIVE_MAX` | Override the destructive-operation rate-limit ceiling (requests per 1-minute window) | `10` |
| `EXCALIDRAW_RATE_LIMIT_WRITE_BURST_MAX` | Override the sync write-burst rate-limit ceiling (requests per 1-minute window) | `10` |
### Security configuration
**Enabling API key protection** (recommended for any network-accessible deployment):
```bash
EXCALIDRAW_API_KEY=your-secret-here node dist/server.js
```
All requests to `/api/*` must then include the header `X-API-Key: your-secret-here`. The `/health` endpoint is always exempt.
When `EXCALIDRAW_API_KEY` is set, the browser canvas UI receives the key automatically: `GET /` injects `window.__EXCALIDRAW_API_KEY__` into the served HTML, so the browser's WebSocket `hello` message can include it without any manual configuration. The WebSocket handshake uses a challenge-response protocol: the server sends `{ type: "auth_required" }` immediately on connect, the client must respond with a `hello` message containing `{ apiKey: "<key>" }` within 5 seconds, or the connection is closed (code 4001).
**Restricting CORS origins** (e.g. if your canvas UI is on a custom domain):
```bash
ALLOWED_ORIGINS=https://canvas.example.com,http://localhost:3000 node dist/server.js
```
This controls both REST CORS responses and WebSocket `Origin` verification. Requests with no `Origin` header (MCP stdio, curl, server-side tools) are always allowed.
## Multi-Tenancy (Workspaces) ## Multi-Tenancy (Workspaces)
@@ -504,8 +529,8 @@ cp -R skills/excalidraw-skill ~/.codex/skills/excalidraw-skill
|---|---| |---|---|
| **Element CRUD** | `create_element`, `get_element`, `update_element`, `delete_element`, `query_elements`, `batch_create_elements`, `duplicate_elements` | | **Element CRUD** | `create_element`, `get_element`, `update_element`, `delete_element`, `query_elements`, `batch_create_elements`, `duplicate_elements` |
| **Layout** | `align_elements`, `distribute_elements`, `group_elements`, `ungroup_elements`, `lock_elements`, `unlock_elements` | | **Layout** | `align_elements`, `distribute_elements`, `group_elements`, `ungroup_elements`, `lock_elements`, `unlock_elements` |
| **Scene Awareness** | `describe_scene`, `get_canvas_screenshot` | | **Scene Awareness** | `describe_scene`, `get_canvas_screenshot` ⚠️ |
| **File I/O** | `export_scene`, `import_scene`, `export_to_image`, `export_to_excalidraw_url`, `create_from_mermaid` | | **File I/O** | `export_scene`, `import_scene`, `export_to_image` ⚠️, `export_to_excalidraw_url`, `create_from_mermaid` |
| **State Management** | `clear_canvas`, `snapshot_scene`, `restore_snapshot` | | **State Management** | `clear_canvas`, `snapshot_scene`, `restore_snapshot` |
| **Viewport** | `set_viewport` | | **Viewport** | `set_viewport` |
| **Design Guide** | `read_diagram_guide` | | **Design Guide** | `read_diagram_guide` |
@@ -516,6 +541,8 @@ cp -R skills/excalidraw-skill ~/.codex/skills/excalidraw-skill
Full schemas are discoverable via `tools/list` or in `skills/excalidraw-skill/references/cheatsheet.md`. Full schemas are discoverable via `tools/list` or in `skills/excalidraw-skill/references/cheatsheet.md`.
> ⚠️ **Requires open browser:** `get_canvas_screenshot` and `export_to_image` rely on the frontend rendering pipeline. The canvas UI must be open in a browser tab at `http://localhost:3000` for these tools to work. They return HTTP 503 if no browser is connected.
## Testing ## Testing
### Health check ### Health check
@@ -661,20 +688,37 @@ The canvas server exposes a REST API alongside the WebSocket interface:
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
| GET | `/health` | Health check | | GET | `/health` | Health check (auth-exempt) |
| GET | `/api/elements` | List all elements | | GET | `/api/elements` | List all elements |
| POST | `/api/elements` | Create an element | | POST | `/api/elements` | Create an element |
| GET | `/api/elements/search` | Search elements (`?q=term` for FTS, `?type=rectangle` for filter) |
| GET | `/api/elements/:id` | Get element by ID |
| PUT | `/api/elements/:id` | Update an element | | PUT | `/api/elements/:id` | Update an element |
| DELETE | `/api/elements/:id` | Delete an element | | DELETE | `/api/elements/:id` | Delete an element |
| DELETE | `/api/elements/clear` | Clear all elements | | DELETE | `/api/elements/clear` | Clear all elements (requires `?confirm=true`) |
| POST | `/api/elements/sync` | Sync all elements (bulk upsert) | | POST | `/api/elements/batch` | Batch create elements |
| POST | `/api/elements/from-mermaid` | Convert Mermaid diagram and broadcast to canvas |
| POST | `/api/elements/sync` | Bulk-replace all elements (canvas → server) |
| POST | `/api/elements/sync/v2` | Delta sync (changes since `lastSyncVersion`) |
| GET | `/api/sync/version` | Current sync version for a project |
| GET | `/api/sync/status` | Sync status (element count, memory usage) |
| GET | `/api/files` | List image files (in-memory) |
| POST | `/api/files` | Add image files |
| DELETE | `/api/files/:id` | Delete an image file |
| POST | `/api/export/image` | Request image export (requires open browser tab) |
| POST | `/api/export/image/result` | Deliver export result from frontend |
| POST | `/api/viewport` | Set canvas viewport (requires open browser tab) |
| POST | `/api/viewport/result` | Deliver viewport result from frontend |
| POST | `/api/snapshots` | Save a named snapshot |
| GET | `/api/snapshots` | List snapshots |
| GET | `/api/snapshots/:name` | Get snapshot by name |
| GET | `/api/tenants` | List all tenants | | GET | `/api/tenants` | List all tenants |
| GET | `/api/tenant/active` | Get the active tenant | | GET | `/api/tenant/active` | Get the active tenant |
| PUT | `/api/tenant/active` | Set the active tenant | | PUT | `/api/tenant/active` | Set the active tenant |
| GET | `/api/settings/:key` | Read a setting | | GET | `/api/settings/:key` | Read a setting |
| PUT | `/api/settings/:key` | Write a setting | | PUT | `/api/settings/:key` | Write a setting |
All endpoints accept an `X-Tenant-Id` header for per-request tenant scoping. All endpoints accept an `X-Tenant-Id` header for per-request tenant scoping. When `EXCALIDRAW_API_KEY` is set, all `/api/*` endpoints require `X-API-Key: <key>` (see [Security configuration](#security-configuration)).
## Credits ## Credits
+18
View File
@@ -22,8 +22,14 @@ services:
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT=3000 - PORT=3000
# HOST=0.0.0.0 is intentional in Docker — the container's port is
# exposed only via the published port mapping above. For local dev
# without Docker, the default is localhost (set in src/server.ts).
- HOST=0.0.0.0 - HOST=0.0.0.0
- DEBUG=false - DEBUG=false
# Optional: set to enable API key auth on all /api/* routes.
# Must match EXCALIDRAW_API_KEY in the mcp service below so inter-service calls succeed.
- EXCALIDRAW_API_KEY=${EXCALIDRAW_API_KEY:-}
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"] test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"]
@@ -31,6 +37,11 @@ services:
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 40s start_period: 40s
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
networks: networks:
- mcp-network - mcp-network
@@ -49,9 +60,16 @@ services:
- EXPRESS_SERVER_URL=http://canvas:3000 - EXPRESS_SERVER_URL=http://canvas:3000
- ENABLE_CANVAS_SYNC=true - ENABLE_CANVAS_SYNC=true
- DEBUG=false - DEBUG=false
# Must match canvas EXCALIDRAW_API_KEY so inter-service sync calls are authenticated.
- EXCALIDRAW_API_KEY=${EXCALIDRAW_API_KEY:-}
depends_on: depends_on:
canvas: canvas:
condition: service_healthy condition: service_healthy
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
networks: networks:
- mcp-network - mcp-network
profiles: profiles:
+101 -48
View File
@@ -53,11 +53,21 @@ interface TenantInfo {
workspace_path: string; workspace_path: string;
} }
declare global {
interface Window {
__EXCALIDRAW_API_KEY__?: string;
}
}
const WS_AUTH_CLOSE_CODE = 4001
const browserApiKey = typeof window !== 'undefined' ? window.__EXCALIDRAW_API_KEY__ : undefined
function App(): JSX.Element { function App(): JSX.Element {
const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawAPIRefValue | null>(null) const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawAPIRefValue | null>(null)
const excalidrawAPIRef = useRef<ExcalidrawAPIRefValue | null>(null) const excalidrawAPIRef = useRef<ExcalidrawAPIRefValue | null>(null)
const [isConnected, setIsConnected] = useState<boolean>(false) const [isConnected, setIsConnected] = useState<boolean>(false)
const websocketRef = useRef<WebSocket | null>(null) const websocketRef = useRef<WebSocket | null>(null)
const reconnectEnabledRef = useRef<boolean>(true)
// Sync state // Sync state
const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle') const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle')
@@ -101,6 +111,7 @@ function App(): JSX.Element {
} }
const tid = activeTenantIdRef.current const tid = activeTenantIdRef.current
if (tid) headers['X-Tenant-Id'] = tid if (tid) headers['X-Tenant-Id'] = tid
if (browserApiKey) headers['X-API-Key'] = browserApiKey
return headers return headers
} }
@@ -244,6 +255,9 @@ function App(): JSX.Element {
} }
const connectWebSocket = (): void => { const connectWebSocket = (): void => {
if (!reconnectEnabledRef.current) {
return
}
if (websocketRef.current && websocketRef.current.readyState === WebSocket.OPEN) { if (websocketRef.current && websocketRef.current.readyState === WebSocket.OPEN) {
return return
} }
@@ -274,7 +288,7 @@ function App(): JSX.Element {
setIsConnected(false) setIsConnected(false)
// Reconnect after 3 seconds if not a clean close // Reconnect after 3 seconds if not a clean close
if (event.code !== 1000) { if (event.code !== 1000 && event.code !== WS_AUTH_CLOSE_CODE && reconnectEnabledRef.current) {
setTimeout(connectWebSocket, 3000) setTimeout(connectWebSocket, 3000)
} }
} }
@@ -285,10 +299,13 @@ function App(): JSX.Element {
} }
} }
const sendHello = (tenantId: string): void => { const sendHello = (tenantId?: string): void => {
const ws = websocketRef.current const ws = websocketRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'hello', tenantId })) const message: Record<string, string> = { type: 'hello' }
if (tenantId) message.tenantId = tenantId
if (browserApiKey) message.apiKey = browserApiKey
ws.send(JSON.stringify(message))
} }
const sendAck = (msgId: string | undefined, status: 'applied' | 'partial' | 'failed', elementCount?: number, expectedCount?: number): void => { const sendAck = (msgId: string | undefined, status: 'applied' | 'partial' | 'failed', elementCount?: number, expectedCount?: number): void => {
@@ -358,6 +375,85 @@ function App(): JSX.Element {
} }
const handleWebSocketMessage = async (data: WebSocketMessage): Promise<void> => { const handleWebSocketMessage = async (data: WebSocketMessage): Promise<void> => {
switch (data.type) {
case 'auth_required':
sendHello(activeTenantIdRef.current ?? undefined)
return
case 'auth_failed':
reconnectEnabledRef.current = false
showToast('Authentication failed - check EXCALIDRAW_API_KEY', 4000)
if (websocketRef.current?.readyState === WebSocket.OPEN) {
websocketRef.current.close(WS_AUTH_CLOSE_CODE, 'Authentication failed')
}
return
case 'error':
if (typeof data.message === 'string' && data.message) {
showToast(data.message, 4000)
}
return
case 'tenant_switched': {
console.log('Tenant switched:', data.tenant)
if (!data.tenant) return
const incoming = data.tenant as TenantInfo
sendHello(incoming.id)
if (incoming.id !== activeTenantIdRef.current) {
activeTenantIdRef.current = incoming.id
setActiveTenant(incoming)
const api = excalidrawAPIRef.current
if (!api) return
api.updateScene({
elements: [],
captureUpdate: CaptureUpdateAction.NEVER
})
lastSyncedHashRef.current = ''
loadExistingElements()
} else {
setActiveTenant(incoming)
}
return
}
case 'hello_ack': {
console.log('Hello acknowledged by server:', data.tenantId, data.projectId)
if (data.tenant) {
const incoming = data.tenant as TenantInfo
activeTenantIdRef.current = incoming.id
setActiveTenant(incoming)
} else if (typeof data.tenantId === 'string') {
activeTenantIdRef.current = data.tenantId
}
const api = excalidrawAPIRef.current
if (!api) return
if (Array.isArray(data.elements) && data.elements.length > 0) {
const cleanedElements = data.elements.map(cleanElementForExcalidraw)
const validatedElements = validateAndFixBindings(cleanedElements)
const convertedElements = convertElementsPreservingImageProps(validatedElements)
api.updateScene({
elements: convertedElements,
captureUpdate: CaptureUpdateAction.NEVER
})
const helloBaseline = new Map<string, any>()
for (const el of data.elements) {
helloBaseline.set(el.id, el)
}
lastSyncedElementsRef.current = helloBaseline
} else if (Array.isArray(data.elements)) {
api.updateScene({
elements: [],
captureUpdate: CaptureUpdateAction.NEVER
})
lastSyncedElementsRef.current = new Map()
lastSyncedHashRef.current = ''
}
return
}
}
// Gap detection (Task 12): if a message carries sync_version, check for gaps // Gap detection (Task 12): if a message carries sync_version, check for gaps
if (data.sync_version !== undefined && typeof data.sync_version === 'number') { if (data.sync_version !== undefined && typeof data.sync_version === 'number') {
const expected = lastReceivedSyncVersionRef.current + 1 const expected = lastReceivedSyncVersionRef.current + 1
@@ -714,46 +810,6 @@ function App(): JSX.Element {
case 'file_deleted': case 'file_deleted':
break break
case 'tenant_switched':
console.log('Tenant switched:', data.tenant)
if (data.tenant) {
const incoming = data.tenant as TenantInfo
// Send hello to register WS connection under the correct tenant scope
sendHello(incoming.id)
if (incoming.id !== activeTenantIdRef.current) {
activeTenantIdRef.current = incoming.id
setActiveTenant(incoming)
api.updateScene({
elements: [],
captureUpdate: CaptureUpdateAction.NEVER
})
lastSyncedHashRef.current = ''
loadExistingElements()
} else {
setActiveTenant(incoming)
}
}
break
case 'hello_ack':
console.log('Hello acknowledged by server:', data.tenantId, data.projectId)
if (data.elements && Array.isArray(data.elements) && data.elements.length > 0) {
const converted = convertToExcalidrawElements(data.elements)
api.updateScene({
elements: converted,
captureUpdate: CaptureUpdateAction.NEVER
})
// Update sync baseline for deletion detection
const helloBaseline = new Map<string, any>()
for (const el of data.elements) {
helloBaseline.set(el.id, el)
}
lastSyncedElementsRef.current = helloBaseline
} else if (data.elements && data.elements.length === 0) {
lastSyncedElementsRef.current = new Map()
}
break
default: default:
console.log('Unknown WebSocket message type:', data.type) console.log('Unknown WebSocket message type:', data.type)
} }
@@ -875,10 +931,7 @@ function App(): JSX.Element {
// Load elements for the newly-active tenant // Load elements for the newly-active tenant
const elemRes = await fetch('/api/elements', { const elemRes = await fetch('/api/elements', {
headers: { headers: tenantHeaders({ 'X-Tenant-Id': tenantId })
'Content-Type': 'application/json',
'X-Tenant-Id': tenantId
}
}) })
const result: ApiResponse = await elemRes.json() const result: ApiResponse = await elemRes.json()
if (result.success && result.elements && result.elements.length > 0) { if (result.success && result.elements && result.elements.length > 0) {
@@ -996,7 +1049,7 @@ function App(): JSX.Element {
// Load "skip confirm" preference from backend on mount // Load "skip confirm" preference from backend on mount
useEffect(() => { useEffect(() => {
fetch('/api/settings/clear_canvas_skip_confirm') fetch('/api/settings/clear_canvas_skip_confirm', { headers: tenantHeaders() })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.value === 'true') setClearSkipConfirm(true) if (data.value === 'true') setClearSkipConfirm(true)
+383 -339
View File
File diff suppressed because it is too large Load Diff
+8 -6
View File
@@ -32,18 +32,20 @@
"dependencies": { "dependencies": {
"@excalidraw/excalidraw": "^0.18.0", "@excalidraw/excalidraw": "^0.18.0",
"@excalidraw/mermaid-to-excalidraw": "^1.1.3", "@excalidraw/mermaid-to-excalidraw": "^1.1.3",
"@modelcontextprotocol/sdk": "^1.26.0", "@modelcontextprotocol/sdk": "1.26.0",
"better-sqlite3": "^12.6.2", "better-sqlite3": "12.6.2",
"cors": "^2.8.5", "cors": "2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "4.22.1",
"express-rate-limit": "8.3.1",
"helmet": "8.1.0",
"mermaid": "^11.12.1", "mermaid": "^11.12.1",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"winston": "^3.11.0", "winston": "^3.11.0",
"ws": "^8.14.2", "ws": "8.20.0",
"zod": "^3.22.4", "zod": "3.25.5",
"zod-to-json-schema": "^3.22.3" "zod-to-json-schema": "^3.22.3"
}, },
"devDependencies": { "devDependencies": {
+6 -2
View File
@@ -9,7 +9,7 @@ export default defineConfig({
workers: 1, workers: 1,
reporter: 'list', reporter: 'list',
use: { use: {
baseURL: 'http://localhost:3100', baseURL: 'http://127.0.0.1:3100',
trace: 'on-first-retry', trace: 'on-first-retry',
}, },
projects: [ projects: [
@@ -25,8 +25,12 @@ export default defineConfig({
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
env: { env: {
CANVAS_PORT: '3100', CANVAS_PORT: '3100',
HOST: 'localhost', HOST: '127.0.0.1',
EXCALIDRAW_DB_PATH: '/tmp/excalidraw-e2e-test.db', EXCALIDRAW_DB_PATH: '/tmp/excalidraw-e2e-test.db',
ALLOWED_ORIGINS: 'http://127.0.0.1:3100,http://localhost:3100,http://localhost:3000,http://127.0.0.1:3000',
EXCALIDRAW_RATE_LIMIT_GENERAL_MAX: '10000',
EXCALIDRAW_RATE_LIMIT_DESTRUCTIVE_MAX: '10000',
EXCALIDRAW_RATE_LIMIT_WRITE_BURST_MAX: '10000',
}, },
}, },
}); });
+10
View File
@@ -512,6 +512,10 @@ export function getActiveTenant(): Tenant {
return db.prepare('SELECT * FROM tenants WHERE id = ?').get(activeTenantId) as Tenant; return db.prepare('SELECT * FROM tenants WHERE id = ?').get(activeTenantId) as Tenant;
} }
export function getTenantById(id: string): Tenant | undefined {
return db.prepare('SELECT * FROM tenants WHERE id = ?').get(id) as Tenant | undefined;
}
export function getActiveTenantId(): string { export function getActiveTenantId(): string {
return activeTenantId; return activeTenantId;
} }
@@ -535,6 +539,12 @@ export function listProjects(): Project[] {
return db.prepare('SELECT * FROM projects WHERE tenant_id = ? ORDER BY updated_at DESC').all(activeTenantId) as Project[]; return db.prepare('SELECT * FROM projects WHERE tenant_id = ? ORDER BY updated_at DESC').all(activeTenantId) as Project[];
} }
export function getProjectForTenant(projectId: string, tenantId: string): Project | undefined {
return db.prepare(
'SELECT * FROM projects WHERE id = ? AND tenant_id = ?'
).get(projectId, tenantId) as Project | undefined;
}
export function setActiveProject(id: string): void { export function setActiveProject(id: string): void {
const project = db.prepare('SELECT id, tenant_id FROM projects WHERE id = ?').get(id) as { id: string; tenant_id: string } | undefined; const project = db.prepare('SELECT id, tenant_id FROM projects WHERE id = ?').get(id) as { id: string; tenant_id: string } | undefined;
if (!project) throw new Error(`Project "${id}" not found`); if (!project) throw new Error(`Project "${id}" not found`);
+6 -1
View File
@@ -137,11 +137,16 @@ interface SyncResponse {
} }
function canvasHeaders(extra?: Record<string, string>): Record<string, string> { function canvasHeaders(extra?: Record<string, string>): Record<string, string> {
return { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Tenant-Id': dbGetActiveTenantId(), 'X-Tenant-Id': dbGetActiveTenantId(),
...extra ...extra
}; };
// Forward API key to canvas when auth is enabled — required for two-service
// Docker deployments where canvas runs with EXCALIDRAW_API_KEY set.
const apiKey = process.env.EXCALIDRAW_API_KEY;
if (apiKey) headers['X-API-Key'] = apiKey;
return headers;
} }
// Helper functions to sync with Express server (canvas) // Helper functions to sync with Express server (canvas)
+213
View File
@@ -0,0 +1,213 @@
/**
* Security middleware for mcp-excalidraw-local.
*
* All env vars are read at request/connection time (not at module init)
* so that tests can mutate process.env between cases.
*/
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import helmet from 'helmet';
import { timingSafeEqual } from 'crypto';
import { Request, Response, NextFunction } from 'express';
import { IncomingMessage } from 'http';
// ── Helpers ──────────────────────────────────────────────────────────────────
function getAllowedOrigins(): string[] {
if (process.env.ALLOWED_ORIGINS) {
return process.env.ALLOWED_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean);
}
return ['http://localhost:3000', 'http://127.0.0.1:3000'];
}
function getEnvInt(name: string, fallback: number): number {
const value = process.env[name];
if (!value) return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export function isAuthEnabled(): boolean {
return !!process.env.EXCALIDRAW_API_KEY;
}
export function validateApiKey(provided: string | string[] | undefined): boolean {
const required = process.env.EXCALIDRAW_API_KEY;
if (!required) return true;
if (typeof provided !== 'string') return false;
// Use timing-safe comparison to prevent timing-based key enumeration.
const a = Buffer.from(provided);
const b = Buffer.from(required);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
// ── Security Headers (helmet) ─────────────────────────────────────────────────
// Sets X-Content-Type-Options, X-Frame-Options, X-DNS-Prefetch-Control, etc.
// Disables X-Powered-By to avoid fingerprinting.
// CSP is left permissive here (Excalidraw needs inline scripts/styles for React).
export const helmetMiddleware = helmet({
contentSecurityPolicy: false, // Excalidraw's React bundle needs inline evaluation
crossOriginEmbedderPolicy: false, // Allow embedding Excalidraw assets
});
// ── CORS ─────────────────────────────────────────────────────────────────────
// Restrict to an explicit allowlist. `cors()` with no config defaults to
// wildcard (*) which lets any website make cross-origin calls to the canvas
// server — a security risk for local use.
export const corsMiddleware = cors({
origin(origin, callback) {
// No Origin header = curl / MCP stdio / same-origin request — always allow.
if (!origin) return callback(null, true);
if (getAllowedOrigins().includes(origin)) return callback(null, origin);
// Deny: return false so cors does not set ACAO header.
// The browser will block the response; the server stays available.
return callback(null, false);
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'X-Tenant-Id', 'X-API-Key'],
});
// ── API Key Auth ──────────────────────────────────────────────────────────────
// When EXCALIDRAW_API_KEY is not set, auth is disabled (dev / backward-compat mode).
// Set the env var to protect all /api/* routes.
// /health is exempt so monitoring tools work without credentials.
export function apiKeyAuth(req: Request, res: Response, next: NextFunction): void {
// Auth disabled — pass through.
if (!isAuthEnabled()) return next();
const provided = req.headers['x-api-key'];
if (!validateApiKey(provided)) {
res.status(401).json({ success: false, error: 'Unauthorized' });
return;
}
next();
}
// ── Prototype Pollution Guard ─────────────────────────────────────────────────
// Strip (and reject) dangerous prototype-chain keys from req.body before any
// route handler sees the data. These keys are safe in JSON.parse on modern V8
// but can cause issues downstream with Object.assign / spread patterns.
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
function hasDangerousKey(obj: unknown, depth = 0): boolean {
if (depth > 10 || obj === null || typeof obj !== 'object') return false;
for (const key of Object.keys(obj as object)) {
if (DANGEROUS_KEYS.has(key)) return true;
if (hasDangerousKey((obj as Record<string, unknown>)[key], depth + 1)) return true;
}
return false;
}
export function sanitizeBody(req: Request, res: Response, next: NextFunction): void {
if (req.body && typeof req.body === 'object' && hasDangerousKey(req.body)) {
res.status(400).json({ success: false, error: 'Request body contains disallowed keys.' });
return;
}
next();
}
// ── Mermaid Input Validation ──────────────────────────────────────────────────
const MAX_MERMAID_LENGTH = 50 * 1024; // 50 KB
const MAX_MERMAID_CONFIG_KEYS = 10;
export function validateMermaidInput(req: Request, res: Response, next: NextFunction): void {
const { mermaidDiagram, config } = req.body ?? {};
if (typeof mermaidDiagram === 'string' && mermaidDiagram.length > MAX_MERMAID_LENGTH) {
res.status(400).json({ success: false, error: 'Mermaid diagram exceeds maximum allowed size (50 KB).' });
return;
}
if (config !== undefined && config !== null && typeof config === 'object' && !Array.isArray(config)) {
if (Object.keys(config as object).length > MAX_MERMAID_CONFIG_KEYS) {
res.status(400).json({ success: false, error: `Mermaid config must not exceed ${MAX_MERMAID_CONFIG_KEYS} keys.` });
return;
}
}
next();
}
// ── Rate Limiting ─────────────────────────────────────────────────────────────
// General limit for all /api routes.
export const generalRateLimit = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: getEnvInt('EXCALIDRAW_RATE_LIMIT_GENERAL_MAX', 100),
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { success: false, error: 'Too many requests, please try again later.' },
});
// Stricter limit for destructive clear operations.
export const destructiveRateLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: getEnvInt('EXCALIDRAW_RATE_LIMIT_DESTRUCTIVE_MAX', 10),
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { success: false, error: 'Too many destructive operations, please slow down.' },
});
// Stricter limit for write-heavy sync operations.
export const writeBurstLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: getEnvInt('EXCALIDRAW_RATE_LIMIT_WRITE_BURST_MAX', 10),
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { success: false, error: 'Too many sync operations, please slow down.' },
});
// ── Confirmation Guard ────────────────────────────────────────────────────────
// Requires ?confirm=true on destructive REST endpoints.
// Prevents accidental or CSRF-triggered data loss.
export function requireConfirm(req: Request, res: Response, next: NextFunction): void {
if (req.query['confirm'] !== 'true') {
res.status(400).json({
success: false,
error: 'Add ?confirm=true to confirm this destructive operation.',
});
return;
}
next();
}
// ── WebSocket Origin Check ────────────────────────────────────────────────────
// Passed to WebSocketServer({ verifyClient }) at server init.
// Reads allowed origins dynamically so env changes take effect without restart.
export function verifyWsClient(info: { req: IncomingMessage }): boolean {
const origin = info.req.headers.origin;
// No origin = non-browser client (MCP tool, curl) — allow.
if (!origin) return true;
return getAllowedOrigins().includes(origin);
}
export class InvalidSearchQueryError extends Error {
constructor() {
super('Invalid search query');
this.name = 'InvalidSearchQueryError';
}
}
export function sanitizeSearchQuery(query: string): string {
const trimmed = query.trim();
if (!trimmed) return trimmed;
// Keep search syntax simple and predictable by rejecting FTS operators
// and quoting constructs that otherwise bubble SQLite parse errors.
if (trimmed.includes('"')) {
throw new InvalidSearchQueryError();
}
if (/\b(?:AND|OR|NOT|NEAR(?:\/\d+)?)\b/i.test(trimmed)) {
throw new InvalidSearchQueryError();
}
if (/[*(){}^]/.test(trimmed)) {
throw new InvalidSearchQueryError();
}
return trimmed;
}
+238 -121
View File
@@ -1,6 +1,6 @@
import express, { type Application, Request, Response, NextFunction } from 'express'; import express, { type Application, Request, Response, NextFunction } from 'express';
import cors from 'cors';
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
import { helmetMiddleware, corsMiddleware, apiKeyAuth, verifyWsClient, generalRateLimit, destructiveRateLimit, writeBurstLimit, requireConfirm, sanitizeBody, validateMermaidInput, isAuthEnabled, validateApiKey, sanitizeSearchQuery, InvalidSearchQueryError } from './security.js';
import { createServer } from 'http'; import { createServer } from 'http';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
@@ -19,6 +19,7 @@ import {
BatchCreatedMessage, BatchCreatedMessage,
SyncStatusMessage, SyncStatusMessage,
InitialElementsMessage, InitialElementsMessage,
HelloMessage,
Snapshot, Snapshot,
normalizeFontFamily, normalizeFontFamily,
ExcalidrawFile, ExcalidrawFile,
@@ -27,7 +28,7 @@ import {
BroadcastResult BroadcastResult
} from './types.js'; } from './types.js';
import * as store from './db.js'; import * as store from './db.js';
import { initDb, listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant, getCurrentSyncVersion, getChangesSince } from './db.js'; import { initDb, listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, getTenantById, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant, getProjectForTenant, getCurrentSyncVersion, getChangesSince } from './db.js';
import { z } from 'zod'; import { z } from 'zod';
import WebSocket from 'ws'; import WebSocket from 'ws';
@@ -39,17 +40,25 @@ const __dirname = path.dirname(__filename);
const app: Application = express(); const app: Application = express();
const httpServer = createServer(app); const httpServer = createServer(app);
const wss = new WebSocketServer({ server: httpServer }); const wss = new WebSocketServer({ server: httpServer, verifyClient: verifyWsClient });
// Middleware // Middleware
app.use(cors()); app.use(helmetMiddleware);
app.use(express.json({ limit: '10mb' })); app.use(corsMiddleware);
app.use('/api', generalRateLimit);
app.use('/api', apiKeyAuth);
// Body parsing — path-specific limits applied in order (most-specific first).
// batch/sync endpoints get 5 MB; everything else gets 100 KB.
app.use('/api/elements/batch', express.json({ limit: '5mb' }));
app.use('/api/elements/sync', express.json({ limit: '5mb' }));
app.use(express.json({ limit: '100kb' }));
app.use('/api', sanitizeBody);
// Serve static files from the build directory // Serve static files from the build directory
const staticDir = path.join(__dirname, '../dist'); const staticDir = path.join(__dirname, '../dist');
app.use(express.static(staticDir)); app.use(express.static(staticDir, { index: false }));
// Also serve frontend assets // Also serve frontend assets
app.use(express.static(path.join(__dirname, '../dist/frontend'))); app.use(express.static(path.join(__dirname, '../dist/frontend'), { index: false }));
// Resolve tenant from X-Tenant-Id header to a projectId override. // Resolve tenant from X-Tenant-Id header to a projectId override.
// Returns undefined when header is absent (browser requests), falling back to global state. // Returns undefined when header is absent (browser requests), falling back to global state.
@@ -73,6 +82,113 @@ function resolveScope(req: Request): { tenantId: string; projectId: string } {
return { tenantId: tenant.id, projectId }; return { tenantId: tenant.id, projectId };
} }
const WS_AUTH_CLOSE_CODE = 4001;
const WS_AUTH_TIMEOUT_MS = 5000;
const frontendHtmlPath = path.join(__dirname, '../dist/frontend/index.html');
function identifyConnection(ws: WebSocket, tenantId: string, projectId: string): void {
if (wsToConnection.has(ws)) {
moveConnection(ws, tenantId, projectId);
return;
}
registerConnection({
ws,
tenantId,
projectId,
connectedAt: Date.now(),
identified: true,
});
}
function getAllFilesObject(): Record<string, ExcalidrawFile> {
const result: Record<string, ExcalidrawFile> = {};
for (const [id, file] of files) {
result[id] = file;
}
return result;
}
function sendFilesAdded(ws: WebSocket): void {
if (files.size === 0) return;
ws.send(JSON.stringify({ type: 'files_added', files: getAllFilesObject() }));
}
function sendSyncStatus(ws: WebSocket, projectId: string): void {
const syncMessage: SyncStatusMessage = {
type: 'sync_status',
elementCount: store.getElementCount(projectId),
timestamp: new Date().toISOString()
};
ws.send(JSON.stringify(syncMessage));
}
function sendAuthlessInitialMessages(
ws: WebSocket,
tenant: { id: string; name: string; workspace_path: string },
projectId: string
): void {
ws.send(JSON.stringify({
type: 'tenant_switched',
tenant: { id: tenant.id, name: tenant.name, workspace_path: tenant.workspace_path }
}));
const initialMessage: InitialElementsMessage = {
type: 'initial_elements',
elements: store.getAllElements(projectId)
};
ws.send(JSON.stringify(initialMessage));
sendFilesAdded(ws);
sendSyncStatus(ws, projectId);
}
function sendHelloAck(
ws: WebSocket,
tenant: { id: string; name: string; workspace_path: string },
projectId: string
): void {
ws.send(JSON.stringify({
type: 'hello_ack',
tenant: { id: tenant.id, name: tenant.name, workspace_path: tenant.workspace_path },
tenantId: tenant.id,
projectId,
elements: store.getAllElements(projectId)
}));
}
function resolveHelloTenantAndProject(msg: HelloMessage):
| { tenant: { id: string; name: string; workspace_path: string }; projectId: string }
| { error: string } {
let tenant = dbGetActiveTenant();
if (typeof msg.tenantId === 'string' && msg.tenantId.trim()) {
const requestedTenant = getTenantById(msg.tenantId.trim());
if (!requestedTenant) {
return { error: 'Unknown tenant' };
}
tenant = requestedTenant;
}
let projectId = getDefaultProjectForTenant(tenant.id);
if (typeof msg.projectId === 'string' && msg.projectId.trim()) {
const requestedProject = getProjectForTenant(msg.projectId.trim(), tenant.id);
if (requestedProject) {
projectId = requestedProject.id;
}
}
return { tenant, projectId };
}
function injectApiKeyIntoHtml(html: string): string {
const apiKey = process.env.EXCALIDRAW_API_KEY;
if (!apiKey) return html;
const serialized = JSON.stringify(apiKey).replace(/</g, '\\u003c');
const script = `<script>window.__EXCALIDRAW_API_KEY__=${serialized};</script>`;
return html.includes('</head>') ? html.replace('</head>', `${script}</head>`) : `${script}${html}`;
}
// ── Connection Registry (Task 3) ────────────────────────────────────────── // ── Connection Registry (Task 3) ──────────────────────────────────────────
// Scoped by tenant → project → Set<ClientConnection> // Scoped by tenant → project → Set<ClientConnection>
const connections = new Map<string, Map<string, Set<ClientConnection>>>(); const connections = new Map<string, Map<string, Set<ClientConnection>>>();
@@ -248,72 +364,73 @@ function broadcast(message: WebSocketMessage): void {
// ── WebSocket Connection Handling (Task 4: Hello Handshake) ─────────────── // ── WebSocket Connection Handling (Task 4: Hello Handshake) ───────────────
wss.on('connection', (ws: WebSocket) => { wss.on('connection', (ws: WebSocket) => {
// Register with fallback scope until hello handshake identifies the client. const authEnabled = isAuthEnabled();
const tenant = (() => { try { return dbGetActiveTenant(); } catch { return { id: 'default', name: 'default', workspace_path: '' }; } })(); let awaitingAuth = authEnabled;
let authTimer: ReturnType<typeof setTimeout> | null = null;
const tenant = (() => {
try {
return dbGetActiveTenant();
} catch {
return { id: 'default', name: 'default', workspace_path: '' };
}
})();
const fallbackProjectId = getDefaultProjectForTenant(tenant.id) ?? 'default'; const fallbackProjectId = getDefaultProjectForTenant(tenant.id) ?? 'default';
const conn: ClientConnection = {
logger.info(`New WebSocket connection established${authEnabled ? ' (awaiting auth)' : ' (legacy mode)'}`);
if (authEnabled) {
ws.send(JSON.stringify({ type: 'auth_required' }));
authTimer = setTimeout(() => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: 'auth_failed', reason: 'timeout' }));
ws.close(WS_AUTH_CLOSE_CODE, 'Authentication required');
}, WS_AUTH_TIMEOUT_MS);
} else {
registerConnection({
ws, ws,
tenantId: tenant.id, tenantId: tenant.id,
projectId: fallbackProjectId, projectId: fallbackProjectId,
connectedAt: Date.now(), connectedAt: Date.now(),
identified: false identified: false
}; });
registerConnection(conn); sendAuthlessInitialMessages(ws, tenant, fallbackProjectId);
logger.info('New WebSocket connection established (awaiting hello)');
// Send tenant info so the FE knows where to send hello
ws.send(JSON.stringify({
type: 'tenant_switched',
tenant: { id: tenant.id, name: tenant.name, workspace_path: tenant.workspace_path }
}));
// For backward compatibility: also send initial_elements immediately.
// New FE versions will ignore this and use hello_ack instead.
const initialMessage: InitialElementsMessage = {
type: 'initial_elements',
elements: store.getAllElements(fallbackProjectId)
};
ws.send(JSON.stringify(initialMessage));
// Send any stored files (image data)
if (files.size > 0) {
const allFiles: Record<string, ExcalidrawFile> = {};
for (const [id, file] of files) {
allFiles[id] = file;
} }
ws.send(JSON.stringify({ type: 'files_added', files: allFiles }));
}
// Send sync status to new client
const syncMessage: SyncStatusMessage = {
type: 'sync_status',
elementCount: store.getElementCount(fallbackProjectId),
timestamp: new Date().toISOString()
};
ws.send(JSON.stringify(syncMessage));
// Handle incoming messages from this client // Handle incoming messages from this client
ws.on('message', (raw) => { ws.on('message', (raw) => {
try { try {
const msg = JSON.parse(raw.toString()); const msg = JSON.parse(raw.toString());
if (msg.type === 'hello') { if (msg.type === 'hello') {
const helloTenantId = msg.tenantId as string; if (awaitingAuth) {
const helloProjectId = (msg.projectId as string) || getDefaultProjectForTenant(msg.tenantId) || `${msg.tenantId}-default`; if (!validateApiKey(msg.apiKey)) {
if (helloTenantId) { ws.send(JSON.stringify({ type: 'auth_failed', reason: 'invalid_key' }));
// Move connection to the correct scope ws.close(WS_AUTH_CLOSE_CODE, 'Invalid API key');
moveConnection(ws, helloTenantId, helloProjectId); return;
logger.info(`Client identified: tenant=${helloTenantId} project=${helloProjectId}`); }
awaitingAuth = false;
if (authTimer) {
clearTimeout(authTimer);
authTimer = null;
}
}
// Respond with scoped elements const resolved = resolveHelloTenantAndProject(msg);
const elements = store.getAllElements(helloProjectId); if ('error' in resolved) {
ws.send(JSON.stringify({ ws.send(JSON.stringify({ type: 'error', message: resolved.error }));
type: 'hello_ack', return;
tenantId: helloTenantId,
projectId: helloProjectId,
elements
}));
} }
identifyConnection(ws, resolved.tenant.id, resolved.projectId);
logger.info(`Client identified: tenant=${resolved.tenant.id} project=${resolved.projectId}`);
sendHelloAck(ws, resolved.tenant, resolved.projectId);
if (authEnabled) {
sendFilesAdded(ws);
sendSyncStatus(ws, resolved.projectId);
} }
return;
}
if (awaitingAuth) return;
if (msg.type === 'ack' && msg.msgId) { if (msg.type === 'ack' && msg.msgId) {
resolveAck(msg.msgId, { resolveAck(msg.msgId, {
status: msg.status ?? 'applied', status: msg.status ?? 'applied',
@@ -327,22 +444,23 @@ wss.on('connection', (ws: WebSocket) => {
}); });
ws.on('close', () => { ws.on('close', () => {
if (authTimer) clearTimeout(authTimer);
unregisterConnection(ws); unregisterConnection(ws);
logger.info('WebSocket connection closed'); logger.info('WebSocket connection closed');
}); });
ws.on('error', (error) => { ws.on('error', (error) => {
if (authTimer) clearTimeout(authTimer);
logger.error('WebSocket error:', error); logger.error('WebSocket error:', error);
unregisterConnection(ws); unregisterConnection(ws);
}); });
}); });
// Schema validation // Module-level constants
const CreateElementSchema = z.object({ const VALID_ELEMENT_TYPES = new Set(Object.values(EXCALIDRAW_ELEMENT_TYPES));
id: z.string().optional(),
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]), // Schema validation — shared fields extracted to avoid duplication
x: z.number(), const ElementSharedFieldsSchema = z.object({
y: z.number(),
width: z.number().optional(), width: z.number().optional(),
height: z.number().optional(), height: z.number().optional(),
backgroundColor: z.string().optional(), backgroundColor: z.string().optional(),
@@ -353,9 +471,7 @@ const CreateElementSchema = z.object({
opacity: z.number().optional(), opacity: z.number().optional(),
text: z.string().optional(), text: z.string().optional(),
originalText: z.string().optional(), originalText: z.string().optional(),
label: z.object({ label: z.object({ text: z.string() }).optional(),
text: z.string()
}).optional(),
fontSize: z.number().optional(), fontSize: z.number().optional(),
fontFamily: z.union([z.string(), z.number()]).optional(), fontFamily: z.union([z.string(), z.number()]).optional(),
groupIds: z.array(z.string()).optional(), groupIds: z.array(z.string()).optional(),
@@ -363,7 +479,6 @@ const CreateElementSchema = z.object({
roundness: z.object({ type: z.number(), value: z.number().optional() }).nullable().optional(), roundness: z.object({ type: z.number(), value: z.number().optional() }).nullable().optional(),
fillStyle: z.string().optional(), fillStyle: z.string().optional(),
// Arrow-specific properties // Arrow-specific properties
points: z.any().optional(),
start: z.object({ id: z.string() }).optional(), start: z.object({ id: z.string() }).optional(),
end: z.object({ id: z.string() }).optional(), end: z.object({ id: z.string() }).optional(),
startArrowhead: z.string().nullable().optional(), startArrowhead: z.string().nullable().optional(),
@@ -378,45 +493,23 @@ const CreateElementSchema = z.object({
scale: z.tuple([z.number(), z.number()]).optional(), scale: z.tuple([z.number(), z.number()]).optional(),
}); });
const UpdateElementSchema = z.object({ const CreateElementSchema = ElementSharedFieldsSchema.extend({
id: z.string().optional(),
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]),
x: z.number(),
y: z.number(),
points: z.any().optional(),
});
const UpdateElementSchema = ElementSharedFieldsSchema.extend({
id: z.string(), id: z.string(),
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]).optional(), type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]).optional(),
x: z.number().optional(), x: z.number().optional(),
y: z.number().optional(), y: z.number().optional(),
width: z.number().optional(),
height: z.number().optional(),
backgroundColor: z.string().optional(),
strokeColor: z.string().optional(),
strokeWidth: z.number().optional(),
strokeStyle: z.string().optional(),
roughness: z.number().optional(),
opacity: z.number().optional(),
text: z.string().optional(),
originalText: z.string().optional(),
label: z.object({
text: z.string()
}).optional(),
fontSize: z.number().optional(),
fontFamily: z.union([z.string(), z.number()]).optional(),
groupIds: z.array(z.string()).optional(),
locked: z.boolean().optional(),
roundness: z.object({ type: z.number(), value: z.number().optional() }).nullable().optional(),
fillStyle: z.string().optional(),
points: z.array(z.union([ points: z.array(z.union([
z.tuple([z.number(), z.number()]), z.tuple([z.number(), z.number()]),
z.object({ x: z.number(), y: z.number() }) z.object({ x: z.number(), y: z.number() })
])).optional(), ])).optional(),
start: z.object({ id: z.string() }).optional(),
end: z.object({ id: z.string() }).optional(),
startArrowhead: z.string().nullable().optional(),
endArrowhead: z.string().nullable().optional(),
startBinding: z.any().nullable().optional(),
endBinding: z.any().nullable().optional(),
boundElements: z.any().nullable().optional(),
elbowed: z.boolean().optional(),
fileId: z.string().optional(),
status: z.string().optional(),
scale: z.tuple([z.number(), z.number()]).optional(),
}); });
// API Routes // API Routes
@@ -465,7 +558,7 @@ app.post('/api/elements', async (req: Request, res: Response) => {
type: 'element_created', type: 'element_created',
element: element element: element
}; };
(message as any).sync_version = sv; message['sync_version'] = sv;
const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message); const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message);
res.json({ res.json({
@@ -526,7 +619,7 @@ app.put('/api/elements/:id', async (req: Request, res: Response) => {
type: 'element_updated', type: 'element_updated',
element: updatedElement element: updatedElement
}; };
(message as any).sync_version = sv; message['sync_version'] = sv;
const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message); const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message);
res.json({ res.json({
@@ -550,7 +643,7 @@ app.put('/api/elements/:id', async (req: Request, res: Response) => {
}); });
// Clear all elements (must be before /:id route) // Clear all elements (must be before /:id route)
app.delete('/api/elements/clear', (req: Request, res: Response) => { app.delete('/api/elements/clear', destructiveRateLimit, requireConfirm, (req: Request, res: Response) => {
try { try {
const projId = resolveTenantProject(req); const projId = resolveTenantProject(req);
const count = store.clearElements(projId); const count = store.clearElements(projId);
@@ -626,9 +719,12 @@ app.get('/api/elements/search', (req: Request, res: Response) => {
const { type, q, ...filters } = req.query; const { type, q, ...filters } = req.query;
if (q && typeof q === 'string') { if (q && typeof q === 'string') {
const results = store.searchElements(q, projId); const sanitizedQuery = sanitizeSearchQuery(q);
if (sanitizedQuery) {
const results = store.searchElements(sanitizedQuery, projId);
return res.json({ success: true, elements: results, count: results.length }); return res.json({ success: true, elements: results, count: results.length });
} }
}
const results = store.queryElements( const results = store.queryElements(
type && typeof type === 'string' ? type : undefined, type && typeof type === 'string' ? type : undefined,
@@ -643,9 +739,15 @@ app.get('/api/elements/search', (req: Request, res: Response) => {
}); });
} catch (error) { } catch (error) {
logger.error('Error querying elements:', error); logger.error('Error querying elements:', error);
if (error instanceof InvalidSearchQueryError) {
return res.status(400).json({
success: false,
error: 'Invalid search query'
});
}
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: (error as Error).message error: 'Search failed'
}); });
} }
}); });
@@ -881,7 +983,7 @@ app.post('/api/elements/batch', async (req: Request, res: Response) => {
}); });
// Convert Mermaid diagram to Excalidraw elements // Convert Mermaid diagram to Excalidraw elements
app.post('/api/elements/from-mermaid', (req: Request, res: Response) => { app.post('/api/elements/from-mermaid', validateMermaidInput, (req: Request, res: Response) => {
try { try {
const { mermaidDiagram, config } = req.body; const { mermaidDiagram, config } = req.body;
@@ -923,16 +1025,11 @@ app.post('/api/elements/from-mermaid', (req: Request, res: Response) => {
}); });
// Sync elements from frontend (overwrite sync) // Sync elements from frontend (overwrite sync)
app.post('/api/elements/sync', (req: Request, res: Response) => { app.post('/api/elements/sync', writeBurstLimit, (req: Request, res: Response) => {
try { try {
const projId = resolveTenantProject(req); const projId = resolveTenantProject(req);
const { elements: frontendElements, timestamp } = req.body; const { elements: frontendElements, timestamp } = req.body;
logger.info(`Sync request received: ${frontendElements.length} elements`, {
timestamp,
elementCount: frontendElements.length
});
if (!Array.isArray(frontendElements)) { if (!Array.isArray(frontendElements)) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
@@ -940,6 +1037,11 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
}); });
} }
logger.info(`Sync request received: ${frontendElements.length} elements`, {
timestamp,
elementCount: frontendElements.length
});
const beforeCount = store.getElementCount(projId); const beforeCount = store.getElementCount(projId);
// Process elements with server metadata // Process elements with server metadata
@@ -996,7 +1098,7 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
// ── Delta Sync v2 (Task 10) ── // ── Delta Sync v2 (Task 10) ──
app.post('/api/elements/sync/v2', (req: Request, res: Response) => { app.post('/api/elements/sync/v2', writeBurstLimit, (req: Request, res: Response) => {
try { try {
const projId = resolveTenantProject(req); const projId = resolveTenantProject(req);
const { lastSyncVersion = 0, changes = [] } = req.body; const { lastSyncVersion = 0, changes = [] } = req.body;
@@ -1008,6 +1110,20 @@ app.post('/api/elements/sync/v2', (req: Request, res: Response) => {
const scope = resolveScope(req); const scope = resolveScope(req);
const feChangeIds = new Set<string>(); const feChangeIds = new Set<string>();
// Validate all upsert elements before applying any changes
for (const change of changes) {
const { id, action, element } = change;
if (!id || !action) continue;
if (action === 'upsert' && element && element.type !== undefined) {
if (!VALID_ELEMENT_TYPES.has(element.type)) {
return res.status(400).json({
success: false,
error: `Invalid element type: ${element.type}`
});
}
}
}
// Apply FE changes to DB // Apply FE changes to DB
let appliedCount = 0; let appliedCount = 0;
for (const change of changes) { for (const change of changes) {
@@ -1069,11 +1185,7 @@ app.get('/api/sync/version', (req: Request, res: Response) => {
// Get all files // Get all files
app.get('/api/files', (_req: Request, res: Response) => { app.get('/api/files', (_req: Request, res: Response) => {
try { try {
const allFiles: Record<string, ExcalidrawFile> = {}; res.json({ success: true, files: getAllFilesObject() });
for (const [id, file] of files) {
allFiles[id] = file;
}
res.json({ success: true, files: allFiles });
} catch (error) { } catch (error) {
logger.error('Error fetching files:', error); logger.error('Error fetching files:', error);
res.status(500).json({ success: false, error: (error as Error).message }); res.status(500).json({ success: false, error: (error as Error).message });
@@ -1415,12 +1527,13 @@ app.get('/api/snapshots/:name', (req: Request, res: Response) => {
// Serve the frontend // Serve the frontend
app.get('/', (req: Request, res: Response) => { app.get('/', (req: Request, res: Response) => {
const htmlFile = path.join(__dirname, '../dist/frontend/index.html'); fs.readFile(frontendHtmlPath, 'utf8', (err, html) => {
res.sendFile(htmlFile, (err) => {
if (err) { if (err) {
logger.error('Error serving frontend:', err); logger.error('Error serving frontend:', err);
res.status(404).send('Frontend not found. Please run "npm run build" first.'); res.status(404).send('Frontend not found. Please run "npm run build" first.');
return;
} }
res.type('html').send(injectApiKeyIntoHtml(html));
}); });
}); });
@@ -1524,11 +1637,15 @@ app.get('/api/sync/status', (req: Request, res: Response) => {
}); });
// Error handling middleware // Error handling middleware
app.use((err: Error, req: Request, res: Response, next: NextFunction) => { app.use((err: any, req: Request, res: Response, next: NextFunction) => {
// Propagate HTTP status from framework errors (e.g. 413 from express.json, 429 from rate-limit).
const status: number = typeof err.status === 'number' ? err.status
: typeof err.statusCode === 'number' ? err.statusCode
: 500;
logger.error('Unhandled error:', err); logger.error('Unhandled error:', err);
res.status(500).json({ res.status(status).json({
success: false, success: false,
error: 'Internal server error' error: status === 500 ? 'Internal server error' : (err.message ?? 'Error')
}); });
}); });
+12 -3
View File
@@ -196,7 +196,10 @@ export type WebSocketMessageType =
| 'file_deleted' | 'file_deleted'
| 'hello' | 'hello'
| 'hello_ack' | 'hello_ack'
| 'ack'; | 'ack'
| 'auth_required'
| 'auth_failed'
| 'error';
// Connection registry types // Connection registry types
export interface ClientConnection { export interface ClientConnection {
@@ -215,8 +218,9 @@ export interface BroadcastResult {
export interface HelloMessage extends WebSocketMessage { export interface HelloMessage extends WebSocketMessage {
type: 'hello'; type: 'hello';
tenantId: string; tenantId?: string;
projectId: string; projectId?: string;
apiKey?: string;
} }
export interface HelloAckMessage extends WebSocketMessage { export interface HelloAckMessage extends WebSocketMessage {
@@ -224,6 +228,11 @@ export interface HelloAckMessage extends WebSocketMessage {
tenantId: string; tenantId: string;
projectId: string; projectId: string;
elements: ServerElement[]; elements: ServerElement[];
tenant?: {
id: string;
name: string;
workspace_path: string;
};
} }
export interface AckMessage extends WebSocketMessage { export interface AckMessage extends WebSocketMessage {
+2 -2
View File
@@ -173,7 +173,7 @@ describe('DELETE /api/elements/clear', () => {
setElement('a', makeElement({ id: 'a' })); setElement('a', makeElement({ id: 'a' }));
setElement('b', makeElement({ id: 'b' })); setElement('b', makeElement({ id: 'b' }));
const res = await request(app).delete('/api/elements/clear'); const res = await request(app).delete('/api/elements/clear?confirm=true');
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.count).toBe(2); expect(res.body.count).toBe(2);
@@ -182,7 +182,7 @@ describe('DELETE /api/elements/clear', () => {
}); });
it('returns 0 count when already empty', async () => { it('returns 0 count when already empty', async () => {
const res = await request(app).delete('/api/elements/clear'); const res = await request(app).delete('/api/elements/clear?confirm=true');
expect(res.body.count).toBe(0); expect(res.body.count).toBe(0);
}); });
}); });
+5 -5
View File
@@ -46,7 +46,7 @@ describe('Clear canvas confirmation flow', () => {
setElement('cl-2', makeElement({ id: 'cl-2' })); setElement('cl-2', makeElement({ id: 'cl-2' }));
expect(getAllElements()).toHaveLength(2); expect(getAllElements()).toHaveLength(2);
const res = await request(app).delete('/api/elements/clear'); const res = await request(app).delete('/api/elements/clear?confirm=true');
expect(res.body.success).toBe(true); expect(res.body.success).toBe(true);
expect(res.body.count).toBeDefined(); expect(res.body.count).toBeDefined();
@@ -54,13 +54,13 @@ describe('Clear canvas confirmation flow', () => {
}); });
it('clear on empty canvas returns zero count', async () => { it('clear on empty canvas returns zero count', async () => {
const res = await request(app).delete('/api/elements/clear'); const res = await request(app).delete('/api/elements/clear?confirm=true');
expect(res.body.success).toBe(true); expect(res.body.success).toBe(true);
}); });
it('cleared elements stay gone on subsequent GET requests', async () => { it('cleared elements stay gone on subsequent GET requests', async () => {
setElement('stay-gone', makeElement({ id: 'stay-gone' })); setElement('stay-gone', makeElement({ id: 'stay-gone' }));
await request(app).delete('/api/elements/clear'); await request(app).delete('/api/elements/clear?confirm=true');
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
const res = await request(app).get('/api/elements'); const res = await request(app).get('/api/elements');
@@ -160,7 +160,7 @@ describe('Snapshot create and restore flow', () => {
expect(snapRes.body.success).toBe(true); expect(snapRes.body.success).toBe(true);
// Clear // Clear
await request(app).delete('/api/elements/clear'); await request(app).delete('/api/elements/clear?confirm=true');
expect(getAllElements()).toHaveLength(0); expect(getAllElements()).toHaveLength(0);
// Snapshot should still contain the elements // Snapshot should still contain the elements
@@ -180,7 +180,7 @@ describe('Snapshot create and restore flow', () => {
await request(app).post('/api/snapshots').send({ name: 'restore-test' }); await request(app).post('/api/snapshots').send({ name: 'restore-test' });
// Clear and add different elements // Clear and add different elements
await request(app).delete('/api/elements/clear'); await request(app).delete('/api/elements/clear?confirm=true');
setElement('different', makeElement({ id: 'different' })); setElement('different', makeElement({ id: 'different' }));
// Get snapshot // Get snapshot
+30
View File
@@ -107,6 +107,36 @@ describe('Input validation - sync endpoints', () => {
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
it('POST /api/elements/sync rejects null elements → 400 not 500', async () => {
const res = await request(app)
.post('/api/elements/sync')
.send({ elements: null });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('POST /api/elements/sync rejects missing elements field → 400 not 500', async () => {
const res = await request(app)
.post('/api/elements/sync')
.send({});
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('POST /api/elements/sync/v2 rejects invalid element type in upsert → 400', async () => {
const res = await request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: 0,
changes: [{ id: 'test-id', action: 'upsert', element: { type: 'malicious<script>', x: 0, y: 0 } }]
});
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('POST /api/elements/sync/v2 rejects non-number lastSyncVersion', async () => { it('POST /api/elements/sync/v2 rejects non-number lastSyncVersion', async () => {
const res = await request(app) const res = await request(app)
.post('/api/elements/sync/v2') .post('/api/elements/sync/v2')
+46 -67
View File
@@ -8,6 +8,7 @@ import fs from 'fs';
let dbPath: string; let dbPath: string;
let app: any; let app: any;
let clientCounter = 0;
function makeElement(overrides: Partial<ServerElement> = {}): ServerElement { function makeElement(overrides: Partial<ServerElement> = {}): ServerElement {
return { return {
@@ -22,12 +23,34 @@ function makeElement(overrides: Partial<ServerElement> = {}): ServerElement {
}; };
} }
function nextClientIp(): string {
clientCounter += 1;
const third = Math.floor(clientCounter / 255);
const fourth = (clientCounter % 255) || 1;
return `10.42.${third}.${fourth}`;
}
function postSyncV2(body: Record<string, unknown>, clientIp = nextClientIp()) {
return request(app)
.post('/api/elements/sync/v2')
.set('X-Forwarded-For', clientIp)
.send(body);
}
function postLegacySync(body: Record<string, unknown>, clientIp = nextClientIp()) {
return request(app)
.post('/api/elements/sync')
.set('X-Forwarded-For', clientIp)
.send(body);
}
beforeEach(async () => { beforeEach(async () => {
dbPath = path.join(os.tmpdir(), `excalidraw-sync-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); dbPath = path.join(os.tmpdir(), `excalidraw-sync-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
initDb(dbPath); initDb(dbPath);
setActiveTenant('default'); setActiveTenant('default');
const mod = await import('../../src/server.js'); const mod = await import('../../src/server.js');
app = mod.default; app = mod.default;
app.set('trust proxy', 1);
}); });
afterEach(() => { afterEach(() => {
@@ -47,9 +70,7 @@ describe('Delta sync v2 - deletion flows', () => {
const v0 = getCurrentSyncVersion(); const v0 = getCurrentSyncVersion();
const res = await request(app) const res = await postSyncV2({
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: v0, lastSyncVersion: v0,
changes: [ changes: [
{ id: 'a', action: 'delete' }, { id: 'a', action: 'delete' },
@@ -73,9 +94,7 @@ describe('Delta sync v2 - deletion flows', () => {
const v0 = getCurrentSyncVersion(); const v0 = getCurrentSyncVersion();
const res = await request(app) const res = await postSyncV2({
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: v0, lastSyncVersion: v0,
changes: [ changes: [
{ id: 'x', action: 'delete' }, { id: 'x', action: 'delete' },
@@ -89,9 +108,7 @@ describe('Delta sync v2 - deletion flows', () => {
}); });
it('delete for non-existent element does not crash', async () => { it('delete for non-existent element does not crash', async () => {
const res = await request(app) const res = await postSyncV2({
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: 0, lastSyncVersion: 0,
changes: [{ id: 'ghost', action: 'delete' }], changes: [{ id: 'ghost', action: 'delete' }],
}); });
@@ -106,9 +123,7 @@ describe('Delta sync v2 - deletion flows', () => {
const v0 = getCurrentSyncVersion(); const v0 = getCurrentSyncVersion();
await request(app) await postSyncV2({
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: v0, lastSyncVersion: v0,
changes: [{ id: 'persist-1', action: 'delete' }], changes: [{ id: 'persist-1', action: 'delete' }],
}); });
@@ -125,9 +140,7 @@ describe('Delta sync v2 - deletion flows', () => {
const v0 = getCurrentSyncVersion(); const v0 = getCurrentSyncVersion();
// Simulate: frontend syncs deletions // Simulate: frontend syncs deletions
await request(app) await postSyncV2({
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: v0, lastSyncVersion: v0,
changes: [ changes: [
{ id: 'reload-1', action: 'delete' }, { id: 'reload-1', action: 'delete' },
@@ -153,9 +166,7 @@ describe('Delta sync v2 - mixed operations', () => {
const v0 = getCurrentSyncVersion(); const v0 = getCurrentSyncVersion();
const res = await request(app) const res = await postSyncV2({
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: v0, lastSyncVersion: v0,
changes: [ changes: [
{ id: 'a', action: 'delete' }, { id: 'a', action: 'delete' },
@@ -181,9 +192,7 @@ describe('Delta sync v2 - mixed operations', () => {
const v0 = getCurrentSyncVersion(); const v0 = getCurrentSyncVersion();
// Delete it // Delete it
await request(app) await postSyncV2({
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: v0, lastSyncVersion: v0,
changes: [{ id: 'revive', action: 'delete' }], changes: [{ id: 'revive', action: 'delete' }],
}); });
@@ -192,9 +201,7 @@ describe('Delta sync v2 - mixed operations', () => {
// Re-create it // Re-create it
const v1 = getCurrentSyncVersion(); const v1 = getCurrentSyncVersion();
await request(app) await postSyncV2({
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: v1, lastSyncVersion: v1,
changes: [{ id: 'revive', action: 'upsert', element: makeElement({ id: 'revive', x: 999 }) }], changes: [{ id: 'revive', action: 'upsert', element: makeElement({ id: 'revive', x: 999 }) }],
}); });
@@ -215,9 +222,7 @@ describe('Delta sync v2 - bidirectional sync', () => {
setElement('mcp-2', makeElement({ id: 'mcp-2' })); setElement('mcp-2', makeElement({ id: 'mcp-2' }));
// Client syncs from version 0 with its own new element // Client syncs from version 0 with its own new element
const res = await request(app) const res = await postSyncV2({
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: 0, lastSyncVersion: 0,
changes: [ changes: [
{ id: 'fe-1', action: 'upsert', element: makeElement({ id: 'fe-1' }) }, { id: 'fe-1', action: 'upsert', element: makeElement({ id: 'fe-1' }) },
@@ -241,9 +246,7 @@ describe('Delta sync v2 - bidirectional sync', () => {
const v1 = getCurrentSyncVersion(); const v1 = getCurrentSyncVersion();
// Client syncs from before the delete // Client syncs from before the delete
const res = await request(app) const res = await postSyncV2({ lastSyncVersion: v0, changes: [] });
.post('/api/elements/sync/v2')
.send({ lastSyncVersion: v0, changes: [] });
const deleteChange = res.body.serverChanges.find((c: any) => c.id === 'srv-del'); const deleteChange = res.body.serverChanges.find((c: any) => c.id === 'srv-del');
expect(deleteChange).toBeDefined(); expect(deleteChange).toBeDefined();
@@ -253,9 +256,7 @@ describe('Delta sync v2 - bidirectional sync', () => {
it('excludes client-sent IDs from serverChanges', async () => { it('excludes client-sent IDs from serverChanges', async () => {
setElement('shared', makeElement({ id: 'shared', x: 0 })); setElement('shared', makeElement({ id: 'shared', x: 0 }));
const res = await request(app) const res = await postSyncV2({
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: 0, lastSyncVersion: 0,
changes: [ changes: [
{ id: 'shared', action: 'upsert', element: makeElement({ id: 'shared', x: 50 }) }, { id: 'shared', action: 'upsert', element: makeElement({ id: 'shared', x: 50 }) },
@@ -273,9 +274,7 @@ describe('Delta sync v2 - bidirectional sync', () => {
describe('Delta sync v2 - multiple rounds', () => { describe('Delta sync v2 - multiple rounds', () => {
it('tracks sync version across multiple sync rounds', async () => { it('tracks sync version across multiple sync rounds', async () => {
// Round 1: create elements // Round 1: create elements
const r1 = await request(app) const r1 = await postSyncV2({
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: 0, lastSyncVersion: 0,
changes: [ changes: [
{ id: 'r1-a', action: 'upsert', element: makeElement({ id: 'r1-a' }) }, { id: 'r1-a', action: 'upsert', element: makeElement({ id: 'r1-a' }) },
@@ -287,9 +286,7 @@ describe('Delta sync v2 - multiple rounds', () => {
const v1 = r1.body.currentSyncVersion; const v1 = r1.body.currentSyncVersion;
// Round 2: update one, delete one, create one // Round 2: update one, delete one, create one
const r2 = await request(app) const r2 = await postSyncV2({
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: v1, lastSyncVersion: v1,
changes: [ changes: [
{ id: 'r1-a', action: 'upsert', element: makeElement({ id: 'r1-a', x: 999 }) }, { id: 'r1-a', action: 'upsert', element: makeElement({ id: 'r1-a', x: 999 }) },
@@ -315,9 +312,7 @@ describe('Delta sync v2 - multiple rounds', () => {
setElement('existing', makeElement({ id: 'existing' })); setElement('existing', makeElement({ id: 'existing' }));
const v0 = getCurrentSyncVersion(); const v0 = getCurrentSyncVersion();
const res = await request(app) const res = await postSyncV2({ lastSyncVersion: v0, changes: [] });
.post('/api/elements/sync/v2')
.send({ lastSyncVersion: v0, changes: [] });
expect(res.body.success).toBe(true); expect(res.body.success).toBe(true);
expect(res.body.appliedCount).toBe(0); expect(res.body.appliedCount).toBe(0);
@@ -337,18 +332,14 @@ describe('Sync version monotonicity', () => {
versions.push(getCurrentSyncVersion()); versions.push(getCurrentSyncVersion());
// Update via sync // Update via sync
await request(app) await postSyncV2({
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: 0, lastSyncVersion: 0,
changes: [{ id: 'mono-a', action: 'upsert', element: makeElement({ id: 'mono-a', x: 50 }) }], changes: [{ id: 'mono-a', action: 'upsert', element: makeElement({ id: 'mono-a', x: 50 }) }],
}); });
versions.push(getCurrentSyncVersion()); versions.push(getCurrentSyncVersion());
// Delete via sync // Delete via sync
await request(app) await postSyncV2({
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: versions[versions.length - 1], lastSyncVersion: versions[versions.length - 1],
changes: [{ id: 'mono-a', action: 'delete' }], changes: [{ id: 'mono-a', action: 'delete' }],
}); });
@@ -396,9 +387,7 @@ describe('Sync version monotonicity', () => {
describe('Concurrent sync requests', () => { describe('Concurrent sync requests', () => {
it('parallel sync requests all complete without data loss', async () => { it('parallel sync requests all complete without data loss', async () => {
const promises = Array.from({ length: 5 }, (_, i) => const promises = Array.from({ length: 5 }, (_, i) =>
request(app) postSyncV2({
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: 0, lastSyncVersion: 0,
changes: [ changes: [
{ id: `par-${i}`, action: 'upsert', element: makeElement({ id: `par-${i}`, x: i * 100 }) }, { id: `par-${i}`, action: 'upsert', element: makeElement({ id: `par-${i}`, x: i * 100 }) },
@@ -423,9 +412,7 @@ describe('Concurrent sync requests', () => {
const v0 = getCurrentSyncVersion(); const v0 = getCurrentSyncVersion();
const promises = Array.from({ length: 5 }, (_, i) => const promises = Array.from({ length: 5 }, (_, i) =>
request(app) postSyncV2({
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: v0, lastSyncVersion: v0,
changes: [{ id: `pd-${i}`, action: 'delete' }], changes: [{ id: `pd-${i}`, action: 'delete' }],
}) })
@@ -442,14 +429,12 @@ describe('Sync after clear', () => {
it('elements created after clear persist correctly', async () => { it('elements created after clear persist correctly', async () => {
setElement('pre-clear', makeElement({ id: 'pre-clear' })); setElement('pre-clear', makeElement({ id: 'pre-clear' }));
await request(app).delete('/api/elements/clear'); await request(app).delete('/api/elements/clear?confirm=true');
expect(getAllElements()).toHaveLength(0); expect(getAllElements()).toHaveLength(0);
const v0 = getCurrentSyncVersion(); const v0 = getCurrentSyncVersion();
const res = await request(app) const res = await postSyncV2({
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: v0, lastSyncVersion: v0,
changes: [ changes: [
{ id: 'post-clear', action: 'upsert', element: makeElement({ id: 'post-clear' }) }, { id: 'post-clear', action: 'upsert', element: makeElement({ id: 'post-clear' }) },
@@ -480,9 +465,7 @@ describe('POST /api/elements/sync (legacy overwrite)', () => {
setElement('old-1', makeElement({ id: 'old-1' })); setElement('old-1', makeElement({ id: 'old-1' }));
setElement('old-2', makeElement({ id: 'old-2' })); setElement('old-2', makeElement({ id: 'old-2' }));
const res = await request(app) const res = await postLegacySync({
.post('/api/elements/sync')
.send({
elements: [makeElement({ id: 'new-1' })], elements: [makeElement({ id: 'new-1' })],
}); });
@@ -501,9 +484,7 @@ describe('POST /api/elements/sync (legacy overwrite)', () => {
it('overwrite with empty array clears all elements', async () => { it('overwrite with empty array clears all elements', async () => {
setElement('gone', makeElement({ id: 'gone' })); setElement('gone', makeElement({ id: 'gone' }));
await request(app) await postLegacySync({ elements: [] });
.post('/api/elements/sync')
.send({ elements: [] });
expect(getAllElements()).toHaveLength(0); expect(getAllElements()).toHaveLength(0);
@@ -527,9 +508,7 @@ describe('GET /api/sync/version', () => {
const r1 = await request(app).get('/api/sync/version'); const r1 = await request(app).get('/api/sync/version');
const v1 = r1.body.syncVersion; const v1 = r1.body.syncVersion;
await request(app) await postSyncV2({
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: 0, lastSyncVersion: 0,
changes: [{ id: 'bump', action: 'upsert', element: makeElement({ id: 'bump' }) }], changes: [{ id: 'bump', action: 'upsert', element: makeElement({ id: 'bump' }) }],
}); });
+1 -1
View File
@@ -194,7 +194,7 @@ describe('Element isolation per tenant', () => {
// Clear tenant A // Clear tenant A
await request(app) await request(app)
.delete('/api/elements/clear') .delete('/api/elements/clear?confirm=true')
.set('X-Tenant-Id', 'clr-a'); .set('X-Tenant-Id', 'clr-a');
const resA = await request(app) const resA = await request(app)
+1 -1
View File
@@ -198,7 +198,7 @@ describe('WebSocket broadcasts', () => {
const clearedPromise = waitForMessageOfType(ws, 'canvas_cleared'); const clearedPromise = waitForMessageOfType(ws, 'canvas_cleared');
await fetch(`http://localhost:${port}/api/elements/clear`, { method: 'DELETE' }); await fetch(`http://localhost:${port}/api/elements/clear?confirm=true`, { method: 'DELETE' });
const msg = await clearedPromise; const msg = await clearedPromise;
expect(msg.type).toBe('canvas_cleared'); expect(msg.type).toBe('canvas_cleared');
+12 -2
View File
@@ -1,9 +1,19 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
const API = 'http://localhost:3100'; const API = 'http://127.0.0.1:3100';
async function resetCanvas(request: any): Promise<void> {
const listRes = await request.get(`${API}/api/elements`);
const listBody = await listRes.json();
await Promise.all(
(listBody.elements ?? []).map((element: { id: string }) =>
request.delete(`${API}/api/elements/${element.id}`)
)
);
}
test.beforeEach(async ({ request }) => { test.beforeEach(async ({ request }) => {
await request.delete(`${API}/api/elements/clear`); await resetCanvas(request);
}); });
// ─── Fix 3: Hello handshake → real-time sync works immediately ── // ─── Fix 3: Hello handshake → real-time sync works immediately ──
+14 -4
View File
@@ -1,9 +1,19 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
const API = 'http://localhost:3100'; const API = 'http://127.0.0.1:3100';
async function resetCanvas(request: any): Promise<void> {
const listRes = await request.get(`${API}/api/elements`);
const listBody = await listRes.json();
await Promise.all(
(listBody.elements ?? []).map((element: { id: string }) =>
request.delete(`${API}/api/elements/${element.id}`)
)
);
}
test.beforeEach(async ({ request }) => { test.beforeEach(async ({ request }) => {
await request.delete(`${API}/api/elements/clear`); await resetCanvas(request);
}); });
// ─── Page Load ─────────────────────────────────────────────── // ─── Page Load ───────────────────────────────────────────────
@@ -103,7 +113,7 @@ test.describe('Element CRUD via API', () => {
}, },
}); });
const clearRes = await request.delete(`${API}/api/elements/clear`); const clearRes = await request.delete(`${API}/api/elements/clear?confirm=true`);
expect(clearRes.ok()).toBe(true); expect(clearRes.ok()).toBe(true);
const clearBody = await clearRes.json(); const clearBody = await clearRes.json();
expect(clearBody.count).toBe(2); expect(clearBody.count).toBe(2);
@@ -150,7 +160,7 @@ test.describe('Real-time Canvas Sync', () => {
}); });
await page.waitForTimeout(300); await page.waitForTimeout(300);
await request.delete(`${API}/api/elements/clear`); await request.delete(`${API}/api/elements/clear?confirm=true`);
await page.waitForTimeout(500); await page.waitForTimeout(500);
const listRes = await request.get(`${API}/api/elements`); const listRes = await request.get(`${API}/api/elements`);
+14 -4
View File
@@ -1,9 +1,19 @@
import { test, expect, type Page } from '@playwright/test'; import { test, expect, type Page } from '@playwright/test';
const API = 'http://localhost:3100'; const API = 'http://127.0.0.1:3100';
async function resetCanvas(request: any): Promise<void> {
const listRes = await request.get(`${API}/api/elements`);
const listBody = await listRes.json();
await Promise.all(
(listBody.elements ?? []).map((element: { id: string }) =>
request.delete(`${API}/api/elements/${element.id}`)
)
);
}
test.beforeEach(async ({ request }) => { test.beforeEach(async ({ request }) => {
await request.delete(`${API}/api/elements/clear`); await resetCanvas(request);
}); });
// ─── Helpers ──────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────
@@ -357,7 +367,7 @@ test.describe('Clear canvas persistence', () => {
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Clear // Clear
await request.delete(`${API}/api/elements/clear`); await request.delete(`${API}/api/elements/clear?confirm=true`);
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Verify gone // Verify gone
@@ -394,7 +404,7 @@ test.describe('Snapshots E2E', () => {
expect((await snapRes.json()).success).toBe(true); expect((await snapRes.json()).success).toBe(true);
// Clear // Clear
await request.delete(`${API}/api/elements/clear`); await request.delete(`${API}/api/elements/clear?confirm=true`);
expect(await getServerElementCount(request)).toBe(0); expect(await getServerElementCount(request)).toBe(0);
// List snapshots // List snapshots