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:
co-authored by
Claude Sonnet 4.6
parent
a1977d86f9
commit
5539235004
@@ -30,6 +30,7 @@ coverage
|
||||
.nyc_output
|
||||
*.test.ts
|
||||
*.spec.ts
|
||||
tests
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
@@ -49,6 +50,13 @@ docker-compose*.yml
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Sensitive key material
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
*.pfx
|
||||
*.crt
|
||||
|
||||
# Misc
|
||||
tmp
|
||||
temp
|
||||
|
||||
+17
-17
@@ -17,7 +17,7 @@ jobs:
|
||||
outputs:
|
||||
should_test: ${{ steps.filter.outputs.should_test }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -42,16 +42,16 @@ jobs:
|
||||
if: needs.check-changes.outputs.should_test == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: Cache node_modules
|
||||
id: cache-nm
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: node_modules
|
||||
key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
run: npm run build
|
||||
|
||||
- name: Upload build output
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v4.6.2
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
@@ -75,15 +75,15 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: Restore node_modules from cache
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: node_modules
|
||||
key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
|
||||
@@ -96,15 +96,15 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: Restore node_modules from cache
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: node_modules
|
||||
key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
|
||||
@@ -117,21 +117,21 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: Restore node_modules from cache
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: node_modules
|
||||
key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Download build output
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: cache-pw
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v4.6.2
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
outputs:
|
||||
should_build: ${{ steps.filter.outputs.should_build }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -52,21 +52,21 @@ jobs:
|
||||
if: needs.check-changes.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- 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
|
||||
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:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbea8d2b4be1c9f4f0fc4a38bf1a # v5.7.0
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME_MCP }}
|
||||
tags: |
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Build MCP Server image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v5.5.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -90,21 +90,21 @@ jobs:
|
||||
if: needs.check-changes.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- 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
|
||||
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:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbea8d2b4be1c9f4f0fc4a38bf1a # v5.7.0
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME_CANVAS }}
|
||||
tags: |
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Build Canvas Server image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v5.5.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.canvas
|
||||
@@ -128,7 +128,7 @@ jobs:
|
||||
if: needs.build-mcp.result == 'success' && needs.build-canvas.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Build and test Canvas image locally
|
||||
run: |
|
||||
|
||||
@@ -24,10 +24,10 @@ jobs:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
should_release: ${{ steps.bump.outputs.should_release }}
|
||||
prev_tag: ${{ steps.bump.outputs.prev_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -128,12 +128,12 @@ jobs:
|
||||
steps:
|
||||
- name: Generate release bot token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c550f5b5e3e8cd3f9 # v1.11.6
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -156,7 +156,7 @@ jobs:
|
||||
git push origin "v${{ needs.check.outputs.new_version }}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba36ea0be2ddfef1a7e5c4d12 # v2.2.2
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
tag_name: v${{ needs.check.outputs.new_version }}
|
||||
@@ -180,19 +180,19 @@ jobs:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Cache node_modules
|
||||
id: cache-nm
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: node_modules
|
||||
key: node-modules-${{ runner.os }}-node20.x-${{ hashFiles('package-lock.json') }}
|
||||
@@ -233,21 +233,21 @@ jobs:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: v${{ needs.release.outputs.version }}
|
||||
|
||||
- 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
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push MCP Server image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v5.5.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -260,7 +260,7 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Build and push Canvas Server image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v5.5.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.canvas
|
||||
|
||||
@@ -7,6 +7,12 @@ public/dist/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
*.key
|
||||
*.pem
|
||||
*.p12
|
||||
*.pfx
|
||||
secrets.json
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
Executable
+12
@@ -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
|
||||
@@ -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
|
||||
@@ -51,6 +51,9 @@ USER nodejs
|
||||
|
||||
ENV NODE_ENV=production
|
||||
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 EXCALIDRAW_DB_PATH=/app/data/excalidraw.db
|
||||
|
||||
|
||||
@@ -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_EXPORT_DIR` | Allowed directory for file exports | `process.cwd()` |
|
||||
| `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)
|
||||
|
||||
@@ -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` |
|
||||
| **Layout** | `align_elements`, `distribute_elements`, `group_elements`, `ungroup_elements`, `lock_elements`, `unlock_elements` |
|
||||
| **Scene Awareness** | `describe_scene`, `get_canvas_screenshot` |
|
||||
| **File I/O** | `export_scene`, `import_scene`, `export_to_image`, `export_to_excalidraw_url`, `create_from_mermaid` |
|
||||
| **Scene Awareness** | `describe_scene`, `get_canvas_screenshot` ⚠️ |
|
||||
| **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` |
|
||||
| **Viewport** | `set_viewport` |
|
||||
| **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`.
|
||||
|
||||
> ⚠️ **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
|
||||
|
||||
### Health check
|
||||
@@ -661,20 +688,37 @@ The canvas server exposes a REST API alongside the WebSocket interface:
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/health` | Health check |
|
||||
| GET | `/health` | Health check (auth-exempt) |
|
||||
| GET | `/api/elements` | List all elements |
|
||||
| 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 |
|
||||
| DELETE | `/api/elements/:id` | Delete an element |
|
||||
| DELETE | `/api/elements/clear` | Clear all elements |
|
||||
| POST | `/api/elements/sync` | Sync all elements (bulk upsert) |
|
||||
| DELETE | `/api/elements/clear` | Clear all elements (requires `?confirm=true`) |
|
||||
| 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/tenant/active` | Get the active tenant |
|
||||
| PUT | `/api/tenant/active` | Set the active tenant |
|
||||
| GET | `/api/settings/:key` | Read 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
|
||||
|
||||
|
||||
@@ -22,8 +22,14 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- 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
|
||||
- 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
|
||||
healthcheck:
|
||||
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
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1.0'
|
||||
memory: 512M
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
@@ -49,9 +60,16 @@ services:
|
||||
- EXPRESS_SERVER_URL=http://canvas:3000
|
||||
- ENABLE_CANVAS_SYNC=true
|
||||
- DEBUG=false
|
||||
# Must match canvas EXCALIDRAW_API_KEY so inter-service sync calls are authenticated.
|
||||
- EXCALIDRAW_API_KEY=${EXCALIDRAW_API_KEY:-}
|
||||
depends_on:
|
||||
canvas:
|
||||
condition: service_healthy
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 256M
|
||||
networks:
|
||||
- mcp-network
|
||||
profiles:
|
||||
|
||||
+101
-48
@@ -53,11 +53,21 @@ interface TenantInfo {
|
||||
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 {
|
||||
const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawAPIRefValue | null>(null)
|
||||
const excalidrawAPIRef = useRef<ExcalidrawAPIRefValue | null>(null)
|
||||
const [isConnected, setIsConnected] = useState<boolean>(false)
|
||||
const websocketRef = useRef<WebSocket | null>(null)
|
||||
const reconnectEnabledRef = useRef<boolean>(true)
|
||||
|
||||
// Sync state
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle')
|
||||
@@ -101,6 +111,7 @@ function App(): JSX.Element {
|
||||
}
|
||||
const tid = activeTenantIdRef.current
|
||||
if (tid) headers['X-Tenant-Id'] = tid
|
||||
if (browserApiKey) headers['X-API-Key'] = browserApiKey
|
||||
return headers
|
||||
}
|
||||
|
||||
@@ -244,6 +255,9 @@ function App(): JSX.Element {
|
||||
}
|
||||
|
||||
const connectWebSocket = (): void => {
|
||||
if (!reconnectEnabledRef.current) {
|
||||
return
|
||||
}
|
||||
if (websocketRef.current && websocketRef.current.readyState === WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
@@ -274,7 +288,7 @@ function App(): JSX.Element {
|
||||
setIsConnected(false)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -285,10 +299,13 @@ function App(): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
const sendHello = (tenantId: string): void => {
|
||||
const sendHello = (tenantId?: string): void => {
|
||||
const ws = websocketRef.current
|
||||
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 => {
|
||||
@@ -358,6 +375,85 @@ function App(): JSX.Element {
|
||||
}
|
||||
|
||||
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
|
||||
if (data.sync_version !== undefined && typeof data.sync_version === 'number') {
|
||||
const expected = lastReceivedSyncVersionRef.current + 1
|
||||
@@ -714,46 +810,6 @@ function App(): JSX.Element {
|
||||
case 'file_deleted':
|
||||
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:
|
||||
console.log('Unknown WebSocket message type:', data.type)
|
||||
}
|
||||
@@ -875,10 +931,7 @@ function App(): JSX.Element {
|
||||
|
||||
// Load elements for the newly-active tenant
|
||||
const elemRes = await fetch('/api/elements', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Id': tenantId
|
||||
}
|
||||
headers: tenantHeaders({ 'X-Tenant-Id': tenantId })
|
||||
})
|
||||
const result: ApiResponse = await elemRes.json()
|
||||
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
|
||||
useEffect(() => {
|
||||
fetch('/api/settings/clear_canvas_skip_confirm')
|
||||
fetch('/api/settings/clear_canvas_skip_confirm', { headers: tenantHeaders() })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.value === 'true') setClearSkipConfirm(true)
|
||||
|
||||
Generated
+383
-339
File diff suppressed because it is too large
Load Diff
+8
-6
@@ -32,18 +32,20 @@
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "^0.18.0",
|
||||
"@excalidraw/mermaid-to-excalidraw": "^1.1.3",
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"cors": "^2.8.5",
|
||||
"@modelcontextprotocol/sdk": "1.26.0",
|
||||
"better-sqlite3": "12.6.2",
|
||||
"cors": "2.8.5",
|
||||
"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",
|
||||
"node-fetch": "^3.3.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"winston": "^3.11.0",
|
||||
"ws": "^8.14.2",
|
||||
"zod": "^3.22.4",
|
||||
"ws": "8.20.0",
|
||||
"zod": "3.25.5",
|
||||
"zod-to-json-schema": "^3.22.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -9,7 +9,7 @@ export default defineConfig({
|
||||
workers: 1,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3100',
|
||||
baseURL: 'http://127.0.0.1:3100',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
@@ -25,8 +25,12 @@ export default defineConfig({
|
||||
reuseExistingServer: !process.env.CI,
|
||||
env: {
|
||||
CANVAS_PORT: '3100',
|
||||
HOST: 'localhost',
|
||||
HOST: '127.0.0.1',
|
||||
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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -512,6 +512,10 @@ export function getActiveTenant(): 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 {
|
||||
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[];
|
||||
}
|
||||
|
||||
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 {
|
||||
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`);
|
||||
|
||||
+6
-1
@@ -137,11 +137,16 @@ interface SyncResponse {
|
||||
}
|
||||
|
||||
function canvasHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
return {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Id': dbGetActiveTenantId(),
|
||||
...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)
|
||||
|
||||
+213
@@ -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;
|
||||
}
|
||||
+244
-127
@@ -1,6 +1,6 @@
|
||||
import express, { type Application, Request, Response, NextFunction } from 'express';
|
||||
import cors from 'cors';
|
||||
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 fs from 'fs';
|
||||
import path from 'path';
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
BatchCreatedMessage,
|
||||
SyncStatusMessage,
|
||||
InitialElementsMessage,
|
||||
HelloMessage,
|
||||
Snapshot,
|
||||
normalizeFontFamily,
|
||||
ExcalidrawFile,
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
BroadcastResult
|
||||
} from './types.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 WebSocket from 'ws';
|
||||
|
||||
@@ -39,17 +40,25 @@ const __dirname = path.dirname(__filename);
|
||||
|
||||
const app: Application = express();
|
||||
const httpServer = createServer(app);
|
||||
const wss = new WebSocketServer({ server: httpServer });
|
||||
const wss = new WebSocketServer({ server: httpServer, verifyClient: verifyWsClient });
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(helmetMiddleware);
|
||||
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
|
||||
const staticDir = path.join(__dirname, '../dist');
|
||||
app.use(express.static(staticDir));
|
||||
app.use(express.static(staticDir, { index: false }));
|
||||
// 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.
|
||||
// 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 };
|
||||
}
|
||||
|
||||
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) ──────────────────────────────────────────
|
||||
// Scoped by tenant → project → 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) ───────────────
|
||||
wss.on('connection', (ws: WebSocket) => {
|
||||
// Register with fallback scope until hello handshake identifies the client.
|
||||
const tenant = (() => { try { return dbGetActiveTenant(); } catch { return { id: 'default', name: 'default', workspace_path: '' }; } })();
|
||||
const fallbackProjectId = getDefaultProjectForTenant(tenant.id) ?? 'default';
|
||||
const conn: ClientConnection = {
|
||||
ws,
|
||||
tenantId: tenant.id,
|
||||
projectId: fallbackProjectId,
|
||||
connectedAt: Date.now(),
|
||||
identified: false
|
||||
};
|
||||
registerConnection(conn);
|
||||
logger.info('New WebSocket connection established (awaiting hello)');
|
||||
const authEnabled = isAuthEnabled();
|
||||
let awaitingAuth = authEnabled;
|
||||
let authTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// 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;
|
||||
const tenant = (() => {
|
||||
try {
|
||||
return dbGetActiveTenant();
|
||||
} catch {
|
||||
return { id: 'default', name: 'default', workspace_path: '' };
|
||||
}
|
||||
ws.send(JSON.stringify({ type: 'files_added', files: allFiles }));
|
||||
}
|
||||
})();
|
||||
const fallbackProjectId = getDefaultProjectForTenant(tenant.id) ?? 'default';
|
||||
|
||||
// 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));
|
||||
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,
|
||||
tenantId: tenant.id,
|
||||
projectId: fallbackProjectId,
|
||||
connectedAt: Date.now(),
|
||||
identified: false
|
||||
});
|
||||
sendAuthlessInitialMessages(ws, tenant, fallbackProjectId);
|
||||
}
|
||||
|
||||
// Handle incoming messages from this client
|
||||
ws.on('message', (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type === 'hello') {
|
||||
const helloTenantId = msg.tenantId as string;
|
||||
const helloProjectId = (msg.projectId as string) || getDefaultProjectForTenant(msg.tenantId) || `${msg.tenantId}-default`;
|
||||
if (helloTenantId) {
|
||||
// Move connection to the correct scope
|
||||
moveConnection(ws, helloTenantId, helloProjectId);
|
||||
logger.info(`Client identified: tenant=${helloTenantId} project=${helloProjectId}`);
|
||||
|
||||
// Respond with scoped elements
|
||||
const elements = store.getAllElements(helloProjectId);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'hello_ack',
|
||||
tenantId: helloTenantId,
|
||||
projectId: helloProjectId,
|
||||
elements
|
||||
}));
|
||||
if (awaitingAuth) {
|
||||
if (!validateApiKey(msg.apiKey)) {
|
||||
ws.send(JSON.stringify({ type: 'auth_failed', reason: 'invalid_key' }));
|
||||
ws.close(WS_AUTH_CLOSE_CODE, 'Invalid API key');
|
||||
return;
|
||||
}
|
||||
awaitingAuth = false;
|
||||
if (authTimer) {
|
||||
clearTimeout(authTimer);
|
||||
authTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = resolveHelloTenantAndProject(msg);
|
||||
if ('error' in resolved) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: resolved.error }));
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
resolveAck(msg.msgId, {
|
||||
status: msg.status ?? 'applied',
|
||||
@@ -327,22 +444,23 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
if (authTimer) clearTimeout(authTimer);
|
||||
unregisterConnection(ws);
|
||||
logger.info('WebSocket connection closed');
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
if (authTimer) clearTimeout(authTimer);
|
||||
logger.error('WebSocket error:', error);
|
||||
unregisterConnection(ws);
|
||||
});
|
||||
});
|
||||
|
||||
// Schema validation
|
||||
const CreateElementSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]),
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
// Module-level constants
|
||||
const VALID_ELEMENT_TYPES = new Set(Object.values(EXCALIDRAW_ELEMENT_TYPES));
|
||||
|
||||
// Schema validation — shared fields extracted to avoid duplication
|
||||
const ElementSharedFieldsSchema = z.object({
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
backgroundColor: z.string().optional(),
|
||||
@@ -353,9 +471,7 @@ const CreateElementSchema = z.object({
|
||||
opacity: z.number().optional(),
|
||||
text: z.string().optional(),
|
||||
originalText: z.string().optional(),
|
||||
label: z.object({
|
||||
text: 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(),
|
||||
@@ -363,7 +479,6 @@ const CreateElementSchema = z.object({
|
||||
roundness: z.object({ type: z.number(), value: z.number().optional() }).nullable().optional(),
|
||||
fillStyle: z.string().optional(),
|
||||
// Arrow-specific properties
|
||||
points: z.any().optional(),
|
||||
start: z.object({ id: z.string() }).optional(),
|
||||
end: z.object({ id: z.string() }).optional(),
|
||||
startArrowhead: z.string().nullable().optional(),
|
||||
@@ -378,45 +493,23 @@ const CreateElementSchema = z.object({
|
||||
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(),
|
||||
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]).optional(),
|
||||
x: 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([
|
||||
z.tuple([z.number(), z.number()]),
|
||||
z.object({ x: z.number(), y: z.number() })
|
||||
])).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
|
||||
@@ -465,7 +558,7 @@ app.post('/api/elements', async (req: Request, res: Response) => {
|
||||
type: 'element_created',
|
||||
element: element
|
||||
};
|
||||
(message as any).sync_version = sv;
|
||||
message['sync_version'] = sv;
|
||||
const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message);
|
||||
|
||||
res.json({
|
||||
@@ -526,7 +619,7 @@ app.put('/api/elements/:id', async (req: Request, res: Response) => {
|
||||
type: 'element_updated',
|
||||
element: updatedElement
|
||||
};
|
||||
(message as any).sync_version = sv;
|
||||
message['sync_version'] = sv;
|
||||
const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message);
|
||||
|
||||
res.json({
|
||||
@@ -550,7 +643,7 @@ app.put('/api/elements/:id', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// 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 {
|
||||
const projId = resolveTenantProject(req);
|
||||
const count = store.clearElements(projId);
|
||||
@@ -626,8 +719,11 @@ app.get('/api/elements/search', (req: Request, res: Response) => {
|
||||
const { type, q, ...filters } = req.query;
|
||||
|
||||
if (q && typeof q === 'string') {
|
||||
const results = store.searchElements(q, projId);
|
||||
return res.json({ success: true, elements: results, count: results.length });
|
||||
const sanitizedQuery = sanitizeSearchQuery(q);
|
||||
if (sanitizedQuery) {
|
||||
const results = store.searchElements(sanitizedQuery, projId);
|
||||
return res.json({ success: true, elements: results, count: results.length });
|
||||
}
|
||||
}
|
||||
|
||||
const results = store.queryElements(
|
||||
@@ -643,9 +739,15 @@ app.get('/api/elements/search', (req: Request, res: Response) => {
|
||||
});
|
||||
} catch (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({
|
||||
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
|
||||
app.post('/api/elements/from-mermaid', (req: Request, res: Response) => {
|
||||
app.post('/api/elements/from-mermaid', validateMermaidInput, (req: Request, res: Response) => {
|
||||
try {
|
||||
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)
|
||||
app.post('/api/elements/sync', (req: Request, res: Response) => {
|
||||
app.post('/api/elements/sync', writeBurstLimit, (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const { elements: frontendElements, timestamp } = req.body;
|
||||
|
||||
logger.info(`Sync request received: ${frontendElements.length} elements`, {
|
||||
timestamp,
|
||||
elementCount: frontendElements.length
|
||||
});
|
||||
|
||||
if (!Array.isArray(frontendElements)) {
|
||||
return res.status(400).json({
|
||||
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);
|
||||
|
||||
// Process elements with server metadata
|
||||
@@ -996,7 +1098,7 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
|
||||
|
||||
// ── 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 {
|
||||
const projId = resolveTenantProject(req);
|
||||
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 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
|
||||
let appliedCount = 0;
|
||||
for (const change of changes) {
|
||||
@@ -1069,11 +1185,7 @@ app.get('/api/sync/version', (req: Request, res: Response) => {
|
||||
// Get all files
|
||||
app.get('/api/files', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const allFiles: Record<string, ExcalidrawFile> = {};
|
||||
for (const [id, file] of files) {
|
||||
allFiles[id] = file;
|
||||
}
|
||||
res.json({ success: true, files: allFiles });
|
||||
res.json({ success: true, files: getAllFilesObject() });
|
||||
} catch (error) {
|
||||
logger.error('Error fetching files:', error);
|
||||
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
|
||||
app.get('/', (req: Request, res: Response) => {
|
||||
const htmlFile = path.join(__dirname, '../dist/frontend/index.html');
|
||||
res.sendFile(htmlFile, (err) => {
|
||||
fs.readFile(frontendHtmlPath, 'utf8', (err, html) => {
|
||||
if (err) {
|
||||
logger.error('Error serving frontend:', err);
|
||||
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
|
||||
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);
|
||||
res.status(500).json({
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: 'Internal server error'
|
||||
error: status === 500 ? 'Internal server error' : (err.message ?? 'Error')
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+12
-3
@@ -196,7 +196,10 @@ export type WebSocketMessageType =
|
||||
| 'file_deleted'
|
||||
| 'hello'
|
||||
| 'hello_ack'
|
||||
| 'ack';
|
||||
| 'ack'
|
||||
| 'auth_required'
|
||||
| 'auth_failed'
|
||||
| 'error';
|
||||
|
||||
// Connection registry types
|
||||
export interface ClientConnection {
|
||||
@@ -215,8 +218,9 @@ export interface BroadcastResult {
|
||||
|
||||
export interface HelloMessage extends WebSocketMessage {
|
||||
type: 'hello';
|
||||
tenantId: string;
|
||||
projectId: string;
|
||||
tenantId?: string;
|
||||
projectId?: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
export interface HelloAckMessage extends WebSocketMessage {
|
||||
@@ -224,6 +228,11 @@ export interface HelloAckMessage extends WebSocketMessage {
|
||||
tenantId: string;
|
||||
projectId: string;
|
||||
elements: ServerElement[];
|
||||
tenant?: {
|
||||
id: string;
|
||||
name: string;
|
||||
workspace_path: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AckMessage extends WebSocketMessage {
|
||||
|
||||
@@ -173,7 +173,7 @@ describe('DELETE /api/elements/clear', () => {
|
||||
setElement('a', makeElement({ id: 'a' }));
|
||||
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.body.count).toBe(2);
|
||||
|
||||
@@ -182,7 +182,7 @@ describe('DELETE /api/elements/clear', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('Clear canvas confirmation flow', () => {
|
||||
setElement('cl-2', makeElement({ id: 'cl-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.count).toBeDefined();
|
||||
|
||||
@@ -54,13 +54,13 @@ describe('Clear canvas confirmation flow', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('cleared elements stay gone on subsequent GET requests', async () => {
|
||||
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++) {
|
||||
const res = await request(app).get('/api/elements');
|
||||
@@ -160,7 +160,7 @@ describe('Snapshot create and restore flow', () => {
|
||||
expect(snapRes.body.success).toBe(true);
|
||||
|
||||
// Clear
|
||||
await request(app).delete('/api/elements/clear');
|
||||
await request(app).delete('/api/elements/clear?confirm=true');
|
||||
expect(getAllElements()).toHaveLength(0);
|
||||
|
||||
// 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' });
|
||||
|
||||
// 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' }));
|
||||
|
||||
// Get snapshot
|
||||
|
||||
@@ -107,6 +107,36 @@ describe('Input validation - sync endpoints', () => {
|
||||
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 () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
|
||||
@@ -8,6 +8,7 @@ import fs from 'fs';
|
||||
|
||||
let dbPath: string;
|
||||
let app: any;
|
||||
let clientCounter = 0;
|
||||
|
||||
function makeElement(overrides: Partial<ServerElement> = {}): ServerElement {
|
||||
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 () => {
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-sync-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
const mod = await import('../../src/server.js');
|
||||
app = mod.default;
|
||||
app.set('trust proxy', 1);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -47,9 +70,7 @@ describe('Delta sync v2 - deletion flows', () => {
|
||||
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
const res = await postSyncV2({
|
||||
lastSyncVersion: v0,
|
||||
changes: [
|
||||
{ id: 'a', action: 'delete' },
|
||||
@@ -73,9 +94,7 @@ describe('Delta sync v2 - deletion flows', () => {
|
||||
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
const res = await postSyncV2({
|
||||
lastSyncVersion: v0,
|
||||
changes: [
|
||||
{ id: 'x', action: 'delete' },
|
||||
@@ -89,9 +108,7 @@ describe('Delta sync v2 - deletion flows', () => {
|
||||
});
|
||||
|
||||
it('delete for non-existent element does not crash', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
const res = await postSyncV2({
|
||||
lastSyncVersion: 0,
|
||||
changes: [{ id: 'ghost', action: 'delete' }],
|
||||
});
|
||||
@@ -106,9 +123,7 @@ describe('Delta sync v2 - deletion flows', () => {
|
||||
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
await postSyncV2({
|
||||
lastSyncVersion: v0,
|
||||
changes: [{ id: 'persist-1', action: 'delete' }],
|
||||
});
|
||||
@@ -125,9 +140,7 @@ describe('Delta sync v2 - deletion flows', () => {
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
// Simulate: frontend syncs deletions
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
await postSyncV2({
|
||||
lastSyncVersion: v0,
|
||||
changes: [
|
||||
{ id: 'reload-1', action: 'delete' },
|
||||
@@ -153,9 +166,7 @@ describe('Delta sync v2 - mixed operations', () => {
|
||||
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
const res = await postSyncV2({
|
||||
lastSyncVersion: v0,
|
||||
changes: [
|
||||
{ id: 'a', action: 'delete' },
|
||||
@@ -181,9 +192,7 @@ describe('Delta sync v2 - mixed operations', () => {
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
// Delete it
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
await postSyncV2({
|
||||
lastSyncVersion: v0,
|
||||
changes: [{ id: 'revive', action: 'delete' }],
|
||||
});
|
||||
@@ -192,9 +201,7 @@ describe('Delta sync v2 - mixed operations', () => {
|
||||
|
||||
// Re-create it
|
||||
const v1 = getCurrentSyncVersion();
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
await postSyncV2({
|
||||
lastSyncVersion: v1,
|
||||
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' }));
|
||||
|
||||
// Client syncs from version 0 with its own new element
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
const res = await postSyncV2({
|
||||
lastSyncVersion: 0,
|
||||
changes: [
|
||||
{ id: 'fe-1', action: 'upsert', element: makeElement({ id: 'fe-1' }) },
|
||||
@@ -241,9 +246,7 @@ describe('Delta sync v2 - bidirectional sync', () => {
|
||||
const v1 = getCurrentSyncVersion();
|
||||
|
||||
// Client syncs from before the delete
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({ lastSyncVersion: v0, changes: [] });
|
||||
const res = await postSyncV2({ lastSyncVersion: v0, changes: [] });
|
||||
|
||||
const deleteChange = res.body.serverChanges.find((c: any) => c.id === 'srv-del');
|
||||
expect(deleteChange).toBeDefined();
|
||||
@@ -253,9 +256,7 @@ describe('Delta sync v2 - bidirectional sync', () => {
|
||||
it('excludes client-sent IDs from serverChanges', async () => {
|
||||
setElement('shared', makeElement({ id: 'shared', x: 0 }));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
const res = await postSyncV2({
|
||||
lastSyncVersion: 0,
|
||||
changes: [
|
||||
{ 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', () => {
|
||||
it('tracks sync version across multiple sync rounds', async () => {
|
||||
// Round 1: create elements
|
||||
const r1 = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
const r1 = await postSyncV2({
|
||||
lastSyncVersion: 0,
|
||||
changes: [
|
||||
{ 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;
|
||||
|
||||
// Round 2: update one, delete one, create one
|
||||
const r2 = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
const r2 = await postSyncV2({
|
||||
lastSyncVersion: v1,
|
||||
changes: [
|
||||
{ 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' }));
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({ lastSyncVersion: v0, changes: [] });
|
||||
const res = await postSyncV2({ lastSyncVersion: v0, changes: [] });
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.appliedCount).toBe(0);
|
||||
@@ -337,18 +332,14 @@ describe('Sync version monotonicity', () => {
|
||||
versions.push(getCurrentSyncVersion());
|
||||
|
||||
// Update via sync
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
await postSyncV2({
|
||||
lastSyncVersion: 0,
|
||||
changes: [{ id: 'mono-a', action: 'upsert', element: makeElement({ id: 'mono-a', x: 50 }) }],
|
||||
});
|
||||
versions.push(getCurrentSyncVersion());
|
||||
|
||||
// Delete via sync
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
await postSyncV2({
|
||||
lastSyncVersion: versions[versions.length - 1],
|
||||
changes: [{ id: 'mono-a', action: 'delete' }],
|
||||
});
|
||||
@@ -396,9 +387,7 @@ describe('Sync version monotonicity', () => {
|
||||
describe('Concurrent sync requests', () => {
|
||||
it('parallel sync requests all complete without data loss', async () => {
|
||||
const promises = Array.from({ length: 5 }, (_, i) =>
|
||||
request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
postSyncV2({
|
||||
lastSyncVersion: 0,
|
||||
changes: [
|
||||
{ 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 promises = Array.from({ length: 5 }, (_, i) =>
|
||||
request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
postSyncV2({
|
||||
lastSyncVersion: v0,
|
||||
changes: [{ id: `pd-${i}`, action: 'delete' }],
|
||||
})
|
||||
@@ -442,14 +429,12 @@ describe('Sync after clear', () => {
|
||||
it('elements created after clear persist correctly', async () => {
|
||||
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);
|
||||
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
const res = await postSyncV2({
|
||||
lastSyncVersion: v0,
|
||||
changes: [
|
||||
{ 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-2', makeElement({ id: 'old-2' }));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.send({
|
||||
const res = await postLegacySync({
|
||||
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 () => {
|
||||
setElement('gone', makeElement({ id: 'gone' }));
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.send({ elements: [] });
|
||||
await postLegacySync({ elements: [] });
|
||||
|
||||
expect(getAllElements()).toHaveLength(0);
|
||||
|
||||
@@ -527,9 +508,7 @@ describe('GET /api/sync/version', () => {
|
||||
const r1 = await request(app).get('/api/sync/version');
|
||||
const v1 = r1.body.syncVersion;
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
await postSyncV2({
|
||||
lastSyncVersion: 0,
|
||||
changes: [{ id: 'bump', action: 'upsert', element: makeElement({ id: 'bump' }) }],
|
||||
});
|
||||
|
||||
@@ -194,7 +194,7 @@ describe('Element isolation per tenant', () => {
|
||||
|
||||
// Clear tenant A
|
||||
await request(app)
|
||||
.delete('/api/elements/clear')
|
||||
.delete('/api/elements/clear?confirm=true')
|
||||
.set('X-Tenant-Id', 'clr-a');
|
||||
|
||||
const resA = await request(app)
|
||||
|
||||
@@ -198,7 +198,7 @@ describe('WebSocket broadcasts', () => {
|
||||
|
||||
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;
|
||||
expect(msg.type).toBe('canvas_cleared');
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
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 }) => {
|
||||
await request.delete(`${API}/api/elements/clear`);
|
||||
await resetCanvas(request);
|
||||
});
|
||||
|
||||
// ─── Fix 3: Hello handshake → real-time sync works immediately ──
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
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 }) => {
|
||||
await request.delete(`${API}/api/elements/clear`);
|
||||
await resetCanvas(request);
|
||||
});
|
||||
|
||||
// ─── 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);
|
||||
const clearBody = await clearRes.json();
|
||||
expect(clearBody.count).toBe(2);
|
||||
@@ -150,7 +160,7 @@ test.describe('Real-time Canvas Sync', () => {
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await request.delete(`${API}/api/elements/clear`);
|
||||
await request.delete(`${API}/api/elements/clear?confirm=true`);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const listRes = await request.get(`${API}/api/elements`);
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
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 }) => {
|
||||
await request.delete(`${API}/api/elements/clear`);
|
||||
await resetCanvas(request);
|
||||
});
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────
|
||||
@@ -357,7 +367,7 @@ test.describe('Clear canvas persistence', () => {
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Clear
|
||||
await request.delete(`${API}/api/elements/clear`);
|
||||
await request.delete(`${API}/api/elements/clear?confirm=true`);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify gone
|
||||
@@ -394,7 +404,7 @@ test.describe('Snapshots E2E', () => {
|
||||
expect((await snapRes.json()).success).toBe(true);
|
||||
|
||||
// Clear
|
||||
await request.delete(`${API}/api/elements/clear`);
|
||||
await request.delete(`${API}/api/elements/clear?confirm=true`);
|
||||
expect(await getServerElementCount(request)).toBe(0);
|
||||
|
||||
// List snapshots
|
||||
|
||||
Reference in New Issue
Block a user