Merge pull request #1 from celstnblacc/feat/gauntlet-hardening
feat(security): harden canvas server with auth, rate-limiting, and validation
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+15
-15
@@ -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') }}
|
||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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,14 +52,14 @@ 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 }}
|
||||||
@@ -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,14 +90,14 @@ 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 }}
|
||||||
@@ -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: |
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ public/dist/
|
|||||||
|
|
||||||
# Environment files
|
# Environment files
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
secrets.json
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.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 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Generated
+383
-339
File diff suppressed because it is too large
Load Diff
+8
-6
@@ -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": {
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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' }) }],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 ──
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user