From 5539235004f6b1471d8b78502b4a111f4e2aa679 Mon Sep 17 00:00:00 2001 From: newblacc Date: Sun, 29 Mar 2026 16:06:04 +0200 Subject: [PATCH 1/2] feat(security): harden canvas server with auth, rate-limiting, and validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add security.ts: helmet, CORS allowlist, timing-safe API key auth, prototype pollution guard, Mermaid input limits, rate limiting (general/destructive/burst) - WS auth challenge-response with 5 s timeout and close code 4001 - Fix sync crash: array check before logger access (500 → 400) - Fix sync/v2: validate element type before write (invalid → 400) - Upgrade zod 3.22.4 → 3.25.5 (fixes ERR_PACKAGE_PATH_NOT_EXPORTED on startup) - Extract ElementSharedFieldsSchema; move VALID_ELEMENT_TYPES to module level - Docker: resource limits, .dockerignore hardening - Add .project-hooks/pre-commit; expand test coverage (369 tests) Co-Authored-By: Claude Sonnet 4.6 --- .dockerignore | 8 + .github/workflows/ci.yml | 34 +- .github/workflows/docker.yml | 24 +- .github/workflows/npm-publish.yml | 4 +- .github/workflows/release.yml | 24 +- .gitignore | 6 + .project-hooks/pre-commit | 12 + CHANGELOG.md | 36 + Dockerfile.canvas | 3 + README.md | 56 +- docker-compose.yml | 18 + frontend/src/App.tsx | 149 ++-- package-lock.json | 722 +++++++++++--------- package.json | 14 +- playwright.config.ts | 8 +- src/db.ts | 10 + src/index.ts | 7 +- src/security.ts | 213 ++++++ src/server.ts | 375 ++++++---- src/types.ts | 17 +- tests/backend/api.test.ts | 4 +- tests/backend/mcp-tools-integration.test.ts | 10 +- tests/backend/security.test.ts | 30 + tests/backend/sync-flows.test.ts | 113 ++- tests/backend/tenant-isolation.test.ts | 2 +- tests/backend/ws.test.ts | 2 +- tests/e2e/bugfixes.spec.ts | 14 +- tests/e2e/canvas.spec.ts | 18 +- tests/e2e/sync-flows.spec.ts | 18 +- 29 files changed, 1287 insertions(+), 664 deletions(-) create mode 100755 .project-hooks/pre-commit create mode 100644 CHANGELOG.md create mode 100644 src/security.ts diff --git a/.dockerignore b/.dockerignore index 6e2f448..06c68c5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -30,6 +30,7 @@ coverage .nyc_output *.test.ts *.spec.ts +tests # CI/CD .github @@ -49,6 +50,13 @@ docker-compose*.yml .env.local .env.*.local +# Sensitive key material +*.pem +*.key +*.p12 +*.pfx +*.crt + # Misc tmp temp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd62996..8529152 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: outputs: should_test: ${{ steps.filter.outputs.should_test }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 @@ -42,16 +42,16 @@ jobs: if: needs.check-changes.outputs.should_test == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20.x' - name: Cache node_modules id: cache-nm - uses: actions/cache@v4 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: path: node_modules key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }} @@ -64,7 +64,7 @@ jobs: run: npm run build - name: Upload build output - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v4.6.2 with: name: dist path: dist/ @@ -75,15 +75,15 @@ jobs: needs: setup runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20.x' - name: Restore node_modules from cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: path: node_modules key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }} @@ -96,15 +96,15 @@ jobs: needs: setup runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20.x' - name: Restore node_modules from cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: path: node_modules key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }} @@ -117,21 +117,21 @@ jobs: needs: setup runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20.x' - name: Restore node_modules from cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: path: node_modules key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }} - name: Download build output - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: dist path: dist/ @@ -148,7 +148,7 @@ jobs: - name: Cache Playwright browsers id: cache-pw - uses: actions/cache@v4 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} @@ -168,7 +168,7 @@ jobs: - name: Upload Playwright report if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v4.6.2 with: name: playwright-report path: playwright-report/ diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index dc23399..2a9360e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -27,7 +27,7 @@ jobs: outputs: should_build: ${{ steps.filter.outputs.should_build }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 @@ -52,21 +52,21 @@ jobs: if: needs.check-changes.outputs.should_build == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 - name: Log in to Docker Hub if: github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true' - uses: docker/login-action@v3 + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@902fa8ec7d6ecbea8d2b4be1c9f4f0fc4a38bf1a # v5.7.0 with: images: ${{ env.IMAGE_NAME_MCP }} tags: | @@ -74,7 +74,7 @@ jobs: type=sha,prefix=sha- - name: Build MCP Server image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v5.5.0 with: context: . file: ./Dockerfile @@ -90,21 +90,21 @@ jobs: if: needs.check-changes.outputs.should_build == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 - name: Log in to Docker Hub if: github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true' - uses: docker/login-action@v3 + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@902fa8ec7d6ecbea8d2b4be1c9f4f0fc4a38bf1a # v5.7.0 with: images: ${{ env.IMAGE_NAME_CANVAS }} tags: | @@ -112,7 +112,7 @@ jobs: type=sha,prefix=sha- - name: Build Canvas Server image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v5.5.0 with: context: . file: ./Dockerfile.canvas @@ -128,7 +128,7 @@ jobs: if: needs.build-mcp.result == 'success' && needs.build-canvas.result == 'success' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Build and test Canvas image locally run: | diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 549cb7b..d652358 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -24,10 +24,10 @@ jobs: id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20.x' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d0c25f..08021cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: should_release: ${{ steps.bump.outputs.should_release }} prev_tag: ${{ steps.bump.outputs.prev_tag }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 @@ -128,12 +128,12 @@ jobs: steps: - name: Generate release bot token id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@c1a285145b9d317df6ced56c550f5b5e3e8cd3f9 # v1.11.6 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} @@ -156,7 +156,7 @@ jobs: git push origin "v${{ needs.check.outputs.new_version }}" - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@da05d552573ad5aba36ea0be2ddfef1a7e5c4d12 # v2.2.2 with: token: ${{ steps.app-token.outputs.token }} tag_name: v${{ needs.check.outputs.new_version }} @@ -180,19 +180,19 @@ jobs: needs: release runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: main - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20.x' registry-url: 'https://registry.npmjs.org' - name: Cache node_modules id: cache-nm - uses: actions/cache@v4 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: path: node_modules key: node-modules-${{ runner.os }}-node20.x-${{ hashFiles('package-lock.json') }} @@ -233,21 +233,21 @@ jobs: needs: release runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: v${{ needs.release.outputs.version }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 - name: Log in to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push MCP Server image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v5.5.0 with: context: . file: ./Dockerfile @@ -260,7 +260,7 @@ jobs: platforms: linux/amd64,linux/arm64 - name: Build and push Canvas Server image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v5.5.0 with: context: . file: ./Dockerfile.canvas diff --git a/.gitignore b/.gitignore index 7ad0d1c..02899fe 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,12 @@ public/dist/ # Environment files .env +.env.* +*.key +*.pem +*.p12 +*.pfx +secrets.json # Logs *.log diff --git a/.project-hooks/pre-commit b/.project-hooks/pre-commit new file mode 100755 index 0000000..7a471ba --- /dev/null +++ b/.project-hooks/pre-commit @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2f26b67 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/Dockerfile.canvas b/Dockerfile.canvas index bb752bf..140a01e 100644 --- a/Dockerfile.canvas +++ b/Dockerfile.canvas @@ -51,6 +51,9 @@ USER nodejs ENV NODE_ENV=production ENV PORT=3000 +# HOST=0.0.0.0 is correct inside Docker: the container binds all interfaces, +# but external access is gated by the published port mapping in docker-compose.yml. +# For local dev without Docker, the server defaults to localhost (127.0.0.1). ENV HOST=0.0.0.0 ENV EXCALIDRAW_DB_PATH=/app/data/excalidraw.db diff --git a/README.md b/README.md index e4fc9d5..d9d6cdf 100644 --- a/README.md +++ b/README.md @@ -449,6 +449,31 @@ This fork extends [yctimlin/mcp_excalidraw](https://github.com/yctimlin/mcp_exca | `EXCALIDRAW_DB_PATH` | Path to the SQLite database file | `~/.excalidraw-mcp/excalidraw.db` | | `EXCALIDRAW_EXPORT_DIR` | Allowed directory for file exports | `process.cwd()` | | `EXPRESS_SERVER_URL` | Canvas server URL (only if running canvas separately) | `http://localhost:3000` | +| `EXCALIDRAW_API_KEY` | Shared secret for API key auth on all `/api/*` routes. When unset, auth is disabled (dev mode). | _(unset — auth off)_ | +| `ALLOWED_ORIGINS` | Comma-separated list of allowed CORS + WebSocket origins | `http://localhost:3000,http://127.0.0.1:3000` | +| `EXCALIDRAW_RATE_LIMIT_GENERAL_MAX` | Override the general API rate-limit ceiling (requests per 15-minute window) | `100` | +| `EXCALIDRAW_RATE_LIMIT_DESTRUCTIVE_MAX` | Override the destructive-operation rate-limit ceiling (requests per 1-minute window) | `10` | +| `EXCALIDRAW_RATE_LIMIT_WRITE_BURST_MAX` | Override the sync write-burst rate-limit ceiling (requests per 1-minute window) | `10` | + +### Security configuration + +**Enabling API key protection** (recommended for any network-accessible deployment): + +```bash +EXCALIDRAW_API_KEY=your-secret-here node dist/server.js +``` + +All requests to `/api/*` must then include the header `X-API-Key: your-secret-here`. The `/health` endpoint is always exempt. + +When `EXCALIDRAW_API_KEY` is set, the browser canvas UI receives the key automatically: `GET /` injects `window.__EXCALIDRAW_API_KEY__` into the served HTML, so the browser's WebSocket `hello` message can include it without any manual configuration. The WebSocket handshake uses a challenge-response protocol: the server sends `{ type: "auth_required" }` immediately on connect, the client must respond with a `hello` message containing `{ apiKey: "" }` within 5 seconds, or the connection is closed (code 4001). + +**Restricting CORS origins** (e.g. if your canvas UI is on a custom domain): + +```bash +ALLOWED_ORIGINS=https://canvas.example.com,http://localhost:3000 node dist/server.js +``` + +This controls both REST CORS responses and WebSocket `Origin` verification. Requests with no `Origin` header (MCP stdio, curl, server-side tools) are always allowed. ## Multi-Tenancy (Workspaces) @@ -504,8 +529,8 @@ cp -R skills/excalidraw-skill ~/.codex/skills/excalidraw-skill |---|---| | **Element CRUD** | `create_element`, `get_element`, `update_element`, `delete_element`, `query_elements`, `batch_create_elements`, `duplicate_elements` | | **Layout** | `align_elements`, `distribute_elements`, `group_elements`, `ungroup_elements`, `lock_elements`, `unlock_elements` | -| **Scene Awareness** | `describe_scene`, `get_canvas_screenshot` | -| **File I/O** | `export_scene`, `import_scene`, `export_to_image`, `export_to_excalidraw_url`, `create_from_mermaid` | +| **Scene Awareness** | `describe_scene`, `get_canvas_screenshot` ⚠️ | +| **File I/O** | `export_scene`, `import_scene`, `export_to_image` ⚠️, `export_to_excalidraw_url`, `create_from_mermaid` | | **State Management** | `clear_canvas`, `snapshot_scene`, `restore_snapshot` | | **Viewport** | `set_viewport` | | **Design Guide** | `read_diagram_guide` | @@ -516,6 +541,8 @@ cp -R skills/excalidraw-skill ~/.codex/skills/excalidraw-skill Full schemas are discoverable via `tools/list` or in `skills/excalidraw-skill/references/cheatsheet.md`. +> ⚠️ **Requires open browser:** `get_canvas_screenshot` and `export_to_image` rely on the frontend rendering pipeline. The canvas UI must be open in a browser tab at `http://localhost:3000` for these tools to work. They return HTTP 503 if no browser is connected. + ## Testing ### Health check @@ -661,20 +688,37 @@ The canvas server exposes a REST API alongside the WebSocket interface: | Method | Endpoint | Description | |--------|----------|-------------| -| GET | `/health` | Health check | +| GET | `/health` | Health check (auth-exempt) | | GET | `/api/elements` | List all elements | | POST | `/api/elements` | Create an element | +| GET | `/api/elements/search` | Search elements (`?q=term` for FTS, `?type=rectangle` for filter) | +| GET | `/api/elements/:id` | Get element by ID | | PUT | `/api/elements/:id` | Update an element | | DELETE | `/api/elements/:id` | Delete an element | -| DELETE | `/api/elements/clear` | Clear all elements | -| POST | `/api/elements/sync` | Sync all elements (bulk upsert) | +| DELETE | `/api/elements/clear` | Clear all elements (requires `?confirm=true`) | +| POST | `/api/elements/batch` | Batch create elements | +| POST | `/api/elements/from-mermaid` | Convert Mermaid diagram and broadcast to canvas | +| POST | `/api/elements/sync` | Bulk-replace all elements (canvas → server) | +| POST | `/api/elements/sync/v2` | Delta sync (changes since `lastSyncVersion`) | +| GET | `/api/sync/version` | Current sync version for a project | +| GET | `/api/sync/status` | Sync status (element count, memory usage) | +| GET | `/api/files` | List image files (in-memory) | +| POST | `/api/files` | Add image files | +| DELETE | `/api/files/:id` | Delete an image file | +| POST | `/api/export/image` | Request image export (requires open browser tab) | +| POST | `/api/export/image/result` | Deliver export result from frontend | +| POST | `/api/viewport` | Set canvas viewport (requires open browser tab) | +| POST | `/api/viewport/result` | Deliver viewport result from frontend | +| POST | `/api/snapshots` | Save a named snapshot | +| GET | `/api/snapshots` | List snapshots | +| GET | `/api/snapshots/:name` | Get snapshot by name | | GET | `/api/tenants` | List all tenants | | GET | `/api/tenant/active` | Get the active tenant | | PUT | `/api/tenant/active` | Set the active tenant | | GET | `/api/settings/:key` | Read a setting | | PUT | `/api/settings/:key` | Write a setting | -All endpoints accept an `X-Tenant-Id` header for per-request tenant scoping. +All endpoints accept an `X-Tenant-Id` header for per-request tenant scoping. When `EXCALIDRAW_API_KEY` is set, all `/api/*` endpoints require `X-API-Key: ` (see [Security configuration](#security-configuration)). ## Credits diff --git a/docker-compose.yml b/docker-compose.yml index d0f2a4d..d9e4929 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,8 +22,14 @@ services: environment: - NODE_ENV=production - PORT=3000 + # HOST=0.0.0.0 is intentional in Docker — the container's port is + # exposed only via the published port mapping above. For local dev + # without Docker, the default is localhost (set in src/server.ts). - HOST=0.0.0.0 - DEBUG=false + # Optional: set to enable API key auth on all /api/* routes. + # Must match EXCALIDRAW_API_KEY in the mcp service below so inter-service calls succeed. + - EXCALIDRAW_API_KEY=${EXCALIDRAW_API_KEY:-} restart: unless-stopped healthcheck: test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"] @@ -31,6 +37,11 @@ services: timeout: 10s retries: 3 start_period: 40s + deploy: + resources: + limits: + cpus: '1.0' + memory: 512M networks: - mcp-network @@ -49,9 +60,16 @@ services: - EXPRESS_SERVER_URL=http://canvas:3000 - ENABLE_CANVAS_SYNC=true - DEBUG=false + # Must match canvas EXCALIDRAW_API_KEY so inter-service sync calls are authenticated. + - EXCALIDRAW_API_KEY=${EXCALIDRAW_API_KEY:-} depends_on: canvas: condition: service_healthy + deploy: + resources: + limits: + cpus: '0.5' + memory: 256M networks: - mcp-network profiles: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dd13a15..eb01262 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -53,11 +53,21 @@ interface TenantInfo { workspace_path: string; } +declare global { + interface Window { + __EXCALIDRAW_API_KEY__?: string; + } +} + +const WS_AUTH_CLOSE_CODE = 4001 +const browserApiKey = typeof window !== 'undefined' ? window.__EXCALIDRAW_API_KEY__ : undefined + function App(): JSX.Element { const [excalidrawAPI, setExcalidrawAPI] = useState(null) const excalidrawAPIRef = useRef(null) const [isConnected, setIsConnected] = useState(false) const websocketRef = useRef(null) + const reconnectEnabledRef = useRef(true) // Sync state const [syncStatus, setSyncStatus] = useState('idle') @@ -101,6 +111,7 @@ function App(): JSX.Element { } const tid = activeTenantIdRef.current if (tid) headers['X-Tenant-Id'] = tid + if (browserApiKey) headers['X-API-Key'] = browserApiKey return headers } @@ -244,6 +255,9 @@ function App(): JSX.Element { } const connectWebSocket = (): void => { + if (!reconnectEnabledRef.current) { + return + } if (websocketRef.current && websocketRef.current.readyState === WebSocket.OPEN) { return } @@ -274,7 +288,7 @@ function App(): JSX.Element { setIsConnected(false) // Reconnect after 3 seconds if not a clean close - if (event.code !== 1000) { + if (event.code !== 1000 && event.code !== WS_AUTH_CLOSE_CODE && reconnectEnabledRef.current) { setTimeout(connectWebSocket, 3000) } } @@ -285,10 +299,13 @@ function App(): JSX.Element { } } - const sendHello = (tenantId: string): void => { + const sendHello = (tenantId?: string): void => { const ws = websocketRef.current if (!ws || ws.readyState !== WebSocket.OPEN) return - ws.send(JSON.stringify({ type: 'hello', tenantId })) + const message: Record = { type: 'hello' } + if (tenantId) message.tenantId = tenantId + if (browserApiKey) message.apiKey = browserApiKey + ws.send(JSON.stringify(message)) } const sendAck = (msgId: string | undefined, status: 'applied' | 'partial' | 'failed', elementCount?: number, expectedCount?: number): void => { @@ -358,6 +375,85 @@ function App(): JSX.Element { } const handleWebSocketMessage = async (data: WebSocketMessage): Promise => { + 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() + for (const el of data.elements) { + helloBaseline.set(el.id, el) + } + lastSyncedElementsRef.current = helloBaseline + } else if (Array.isArray(data.elements)) { + api.updateScene({ + elements: [], + captureUpdate: CaptureUpdateAction.NEVER + }) + lastSyncedElementsRef.current = new Map() + lastSyncedHashRef.current = '' + } + return + } + } + // Gap detection (Task 12): if a message carries sync_version, check for gaps if (data.sync_version !== undefined && typeof data.sync_version === 'number') { const expected = lastReceivedSyncVersionRef.current + 1 @@ -714,46 +810,6 @@ function App(): JSX.Element { case 'file_deleted': break - case 'tenant_switched': - console.log('Tenant switched:', data.tenant) - if (data.tenant) { - const incoming = data.tenant as TenantInfo - // Send hello to register WS connection under the correct tenant scope - sendHello(incoming.id) - if (incoming.id !== activeTenantIdRef.current) { - activeTenantIdRef.current = incoming.id - setActiveTenant(incoming) - api.updateScene({ - elements: [], - captureUpdate: CaptureUpdateAction.NEVER - }) - lastSyncedHashRef.current = '' - loadExistingElements() - } else { - setActiveTenant(incoming) - } - } - break - - case 'hello_ack': - console.log('Hello acknowledged by server:', data.tenantId, data.projectId) - if (data.elements && Array.isArray(data.elements) && data.elements.length > 0) { - const converted = convertToExcalidrawElements(data.elements) - api.updateScene({ - elements: converted, - captureUpdate: CaptureUpdateAction.NEVER - }) - // Update sync baseline for deletion detection - const helloBaseline = new Map() - for (const el of data.elements) { - helloBaseline.set(el.id, el) - } - lastSyncedElementsRef.current = helloBaseline - } else if (data.elements && data.elements.length === 0) { - lastSyncedElementsRef.current = new Map() - } - break - default: console.log('Unknown WebSocket message type:', data.type) } @@ -875,10 +931,7 @@ function App(): JSX.Element { // Load elements for the newly-active tenant const elemRes = await fetch('/api/elements', { - headers: { - 'Content-Type': 'application/json', - 'X-Tenant-Id': tenantId - } + headers: tenantHeaders({ 'X-Tenant-Id': tenantId }) }) const result: ApiResponse = await elemRes.json() if (result.success && result.elements && result.elements.length > 0) { @@ -996,7 +1049,7 @@ function App(): JSX.Element { // Load "skip confirm" preference from backend on mount useEffect(() => { - fetch('/api/settings/clear_canvas_skip_confirm') + fetch('/api/settings/clear_canvas_skip_confirm', { headers: tenantHeaders() }) .then(r => r.json()) .then(data => { if (data.value === 'true') setClearSkipConfirm(true) diff --git a/package-lock.json b/package-lock.json index 465fb34..bfe742a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,23 +7,24 @@ "": { "name": "@sanjibdevnath/mcp-excalidraw-local", "version": "1.6.2", - "hasInstallScript": true, "license": "MIT", "dependencies": { "@excalidraw/excalidraw": "^0.18.0", "@excalidraw/mermaid-to-excalidraw": "^1.1.3", - "@modelcontextprotocol/sdk": "^1.26.0", - "better-sqlite3": "^12.6.2", - "cors": "^2.8.5", + "@modelcontextprotocol/sdk": "1.26.0", + "better-sqlite3": "12.6.2", + "cors": "2.8.5", "dotenv": "^16.3.1", - "express": "^4.18.2", + "express": "4.22.1", + "express-rate-limit": "8.3.1", + "helmet": "8.1.0", "mermaid": "^11.12.1", "node-fetch": "^3.3.2", "react": "^18.3.1", "react-dom": "^18.3.1", "winston": "^3.11.0", - "ws": "^8.14.2", - "zod": "^3.22.4", + "ws": "8.20.0", + "zod": "3.25.5", "zod-to-json-schema": "^3.22.3" }, "bin": { @@ -48,7 +49,7 @@ "vitest": "^4.1.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@ampproject/remapping": { @@ -395,42 +396,42 @@ "license": "MIT" }, "node_modules/@chevrotain/cst-dts-gen": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", - "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", + "integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/gast": "11.0.3", - "@chevrotain/types": "11.0.3", - "lodash-es": "4.17.21" + "@chevrotain/gast": "11.1.2", + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" } }, "node_modules/@chevrotain/gast": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", - "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz", + "integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/types": "11.0.3", - "lodash-es": "4.17.21" + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" } }, "node_modules/@chevrotain/regexp-to-ast": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", - "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz", + "integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==", "license": "Apache-2.0" }, "node_modules/@chevrotain/types": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", - "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", "license": "Apache-2.0" }, "node_modules/@chevrotain/utils": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", - "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz", + "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==", "license": "Apache-2.0" }, "node_modules/@colors/colors": { @@ -1037,9 +1038,10 @@ "license": "MIT" }, "node_modules/@excalidraw/mermaid-to-excalidraw": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.3.tgz", - "integrity": "sha512-/50GUWlGotc+FCMX7nM1P1kWm9vNd3fuq38v7upBp9IHqlw6Zmfyj79eG/0vz1heifuYrSW9yzzv0q9jVALzxg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.4.tgz", + "integrity": "sha512-RfuNMLLBhlqOUqRmB9LuuXEhD7AKGXJJOSrjcDHBOxnuY9E9Rlj5HGCqTLZfEu7NWzpTb+24Sq8BORoKhMXaPw==", + "license": "MIT", "dependencies": { "@excalidraw/markdown-to-text": "0.1.2", "mermaid": "10.9.4", @@ -1243,18 +1245,18 @@ } }, "node_modules/@mermaid-js/parser": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz", - "integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.1.tgz", + "integrity": "sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==", "license": "MIT", "dependencies": { - "langium": "3.3.1" + "langium": "^4.0.0" } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.27.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", - "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", @@ -1423,26 +1425,6 @@ "node": ">= 0.8" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -1574,15 +1556,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -1597,6 +1570,15 @@ "node": ">= 0.6" } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -2412,9 +2394,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.2.tgz", - "integrity": "sha512-g0dF8P1e2QYPOj1gu7s/3LVP6kze9A7m6x0BZ9iTdXK8N5c2V7cpBKHV3/9A4Zd8xxavdhK0t4PnqjkqVmUc9Q==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", "cpu": [ "arm" ], @@ -2426,9 +2408,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.2.tgz", - "integrity": "sha512-Yt5MKrOosSbSaAK5Y4J+vSiID57sOvpBNBR6K7xAaQvk3MkcNVV0f9fE20T+41WYN8hDn6SGFlFrKudtx4EoxA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", "cpu": [ "arm64" ], @@ -2440,9 +2422,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.2.tgz", - "integrity": "sha512-EsnFot9ZieM35YNA26nhbLTJBHD0jTwWpPwmRVDzjylQT6gkar+zenfb8mHxWpRrbn+WytRRjE0WKsfaxBkVUA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", "cpu": [ "arm64" ], @@ -2454,9 +2436,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.2.tgz", - "integrity": "sha512-dv/t1t1RkCvJdWWxQ2lWOO+b7cMsVw5YFaS04oHpZRWehI1h0fV1gF4wgGCTyQHHjJDfbNpwOi6PXEafRBBezw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", "cpu": [ "x64" ], @@ -2468,9 +2450,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.2.tgz", - "integrity": "sha512-W4tt4BLorKND4qeHElxDoim0+BsprFTwb+vriVQnFFtT/P6v/xO5I99xvYnVzKWrK6j7Hb0yp3x7V5LUbaeOMg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", "cpu": [ "arm64" ], @@ -2482,9 +2464,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.2.tgz", - "integrity": "sha512-tdT1PHopokkuBVyHjvYehnIe20fxibxFCEhQP/96MDSOcyjM/shlTkZZLOufV3qO6/FQOSiJTBebhVc12JyPTA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", "cpu": [ "x64" ], @@ -2496,9 +2478,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.2.tgz", - "integrity": "sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", "cpu": [ "arm" ], @@ -2510,9 +2492,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.2.tgz", - "integrity": "sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", "cpu": [ "arm" ], @@ -2524,9 +2506,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.2.tgz", - "integrity": "sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", "cpu": [ "arm64" ], @@ -2538,9 +2520,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.2.tgz", - "integrity": "sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", "cpu": [ "arm64" ], @@ -2551,10 +2533,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.2.tgz", - "integrity": "sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", "cpu": [ "loong64" ], @@ -2565,10 +2547,38 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.2.tgz", - "integrity": "sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw==", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", "cpu": [ "ppc64" ], @@ -2580,9 +2590,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.2.tgz", - "integrity": "sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", "cpu": [ "riscv64" ], @@ -2594,9 +2604,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.2.tgz", - "integrity": "sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", "cpu": [ "riscv64" ], @@ -2608,9 +2618,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.2.tgz", - "integrity": "sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", "cpu": [ "s390x" ], @@ -2622,9 +2632,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.2.tgz", - "integrity": "sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", "cpu": [ "x64" ], @@ -2636,9 +2646,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.2.tgz", - "integrity": "sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", "cpu": [ "x64" ], @@ -2649,10 +2659,38 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.2.tgz", - "integrity": "sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", "cpu": [ "arm64" ], @@ -2664,9 +2702,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.2.tgz", - "integrity": "sha512-+qMUrkbUurpE6DVRjiJCNGZBGo9xM4Y0FXU5cjgudWqIBWbcLkjE3XprJUsOFgC6xjBClwVa9k6O3A7K3vxb5Q==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", "cpu": [ "ia32" ], @@ -2677,10 +2715,24 @@ "win32" ] }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.2.tgz", - "integrity": "sha512-3+QZROYfJ25PDcxFF66UEk8jGWigHJeecZILvkPkyQN7oc5BvFo4YEXFkOs154j3FTMp9mn9Ky8RCOwastduEA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", "cpu": [ "x64" ], @@ -3160,7 +3212,7 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/qs": { @@ -3181,7 +3233,7 @@ "version": "18.3.23", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -3192,7 +3244,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -3274,6 +3326,16 @@ "@types/node": "*" } }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz", @@ -3670,23 +3732,23 @@ } }, "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", + "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", "type-is": "~1.6.18", - "unpipe": "1.0.0" + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8", @@ -3721,15 +3783,15 @@ "license": "MIT" }, "node_modules/body-parser/node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8" @@ -3913,17 +3975,17 @@ } }, "node_modules/chevrotain": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", - "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz", + "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/cst-dts-gen": "11.0.3", - "@chevrotain/gast": "11.0.3", - "@chevrotain/regexp-to-ast": "11.0.3", - "@chevrotain/types": "11.0.3", - "@chevrotain/utils": "11.0.3", - "lodash-es": "4.17.21" + "@chevrotain/cst-dts-gen": "11.1.2", + "@chevrotain/gast": "11.1.2", + "@chevrotain/regexp-to-ast": "11.1.2", + "@chevrotain/types": "11.1.2", + "@chevrotain/utils": "11.1.2", + "lodash-es": "4.17.23" } }, "node_modules/chevrotain-allstar": { @@ -4265,13 +4327,13 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/cytoscape": { - "version": "3.32.1", - "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.32.1.tgz", - "integrity": "sha512-dbeqFTLYEwlFg7UGtcZhCCG/2WayX72zK3Sq323CEX29CY81tYfVhw1MIdduCtpstB0cTOhJswWlM/OEB3Xp+Q==", + "version": "3.33.1", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", + "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", "engines": { "node": ">=0.10" @@ -4758,9 +4820,9 @@ } }, "node_modules/dagre-d3-es": { - "version": "7.0.13", - "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz", - "integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==", + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", "license": "MIT", "dependencies": { "d3": "^7.9.0", @@ -4910,18 +4972,18 @@ } }, "node_modules/diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } }, "node_modules/dompurify": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", - "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -5183,39 +5245,39 @@ } }, "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.13.0", + "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", + "send": "~0.19.0", + "serve-static": "~1.16.2", "setprototypeof": "1.2.0", - "statuses": "2.0.1", + "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" @@ -5344,17 +5406,17 @@ } }, "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", "license": "MIT", "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "statuses": "2.0.1", + "statuses": "~2.0.2", "unpipe": "~1.0.0" }, "engines": { @@ -5651,6 +5713,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/hono": { "version": "4.12.7", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", @@ -5668,19 +5739,23 @@ "license": "MIT" }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/iconv-lite": { @@ -5725,9 +5800,9 @@ } }, "node_modules/immutable": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", - "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz", + "integrity": "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==", "license": "MIT" }, "node_modules/inherits": { @@ -5969,9 +6044,9 @@ } }, "node_modules/katex": { - "version": "0.16.22", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", - "integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==", + "version": "0.16.44", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.44.tgz", + "integrity": "sha512-EkxoDTk8ufHqHlf9QxGwcxeLkWRR3iOuYfRpfORgYfqc8s13bgb+YtRY59NK5ZpRaCwq1kqA6a5lpX8C/eLphQ==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -6020,19 +6095,20 @@ "license": "MIT" }, "node_modules/langium": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz", - "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz", + "integrity": "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==", "license": "MIT", "dependencies": { - "chevrotain": "~11.0.3", - "chevrotain-allstar": "~0.3.0", + "chevrotain": "~11.1.1", + "chevrotain-allstar": "~0.3.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", - "vscode-uri": "~3.0.8" + "vscode-uri": "~3.1.0" }, "engines": { - "node": ">=16.0.0" + "node": ">=20.10.0", + "npm": ">=10.2.3" } }, "node_modules/layout-base": { @@ -6059,16 +6135,16 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", "license": "MIT" }, "node_modules/lodash.debounce": { @@ -6250,27 +6326,28 @@ } }, "node_modules/mermaid": { - "version": "11.12.1", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.1.tgz", - "integrity": "sha512-UlIZrRariB11TY1RtTgUWp65tphtBv4CSq7vyS2ZZ2TgoMjs2nloq+wFqxiwcxlhHUvs7DPGgMjs2aeQxz5h9g==", + "version": "11.13.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.13.0.tgz", + "integrity": "sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==", "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.1", - "@iconify/utils": "^3.0.1", - "@mermaid-js/parser": "^0.6.3", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.0.1", "@types/d3": "^7.4.3", - "cytoscape": "^3.29.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", - "dagre-d3-es": "7.0.13", - "dayjs": "^1.11.18", - "dompurify": "^3.2.5", - "katex": "^0.16.22", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "katex": "^0.16.25", "khroma": "^2.1.0", - "lodash-es": "^4.17.21", - "marked": "^16.2.1", + "lodash-es": "^4.17.23", + "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", @@ -7082,9 +7159,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/pathe": { @@ -7120,9 +7197,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -7360,12 +7437,12 @@ "license": "Apache-2.0" }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -7414,26 +7491,6 @@ "node": ">= 0.10" } }, - "node_modules/raw-body/node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/raw-body/node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -7450,15 +7507,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/raw-body/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -7649,9 +7697,9 @@ "license": "Unlicense" }, "node_modules/rollup": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.2.tgz", - "integrity": "sha512-PVoapzTwSEcelaWGth3uR66u7ZRo6qhPHc0f2uRO9fX6XDVNrIiGYS0Pj9+R8yIIYSD/mCx2b16Ws9itljKSPg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7665,26 +7713,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.44.2", - "@rollup/rollup-android-arm64": "4.44.2", - "@rollup/rollup-darwin-arm64": "4.44.2", - "@rollup/rollup-darwin-x64": "4.44.2", - "@rollup/rollup-freebsd-arm64": "4.44.2", - "@rollup/rollup-freebsd-x64": "4.44.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.44.2", - "@rollup/rollup-linux-arm-musleabihf": "4.44.2", - "@rollup/rollup-linux-arm64-gnu": "4.44.2", - "@rollup/rollup-linux-arm64-musl": "4.44.2", - "@rollup/rollup-linux-loongarch64-gnu": "4.44.2", - "@rollup/rollup-linux-powerpc64le-gnu": "4.44.2", - "@rollup/rollup-linux-riscv64-gnu": "4.44.2", - "@rollup/rollup-linux-riscv64-musl": "4.44.2", - "@rollup/rollup-linux-s390x-gnu": "4.44.2", - "@rollup/rollup-linux-x64-gnu": "4.44.2", - "@rollup/rollup-linux-x64-musl": "4.44.2", - "@rollup/rollup-win32-arm64-msvc": "4.44.2", - "@rollup/rollup-win32-ia32-msvc": "4.44.2", - "@rollup/rollup-win32-x64-msvc": "4.44.2", + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" } }, @@ -7723,9 +7776,9 @@ } }, "node_modules/router/node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", + "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", "license": "MIT", "funding": { "type": "opencollective", @@ -7832,24 +7885,24 @@ } }, "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "license": "MIT", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "range-parser": "~1.2.1", - "statuses": "2.0.1" + "statuses": "~2.0.2" }, "engines": { "node": ">= 0.8.0" @@ -7870,25 +7923,16 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "license": "MIT", "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.19.0" + "send": "~0.19.1" }, "engines": { "node": ">= 0.8.0" @@ -8111,9 +8155,9 @@ "license": "MIT" }, "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -8352,9 +8396,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -8741,9 +8785,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -8836,9 +8880,9 @@ } }, "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -8892,9 +8936,9 @@ "license": "MIT" }, "node_modules/vscode-uri": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", - "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "license": "MIT" }, "node_modules/web-streams-polyfill": { @@ -9011,9 +9055,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -9078,9 +9122,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "3.25.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.5.tgz", + "integrity": "sha512-ualArhgJydGAKkSdtxQyu6RXFW8nHFKWaw20jey8UFXU9uzkHYqWXJ93Iz+hUVVJb37VpF4LCCsOOfj6xaCVRQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index ca4231d..c8e0f95 100644 --- a/package.json +++ b/package.json @@ -32,18 +32,20 @@ "dependencies": { "@excalidraw/excalidraw": "^0.18.0", "@excalidraw/mermaid-to-excalidraw": "^1.1.3", - "@modelcontextprotocol/sdk": "^1.26.0", - "better-sqlite3": "^12.6.2", - "cors": "^2.8.5", + "@modelcontextprotocol/sdk": "1.26.0", + "better-sqlite3": "12.6.2", + "cors": "2.8.5", "dotenv": "^16.3.1", - "express": "^4.18.2", + "express": "4.22.1", + "express-rate-limit": "8.3.1", + "helmet": "8.1.0", "mermaid": "^11.12.1", "node-fetch": "^3.3.2", "react": "^18.3.1", "react-dom": "^18.3.1", "winston": "^3.11.0", - "ws": "^8.14.2", - "zod": "^3.22.4", + "ws": "8.20.0", + "zod": "3.25.5", "zod-to-json-schema": "^3.22.3" }, "devDependencies": { diff --git a/playwright.config.ts b/playwright.config.ts index 25b821b..0b06335 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ workers: 1, reporter: 'list', use: { - baseURL: 'http://localhost:3100', + baseURL: 'http://127.0.0.1:3100', trace: 'on-first-retry', }, projects: [ @@ -25,8 +25,12 @@ export default defineConfig({ reuseExistingServer: !process.env.CI, env: { CANVAS_PORT: '3100', - HOST: 'localhost', + HOST: '127.0.0.1', EXCALIDRAW_DB_PATH: '/tmp/excalidraw-e2e-test.db', + ALLOWED_ORIGINS: 'http://127.0.0.1:3100,http://localhost:3100,http://localhost:3000,http://127.0.0.1:3000', + EXCALIDRAW_RATE_LIMIT_GENERAL_MAX: '10000', + EXCALIDRAW_RATE_LIMIT_DESTRUCTIVE_MAX: '10000', + EXCALIDRAW_RATE_LIMIT_WRITE_BURST_MAX: '10000', }, }, }); diff --git a/src/db.ts b/src/db.ts index e3941d1..3d8731c 100644 --- a/src/db.ts +++ b/src/db.ts @@ -512,6 +512,10 @@ export function getActiveTenant(): Tenant { return db.prepare('SELECT * FROM tenants WHERE id = ?').get(activeTenantId) as Tenant; } +export function getTenantById(id: string): Tenant | undefined { + return db.prepare('SELECT * FROM tenants WHERE id = ?').get(id) as Tenant | undefined; +} + export function getActiveTenantId(): string { return activeTenantId; } @@ -535,6 +539,12 @@ export function listProjects(): Project[] { return db.prepare('SELECT * FROM projects WHERE tenant_id = ? ORDER BY updated_at DESC').all(activeTenantId) as Project[]; } +export function getProjectForTenant(projectId: string, tenantId: string): Project | undefined { + return db.prepare( + 'SELECT * FROM projects WHERE id = ? AND tenant_id = ?' + ).get(projectId, tenantId) as Project | undefined; +} + export function setActiveProject(id: string): void { const project = db.prepare('SELECT id, tenant_id FROM projects WHERE id = ?').get(id) as { id: string; tenant_id: string } | undefined; if (!project) throw new Error(`Project "${id}" not found`); diff --git a/src/index.ts b/src/index.ts index e2573f0..c915136 100644 --- a/src/index.ts +++ b/src/index.ts @@ -137,11 +137,16 @@ interface SyncResponse { } function canvasHeaders(extra?: Record): Record { - return { + const headers: Record = { 'Content-Type': 'application/json', 'X-Tenant-Id': dbGetActiveTenantId(), ...extra }; + // Forward API key to canvas when auth is enabled — required for two-service + // Docker deployments where canvas runs with EXCALIDRAW_API_KEY set. + const apiKey = process.env.EXCALIDRAW_API_KEY; + if (apiKey) headers['X-API-Key'] = apiKey; + return headers; } // Helper functions to sync with Express server (canvas) diff --git a/src/security.ts b/src/security.ts new file mode 100644 index 0000000..64b8f4d --- /dev/null +++ b/src/security.ts @@ -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)[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; +} diff --git a/src/server.ts b/src/server.ts index 4a8afbc..db90b36 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,6 @@ import express, { type Application, Request, Response, NextFunction } from 'express'; -import cors from 'cors'; import { WebSocketServer } from 'ws'; +import { helmetMiddleware, corsMiddleware, apiKeyAuth, verifyWsClient, generalRateLimit, destructiveRateLimit, writeBurstLimit, requireConfirm, sanitizeBody, validateMermaidInput, isAuthEnabled, validateApiKey, sanitizeSearchQuery, InvalidSearchQueryError } from './security.js'; import { createServer } from 'http'; import fs from 'fs'; import path from 'path'; @@ -19,6 +19,7 @@ import { BatchCreatedMessage, SyncStatusMessage, InitialElementsMessage, + HelloMessage, Snapshot, normalizeFontFamily, ExcalidrawFile, @@ -27,7 +28,7 @@ import { BroadcastResult } from './types.js'; import * as store from './db.js'; -import { initDb, listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant, getCurrentSyncVersion, getChangesSince } from './db.js'; +import { initDb, listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, getTenantById, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant, getProjectForTenant, getCurrentSyncVersion, getChangesSince } from './db.js'; import { z } from 'zod'; import WebSocket from 'ws'; @@ -39,17 +40,25 @@ const __dirname = path.dirname(__filename); const app: Application = express(); const httpServer = createServer(app); -const wss = new WebSocketServer({ server: httpServer }); +const wss = new WebSocketServer({ server: httpServer, verifyClient: verifyWsClient }); // Middleware -app.use(cors()); -app.use(express.json({ limit: '10mb' })); +app.use(helmetMiddleware); +app.use(corsMiddleware); +app.use('/api', generalRateLimit); +app.use('/api', apiKeyAuth); +// Body parsing — path-specific limits applied in order (most-specific first). +// batch/sync endpoints get 5 MB; everything else gets 100 KB. +app.use('/api/elements/batch', express.json({ limit: '5mb' })); +app.use('/api/elements/sync', express.json({ limit: '5mb' })); +app.use(express.json({ limit: '100kb' })); +app.use('/api', sanitizeBody); // Serve static files from the build directory const staticDir = path.join(__dirname, '../dist'); -app.use(express.static(staticDir)); +app.use(express.static(staticDir, { index: false })); // Also serve frontend assets -app.use(express.static(path.join(__dirname, '../dist/frontend'))); +app.use(express.static(path.join(__dirname, '../dist/frontend'), { index: false })); // Resolve tenant from X-Tenant-Id header to a projectId override. // Returns undefined when header is absent (browser requests), falling back to global state. @@ -73,6 +82,113 @@ function resolveScope(req: Request): { tenantId: string; projectId: string } { return { tenantId: tenant.id, projectId }; } +const WS_AUTH_CLOSE_CODE = 4001; +const WS_AUTH_TIMEOUT_MS = 5000; +const frontendHtmlPath = path.join(__dirname, '../dist/frontend/index.html'); + +function identifyConnection(ws: WebSocket, tenantId: string, projectId: string): void { + if (wsToConnection.has(ws)) { + moveConnection(ws, tenantId, projectId); + return; + } + + registerConnection({ + ws, + tenantId, + projectId, + connectedAt: Date.now(), + identified: true, + }); +} + +function getAllFilesObject(): Record { + const result: Record = {}; + 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(/window.__EXCALIDRAW_API_KEY__=${serialized};`; + return html.includes('') ? html.replace('', `${script}`) : `${script}${html}`; +} + // ── Connection Registry (Task 3) ────────────────────────────────────────── // Scoped by tenant → project → Set const connections = new Map>>(); @@ -248,72 +364,73 @@ function broadcast(message: WebSocketMessage): void { // ── WebSocket Connection Handling (Task 4: Hello Handshake) ─────────────── wss.on('connection', (ws: WebSocket) => { - // Register with fallback scope until hello handshake identifies the client. - const tenant = (() => { try { return dbGetActiveTenant(); } catch { return { id: 'default', name: 'default', workspace_path: '' }; } })(); - const fallbackProjectId = getDefaultProjectForTenant(tenant.id) ?? 'default'; - const conn: ClientConnection = { - ws, - tenantId: tenant.id, - projectId: fallbackProjectId, - connectedAt: Date.now(), - identified: false - }; - registerConnection(conn); - logger.info('New WebSocket connection established (awaiting hello)'); + const authEnabled = isAuthEnabled(); + let awaitingAuth = authEnabled; + let authTimer: ReturnType | null = null; - // Send tenant info so the FE knows where to send hello - ws.send(JSON.stringify({ - type: 'tenant_switched', - tenant: { id: tenant.id, name: tenant.name, workspace_path: tenant.workspace_path } - })); - - // For backward compatibility: also send initial_elements immediately. - // New FE versions will ignore this and use hello_ack instead. - const initialMessage: InitialElementsMessage = { - type: 'initial_elements', - elements: store.getAllElements(fallbackProjectId) - }; - ws.send(JSON.stringify(initialMessage)); - - // Send any stored files (image data) - if (files.size > 0) { - const allFiles: Record = {}; - for (const [id, file] of files) { - allFiles[id] = file; + const tenant = (() => { + try { + return dbGetActiveTenant(); + } catch { + return { id: 'default', name: 'default', workspace_path: '' }; } - ws.send(JSON.stringify({ type: 'files_added', files: allFiles })); - } + })(); + const fallbackProjectId = getDefaultProjectForTenant(tenant.id) ?? 'default'; - // Send sync status to new client - const syncMessage: SyncStatusMessage = { - type: 'sync_status', - elementCount: store.getElementCount(fallbackProjectId), - timestamp: new Date().toISOString() - }; - ws.send(JSON.stringify(syncMessage)); + logger.info(`New WebSocket connection established${authEnabled ? ' (awaiting auth)' : ' (legacy mode)'}`); + + if (authEnabled) { + ws.send(JSON.stringify({ type: 'auth_required' })); + authTimer = setTimeout(() => { + if (ws.readyState !== WebSocket.OPEN) return; + ws.send(JSON.stringify({ type: 'auth_failed', reason: 'timeout' })); + ws.close(WS_AUTH_CLOSE_CODE, 'Authentication required'); + }, WS_AUTH_TIMEOUT_MS); + } else { + registerConnection({ + ws, + tenantId: tenant.id, + projectId: fallbackProjectId, + connectedAt: Date.now(), + identified: false + }); + sendAuthlessInitialMessages(ws, tenant, fallbackProjectId); + } // Handle incoming messages from this client ws.on('message', (raw) => { try { const msg = JSON.parse(raw.toString()); if (msg.type === 'hello') { - const helloTenantId = msg.tenantId as string; - const helloProjectId = (msg.projectId as string) || getDefaultProjectForTenant(msg.tenantId) || `${msg.tenantId}-default`; - if (helloTenantId) { - // Move connection to the correct scope - moveConnection(ws, helloTenantId, helloProjectId); - logger.info(`Client identified: tenant=${helloTenantId} project=${helloProjectId}`); - - // Respond with scoped elements - const elements = store.getAllElements(helloProjectId); - ws.send(JSON.stringify({ - type: 'hello_ack', - tenantId: helloTenantId, - projectId: helloProjectId, - elements - })); + if (awaitingAuth) { + if (!validateApiKey(msg.apiKey)) { + ws.send(JSON.stringify({ type: 'auth_failed', reason: 'invalid_key' })); + ws.close(WS_AUTH_CLOSE_CODE, 'Invalid API key'); + return; + } + awaitingAuth = false; + if (authTimer) { + clearTimeout(authTimer); + authTimer = null; + } } + + const resolved = resolveHelloTenantAndProject(msg); + if ('error' in resolved) { + ws.send(JSON.stringify({ type: 'error', message: resolved.error })); + return; + } + identifyConnection(ws, resolved.tenant.id, resolved.projectId); + logger.info(`Client identified: tenant=${resolved.tenant.id} project=${resolved.projectId}`); + + sendHelloAck(ws, resolved.tenant, resolved.projectId); + if (authEnabled) { + sendFilesAdded(ws); + sendSyncStatus(ws, resolved.projectId); + } + return; } + if (awaitingAuth) return; if (msg.type === 'ack' && msg.msgId) { resolveAck(msg.msgId, { status: msg.status ?? 'applied', @@ -327,22 +444,23 @@ wss.on('connection', (ws: WebSocket) => { }); ws.on('close', () => { + if (authTimer) clearTimeout(authTimer); unregisterConnection(ws); logger.info('WebSocket connection closed'); }); ws.on('error', (error) => { + if (authTimer) clearTimeout(authTimer); logger.error('WebSocket error:', error); unregisterConnection(ws); }); }); -// Schema validation -const CreateElementSchema = z.object({ - id: z.string().optional(), - type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]), - x: z.number(), - y: z.number(), +// Module-level constants +const VALID_ELEMENT_TYPES = new Set(Object.values(EXCALIDRAW_ELEMENT_TYPES)); + +// Schema validation — shared fields extracted to avoid duplication +const ElementSharedFieldsSchema = z.object({ width: z.number().optional(), height: z.number().optional(), backgroundColor: z.string().optional(), @@ -353,9 +471,7 @@ const CreateElementSchema = z.object({ opacity: z.number().optional(), text: z.string().optional(), originalText: z.string().optional(), - label: z.object({ - text: z.string() - }).optional(), + label: z.object({ text: z.string() }).optional(), fontSize: z.number().optional(), fontFamily: z.union([z.string(), z.number()]).optional(), groupIds: z.array(z.string()).optional(), @@ -363,7 +479,6 @@ const CreateElementSchema = z.object({ roundness: z.object({ type: z.number(), value: z.number().optional() }).nullable().optional(), fillStyle: z.string().optional(), // Arrow-specific properties - points: z.any().optional(), start: z.object({ id: z.string() }).optional(), end: z.object({ id: z.string() }).optional(), startArrowhead: z.string().nullable().optional(), @@ -378,45 +493,23 @@ const CreateElementSchema = z.object({ scale: z.tuple([z.number(), z.number()]).optional(), }); -const UpdateElementSchema = z.object({ +const CreateElementSchema = ElementSharedFieldsSchema.extend({ + id: z.string().optional(), + type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]), + x: z.number(), + y: z.number(), + points: z.any().optional(), +}); + +const UpdateElementSchema = ElementSharedFieldsSchema.extend({ id: z.string(), type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]).optional(), x: z.number().optional(), y: z.number().optional(), - width: z.number().optional(), - height: z.number().optional(), - backgroundColor: z.string().optional(), - strokeColor: z.string().optional(), - strokeWidth: z.number().optional(), - strokeStyle: z.string().optional(), - roughness: z.number().optional(), - opacity: z.number().optional(), - text: z.string().optional(), - originalText: z.string().optional(), - label: z.object({ - text: z.string() - }).optional(), - fontSize: z.number().optional(), - fontFamily: z.union([z.string(), z.number()]).optional(), - groupIds: z.array(z.string()).optional(), - locked: z.boolean().optional(), - roundness: z.object({ type: z.number(), value: z.number().optional() }).nullable().optional(), - fillStyle: z.string().optional(), points: z.array(z.union([ z.tuple([z.number(), z.number()]), z.object({ x: z.number(), y: z.number() }) ])).optional(), - start: z.object({ id: z.string() }).optional(), - end: z.object({ id: z.string() }).optional(), - startArrowhead: z.string().nullable().optional(), - endArrowhead: z.string().nullable().optional(), - startBinding: z.any().nullable().optional(), - endBinding: z.any().nullable().optional(), - boundElements: z.any().nullable().optional(), - elbowed: z.boolean().optional(), - fileId: z.string().optional(), - status: z.string().optional(), - scale: z.tuple([z.number(), z.number()]).optional(), }); // API Routes @@ -465,7 +558,7 @@ app.post('/api/elements', async (req: Request, res: Response) => { type: 'element_created', element: element }; - (message as any).sync_version = sv; + message['sync_version'] = sv; const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message); res.json({ @@ -526,7 +619,7 @@ app.put('/api/elements/:id', async (req: Request, res: Response) => { type: 'element_updated', element: updatedElement }; - (message as any).sync_version = sv; + message['sync_version'] = sv; const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message); res.json({ @@ -550,7 +643,7 @@ app.put('/api/elements/:id', async (req: Request, res: Response) => { }); // Clear all elements (must be before /:id route) -app.delete('/api/elements/clear', (req: Request, res: Response) => { +app.delete('/api/elements/clear', destructiveRateLimit, requireConfirm, (req: Request, res: Response) => { try { const projId = resolveTenantProject(req); const count = store.clearElements(projId); @@ -626,8 +719,11 @@ app.get('/api/elements/search', (req: Request, res: Response) => { const { type, q, ...filters } = req.query; if (q && typeof q === 'string') { - const results = store.searchElements(q, projId); - return res.json({ success: true, elements: results, count: results.length }); + const sanitizedQuery = sanitizeSearchQuery(q); + if (sanitizedQuery) { + const results = store.searchElements(sanitizedQuery, projId); + return res.json({ success: true, elements: results, count: results.length }); + } } const results = store.queryElements( @@ -643,9 +739,15 @@ app.get('/api/elements/search', (req: Request, res: Response) => { }); } catch (error) { logger.error('Error querying elements:', error); + if (error instanceof InvalidSearchQueryError) { + return res.status(400).json({ + success: false, + error: 'Invalid search query' + }); + } res.status(500).json({ success: false, - error: (error as Error).message + error: 'Search failed' }); } }); @@ -881,7 +983,7 @@ app.post('/api/elements/batch', async (req: Request, res: Response) => { }); // Convert Mermaid diagram to Excalidraw elements -app.post('/api/elements/from-mermaid', (req: Request, res: Response) => { +app.post('/api/elements/from-mermaid', validateMermaidInput, (req: Request, res: Response) => { try { const { mermaidDiagram, config } = req.body; @@ -923,22 +1025,22 @@ app.post('/api/elements/from-mermaid', (req: Request, res: Response) => { }); // Sync elements from frontend (overwrite sync) -app.post('/api/elements/sync', (req: Request, res: Response) => { +app.post('/api/elements/sync', writeBurstLimit, (req: Request, res: Response) => { try { const projId = resolveTenantProject(req); const { elements: frontendElements, timestamp } = req.body; - - logger.info(`Sync request received: ${frontendElements.length} elements`, { - timestamp, - elementCount: frontendElements.length - }); - + if (!Array.isArray(frontendElements)) { return res.status(400).json({ success: false, error: 'Expected elements to be an array' }); } + + logger.info(`Sync request received: ${frontendElements.length} elements`, { + timestamp, + elementCount: frontendElements.length + }); const beforeCount = store.getElementCount(projId); @@ -996,7 +1098,7 @@ app.post('/api/elements/sync', (req: Request, res: Response) => { // ── Delta Sync v2 (Task 10) ── -app.post('/api/elements/sync/v2', (req: Request, res: Response) => { +app.post('/api/elements/sync/v2', writeBurstLimit, (req: Request, res: Response) => { try { const projId = resolveTenantProject(req); const { lastSyncVersion = 0, changes = [] } = req.body; @@ -1008,6 +1110,20 @@ app.post('/api/elements/sync/v2', (req: Request, res: Response) => { const scope = resolveScope(req); const feChangeIds = new Set(); + // Validate all upsert elements before applying any changes + for (const change of changes) { + const { id, action, element } = change; + if (!id || !action) continue; + if (action === 'upsert' && element && element.type !== undefined) { + if (!VALID_ELEMENT_TYPES.has(element.type)) { + return res.status(400).json({ + success: false, + error: `Invalid element type: ${element.type}` + }); + } + } + } + // Apply FE changes to DB let appliedCount = 0; for (const change of changes) { @@ -1069,11 +1185,7 @@ app.get('/api/sync/version', (req: Request, res: Response) => { // Get all files app.get('/api/files', (_req: Request, res: Response) => { try { - const allFiles: Record = {}; - for (const [id, file] of files) { - allFiles[id] = file; - } - res.json({ success: true, files: allFiles }); + res.json({ success: true, files: getAllFilesObject() }); } catch (error) { logger.error('Error fetching files:', error); res.status(500).json({ success: false, error: (error as Error).message }); @@ -1415,12 +1527,13 @@ app.get('/api/snapshots/:name', (req: Request, res: Response) => { // Serve the frontend app.get('/', (req: Request, res: Response) => { - const htmlFile = path.join(__dirname, '../dist/frontend/index.html'); - res.sendFile(htmlFile, (err) => { + fs.readFile(frontendHtmlPath, 'utf8', (err, html) => { if (err) { logger.error('Error serving frontend:', err); res.status(404).send('Frontend not found. Please run "npm run build" first.'); + return; } + res.type('html').send(injectApiKeyIntoHtml(html)); }); }); @@ -1524,11 +1637,15 @@ app.get('/api/sync/status', (req: Request, res: Response) => { }); // Error handling middleware -app.use((err: Error, req: Request, res: Response, next: NextFunction) => { +app.use((err: any, req: Request, res: Response, next: NextFunction) => { + // Propagate HTTP status from framework errors (e.g. 413 from express.json, 429 from rate-limit). + const status: number = typeof err.status === 'number' ? err.status + : typeof err.statusCode === 'number' ? err.statusCode + : 500; logger.error('Unhandled error:', err); - res.status(500).json({ + res.status(status).json({ success: false, - error: 'Internal server error' + error: status === 500 ? 'Internal server error' : (err.message ?? 'Error') }); }); @@ -1612,4 +1729,4 @@ if (isServerMainModule()) { }); } -export default app; \ No newline at end of file +export default app; diff --git a/src/types.ts b/src/types.ts index a706169..cf6aeaf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -196,7 +196,10 @@ export type WebSocketMessageType = | 'file_deleted' | 'hello' | 'hello_ack' - | 'ack'; + | 'ack' + | 'auth_required' + | 'auth_failed' + | 'error'; // Connection registry types export interface ClientConnection { @@ -215,8 +218,9 @@ export interface BroadcastResult { export interface HelloMessage extends WebSocketMessage { type: 'hello'; - tenantId: string; - projectId: string; + tenantId?: string; + projectId?: string; + apiKey?: string; } export interface HelloAckMessage extends WebSocketMessage { @@ -224,6 +228,11 @@ export interface HelloAckMessage extends WebSocketMessage { tenantId: string; projectId: string; elements: ServerElement[]; + tenant?: { + id: string; + name: string; + workspace_path: string; + }; } export interface AckMessage extends WebSocketMessage { @@ -413,4 +422,4 @@ export function validateElement(element: Partial): element is Ser // Helper function to generate unique IDs export function generateId(): string { return Date.now().toString(36) + Math.random().toString(36).substring(2); -} \ No newline at end of file +} diff --git a/tests/backend/api.test.ts b/tests/backend/api.test.ts index efbee1e..55629b4 100644 --- a/tests/backend/api.test.ts +++ b/tests/backend/api.test.ts @@ -173,7 +173,7 @@ describe('DELETE /api/elements/clear', () => { setElement('a', makeElement({ id: 'a' })); setElement('b', makeElement({ id: 'b' })); - const res = await request(app).delete('/api/elements/clear'); + const res = await request(app).delete('/api/elements/clear?confirm=true'); expect(res.status).toBe(200); expect(res.body.count).toBe(2); @@ -182,7 +182,7 @@ describe('DELETE /api/elements/clear', () => { }); it('returns 0 count when already empty', async () => { - const res = await request(app).delete('/api/elements/clear'); + const res = await request(app).delete('/api/elements/clear?confirm=true'); expect(res.body.count).toBe(0); }); }); diff --git a/tests/backend/mcp-tools-integration.test.ts b/tests/backend/mcp-tools-integration.test.ts index 4bfe063..ec185c1 100644 --- a/tests/backend/mcp-tools-integration.test.ts +++ b/tests/backend/mcp-tools-integration.test.ts @@ -46,7 +46,7 @@ describe('Clear canvas confirmation flow', () => { setElement('cl-2', makeElement({ id: 'cl-2' })); expect(getAllElements()).toHaveLength(2); - const res = await request(app).delete('/api/elements/clear'); + const res = await request(app).delete('/api/elements/clear?confirm=true'); expect(res.body.success).toBe(true); expect(res.body.count).toBeDefined(); @@ -54,13 +54,13 @@ describe('Clear canvas confirmation flow', () => { }); it('clear on empty canvas returns zero count', async () => { - const res = await request(app).delete('/api/elements/clear'); + const res = await request(app).delete('/api/elements/clear?confirm=true'); expect(res.body.success).toBe(true); }); it('cleared elements stay gone on subsequent GET requests', async () => { setElement('stay-gone', makeElement({ id: 'stay-gone' })); - await request(app).delete('/api/elements/clear'); + await request(app).delete('/api/elements/clear?confirm=true'); for (let i = 0; i < 3; i++) { const res = await request(app).get('/api/elements'); @@ -160,7 +160,7 @@ describe('Snapshot create and restore flow', () => { expect(snapRes.body.success).toBe(true); // Clear - await request(app).delete('/api/elements/clear'); + await request(app).delete('/api/elements/clear?confirm=true'); expect(getAllElements()).toHaveLength(0); // Snapshot should still contain the elements @@ -180,7 +180,7 @@ describe('Snapshot create and restore flow', () => { await request(app).post('/api/snapshots').send({ name: 'restore-test' }); // Clear and add different elements - await request(app).delete('/api/elements/clear'); + await request(app).delete('/api/elements/clear?confirm=true'); setElement('different', makeElement({ id: 'different' })); // Get snapshot diff --git a/tests/backend/security.test.ts b/tests/backend/security.test.ts index a97496f..3e1e9c3 100644 --- a/tests/backend/security.test.ts +++ b/tests/backend/security.test.ts @@ -107,6 +107,36 @@ describe('Input validation - sync endpoints', () => { expect(res.status).toBe(400); }); + it('POST /api/elements/sync rejects null elements → 400 not 500', async () => { + const res = await request(app) + .post('/api/elements/sync') + .send({ elements: null }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('POST /api/elements/sync rejects missing elements field → 400 not 500', async () => { + const res = await request(app) + .post('/api/elements/sync') + .send({}); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('POST /api/elements/sync/v2 rejects invalid element type in upsert → 400', async () => { + const res = await request(app) + .post('/api/elements/sync/v2') + .send({ + lastSyncVersion: 0, + changes: [{ id: 'test-id', action: 'upsert', element: { type: 'malicious