Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d9b052b9a | ||
|
|
b1ae502435 | ||
|
|
ca559acb3f | ||
|
|
53faaca5ab | ||
|
|
2ee3317b00 | ||
|
|
40b882672a | ||
|
|
bb710b46b0 | ||
|
|
9e53a9b92c | ||
|
|
7f01d76c2b | ||
|
|
a1db6e782d | ||
|
|
7222c9ae63 | ||
|
|
a8ee1c229e | ||
|
|
a06c3d2e36 | ||
|
|
bc5c924c8e | ||
|
|
cc3403363c | ||
|
|
e44053a25d | ||
|
|
6ed842dae9 | ||
|
|
0926415ffa | ||
|
|
ac638a3586 | ||
|
|
3de5951e48 | ||
|
|
5775ec1e3b | ||
|
|
d50de9232f | ||
|
|
547fd6affb | ||
|
|
460671e753 | ||
|
|
32188d93b4 | ||
|
|
27dbc1f40e | ||
|
|
2b6500c697 | ||
|
|
0e8c855818 | ||
|
|
936dd63976 | ||
|
|
4dceeb2e7f | ||
|
|
a4a1b03215 | ||
|
|
f91082d2a3 | ||
|
|
82574e7308 | ||
|
|
8d99a58600 | ||
|
|
f1b71ca5b8 | ||
|
|
a092e5b548 | ||
|
|
37be53dae8 | ||
|
|
a8723f6875 | ||
|
|
046719aabc | ||
|
|
9846e0ba0f | ||
|
|
d14d767c75 |
@@ -0,0 +1,98 @@
|
||||
# Gitea Actions: ARM64 native Docker build
|
||||
|
||||
name: Docker Build (ARM64)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
- 'Dockerfile.canvas'
|
||||
- 'src/**'
|
||||
- 'frontend/**'
|
||||
- 'package*.json'
|
||||
- '.dockerignore'
|
||||
- '.gitea/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
- 'Dockerfile.canvas'
|
||||
- 'src/**'
|
||||
- 'frontend/**'
|
||||
- 'package*.json'
|
||||
- '.dockerignore'
|
||||
- '.gitea/**'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: docker-arm64-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.forkless.com
|
||||
IMAGE_MCP: gitea.forkless.com/forkless/excalidraw-mcp-sentinel
|
||||
IMAGE_CANVAS: gitea.forkless.com/forkless/excalidraw-mcp-sentinel-canvas
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
name: Build & Push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build MCP Server
|
||||
run: |
|
||||
docker build \
|
||||
--tag ${{ env.IMAGE_MCP }}:latest \
|
||||
--tag ${{ env.IMAGE_MCP }}:${{ github.sha }} \
|
||||
--file Dockerfile .
|
||||
|
||||
- name: Build Canvas Server
|
||||
run: |
|
||||
docker build \
|
||||
--tag ${{ env.IMAGE_CANVAS }}:latest \
|
||||
--tag ${{ env.IMAGE_CANVAS }}:${{ github.sha }} \
|
||||
--file Dockerfile.canvas .
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
run: |
|
||||
echo "${{ secrets.GITEATOKEN }}" | \
|
||||
docker login ${{ env.REGISTRY }} \
|
||||
--username "${{ secrets.GITEAUSER }}" \
|
||||
--password-stdin
|
||||
|
||||
- name: Push latest
|
||||
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
run: |
|
||||
docker push ${{ env.IMAGE_MCP }}:latest
|
||||
docker push ${{ env.IMAGE_MCP }}:${{ github.sha }}
|
||||
docker push ${{ env.IMAGE_CANVAS }}:latest
|
||||
docker push ${{ env.IMAGE_CANVAS }}:${{ github.sha }}
|
||||
|
||||
- name: Tag version
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
docker tag ${{ env.IMAGE_MCP }}:latest ${{ env.IMAGE_MCP }}:$VERSION
|
||||
docker tag ${{ env.IMAGE_CANVAS }}:latest ${{ env.IMAGE_CANVAS }}:$VERSION
|
||||
docker push ${{ env.IMAGE_MCP }}:$VERSION
|
||||
docker push ${{ env.IMAGE_CANVAS }}:$VERSION
|
||||
|
||||
- name: Smoke test (PR only)
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
docker run -d --network host \
|
||||
--name test-canvas \
|
||||
${{ env.IMAGE_CANVAS }}:${{ github.sha }}
|
||||
trap "docker rm -f test-canvas" EXIT
|
||||
for i in $(seq 1 15); do
|
||||
if curl -sf http://localhost:3000/health > /dev/null 2>&1; then
|
||||
echo "Canvas healthy"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Health check timeout"
|
||||
exit 1
|
||||
@@ -1,10 +1,7 @@
|
||||
name: Release & Publish
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: release-main
|
||||
@@ -18,7 +15,7 @@ jobs:
|
||||
check:
|
||||
name: Check for releasable commits
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
if: always()
|
||||
outputs:
|
||||
bump: ${{ steps.bump.outputs.bump }}
|
||||
new_version: ${{ steps.bump.outputs.new_version }}
|
||||
|
||||
@@ -5,6 +5,37 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.1.0] - 2026-04-06
|
||||
|
||||
### Added
|
||||
- Batch workspace select/delete UI: Select/Unselect All, per-row checkboxes,
|
||||
Delete N workspaces button with confirmation — active workspace is disabled
|
||||
from selection
|
||||
- `POST /api/tenants/batch-delete` endpoint — delete up to 50 tenants in one
|
||||
request with per-tenant cascade (projects, elements, snapshots)
|
||||
- `fillNativeFields()` in db layer — fills all universal and type-specific
|
||||
native Excalidraw fields (angle, strokeColor, roundness, seed, etc.) on
|
||||
every write so elements are identical to those produced by the VSCode
|
||||
Excalidraw extension
|
||||
- `repairContainerBinding()` in db layer — enforces bidirectional
|
||||
`containerId` ↔ `boundElements` binding on every write path (create, update,
|
||||
batch, sync/v2) so text labels always follow their container when moved
|
||||
- Server-side label materialization (`materializeLabel` in server.ts): MCP
|
||||
`create_element`/`update_element` calls with `label.text` or `text` on a
|
||||
shape now produce a native bound text element in the DB instead of an MCP
|
||||
label stub — no synthetic generation required on export
|
||||
- 42 new backend non-regression tests for native field preservation, container
|
||||
binding repair, and label materialization (519 total)
|
||||
|
||||
## [1.0.6] - 2026-04-06
|
||||
|
||||
### Added
|
||||
- `DELETE /api/tenants/:id` endpoint — delete workspaces (tenants) with cascade (projects, elements, snapshots)
|
||||
- Workspace delete UI: inline confirm buttons in the workspace switcher panel
|
||||
|
||||
### Fixed
|
||||
- Project switch in browser did not load new project's elements — `switchProjectUI` now directly clears canvas and calls `loadExistingElements()` instead of relying on WS roundtrip
|
||||
|
||||
## [1.0.5] - 2026-04-06
|
||||
|
||||
### Added
|
||||
@@ -125,3 +156,21 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
||||
|
||||
### Fixed
|
||||
- `npm install -g excalidraw-mcp-sentinel` crashed on Windows — `postinstall` script used Unix-only `2>/dev/null || true` syntax which cmd.exe does not support; replaced with a cross-platform `node -e` inline script
|
||||
|
||||
- 2026-05-14: chore(ci): release workflow now manual (workflow_dispatch) -- no longer fires automatically on every CI pass on main
|
||||
|
||||
## [1.2.0] - 2026-05-20
|
||||
|
||||
### Added
|
||||
- Streamable HTTP transport mode (`MCP_TRANSPORT=http`): single long-lived process serves all MCP clients over HTTP instead of spawning a new stdio process per session. Each client gets its own isolated `Server` instance routed by `mcp-session-id` header. Eliminates per-session process overhead for multi-session setups.
|
||||
- `src/mcp-http.ts`: `mountMcpRoutes`, `startMcpHttpServer`, `resolveTransportMode` — full HTTP session lifecycle (POST/GET/DELETE /mcp, session map, `StreamableHTTPServerTransport`)
|
||||
- `createMcpServer()` factory and `registerHandlers()` in `src/index.ts` — clean per-session server instantiation for HTTP mode
|
||||
- 9 new tests in `tests/backend/mcp-http.test.ts` covering transport resolution, session isolation, session teardown, and `startMcpHttpServer`
|
||||
- `CLAUDE.md`: Strict Installation Decoupling rule
|
||||
- launchd agent (`~/Library/LaunchAgents/com.user.excalidraw-mcp.plist`) for single-instance persistence on macOS
|
||||
|
||||
### Changed
|
||||
- `runServer()` now checks `MCP_TRANSPORT` env var; defaults to stdio (backward-compatible)
|
||||
- `fs.writeFileSync`/`readFileSync` calls in export/import tool handlers converted to `fs.promises` async variants
|
||||
|
||||
### Total tests: 528 (31 files)
|
||||
|
||||
@@ -136,3 +136,7 @@ When exploring or understanding code in supported languages (JS, TS, Python, Go,
|
||||
- Use `smart_outline(file_path)` instead of Read to understand file structure (~1-2K tokens vs ~12K+)
|
||||
- Use `smart_unfold(file_path, symbol_name)` instead of Read for viewing specific functions (~400-2K tokens)
|
||||
- Fall back to Grep for exact string/regex searches, Read for non-code files and files under 100 lines
|
||||
|
||||
## Strict Installation Decoupling
|
||||
|
||||
Once installed (e.g., to ~/.local/bin), the project binary must NEVER depend on the local repository path (~/DevOpsSec) for execution, configuration, or data. All paths must be relative to the installation root or use standard system config paths (~/.config).
|
||||
|
||||
+7
-7
@@ -2,9 +2,9 @@
|
||||
# Builds the MCP server with SQLite persistence
|
||||
|
||||
# Stage 1: Build backend (TypeScript compilation + native modules)
|
||||
FROM node:20-slim AS builder
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -16,12 +16,12 @@ COPY tsconfig.json ./
|
||||
RUN npm run build:server
|
||||
|
||||
# Stage 2: Production MCP Server
|
||||
FROM node:20-slim AS production
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 --gid 1001 nodejs
|
||||
RUN addgroup -S -g 1001 nodejs && \
|
||||
adduser -S -u 1001 -G nodejs nodejs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -29,7 +29,7 @@ COPY package*.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
|
||||
|
||||
# Remove build tools after native modules are compiled
|
||||
RUN apt-get purge -y python3 make g++ && apt-get autoremove -y
|
||||
RUN apk del python3 make g++
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
|
||||
+12
-9
@@ -1,11 +1,14 @@
|
||||
# Dockerfile for Canvas Server (Optional)
|
||||
# Provides the web interface, REST API, and SQLite persistence
|
||||
# Provides the web interface, REST API, and SQLite persistence (Alpine)
|
||||
|
||||
# Stage 1: Build frontend
|
||||
FROM node:20-slim AS frontend-builder
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG CUSTOM_VITE_WS_URL
|
||||
ENV CUSTOM_VITE_WS_URL=$CUSTOM_VITE_WS_URL
|
||||
|
||||
COPY package*.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci --ignore-scripts
|
||||
|
||||
@@ -14,9 +17,9 @@ COPY vite.config.js ./
|
||||
RUN npm run build:frontend
|
||||
|
||||
# Stage 2: Build backend (TypeScript compilation + native modules)
|
||||
FROM node:20-slim AS backend-builder
|
||||
FROM node:20-alpine AS backend-builder
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -28,12 +31,12 @@ COPY tsconfig.json ./
|
||||
RUN npm run build:server
|
||||
|
||||
# Stage 3: Production Canvas Server
|
||||
FROM node:20-slim AS production
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 --gid 1001 nodejs
|
||||
RUN addgroup -S -g 1001 nodejs && \
|
||||
adduser -S -u 1001 -G nodejs nodejs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -41,7 +44,7 @@ COPY package*.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
|
||||
|
||||
# Remove build tools after native modules are compiled
|
||||
RUN apt-get purge -y python3 make g++ && apt-get autoremove -y
|
||||
RUN apk del python3 make g++
|
||||
|
||||
COPY --from=backend-builder /app/dist ./dist
|
||||
COPY --from=frontend-builder /app/dist/frontend ./dist/frontend
|
||||
|
||||
@@ -497,7 +497,7 @@ Each workspace (codebase) gets an isolated canvas. The tenant is identified by a
|
||||
|
||||
1. **Auto-detection**: When the MCP starts, it calls `server.listRoots()` to get the actual workspace path from the MCP client. This is hashed to create a unique tenant ID.
|
||||
2. **Per-request scoping**: Every HTTP request includes an `X-Tenant-Id` header. The canvas server uses this to scope all CRUD operations to the correct tenant.
|
||||
3. **UI switcher**: The canvas UI shows a "Workspace: <name>" badge. Click it to open a dropdown with all known workspaces, complete with search.
|
||||
3. **UI switcher**: The canvas UI shows a "Workspace: <name>" badge. Click it to open a dropdown with all known workspaces, complete with search and bulk management. Use **Select** to enter multi-select mode, check individual workspaces, then **Delete N workspaces** to batch-remove them (with confirmation). **Select All** / **Unselect All** shortcuts are available in selection mode.
|
||||
4. **Multi-instance safe**: SQLite WAL mode with `busy_timeout = 5000ms` handles concurrent access from multiple client instances.
|
||||
|
||||
### Projects within a tenant
|
||||
@@ -758,6 +758,8 @@ The canvas server exposes a REST API alongside the WebSocket interface:
|
||||
| PUT | `/api/project/active` | Switch the active project |
|
||||
| DELETE | `/api/projects/:id` | Delete a project (cascades elements) |
|
||||
| GET | `/api/tenants` | List all tenants |
|
||||
| DELETE | `/api/tenants/:id` | Delete a tenant (cascades projects and elements) |
|
||||
| POST | `/api/tenants/batch-delete` | Delete multiple tenants in one request — body: `{ ids: string[] }` (max 50) |
|
||||
| GET | `/api/tenant/active` | Get the active tenant |
|
||||
| PUT | `/api/tenant/active` | Set the active tenant |
|
||||
| GET | `/api/settings/:key` | Read a setting |
|
||||
|
||||
@@ -15,6 +15,8 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.canvas
|
||||
args:
|
||||
- CUSTOM_VITE_WS_URL=${CUSTOM_VITE_WS_URL:-excalidraw.forkless.com}
|
||||
image: celstnblacc/excalidraw-mcp-sentinel-canvas:latest
|
||||
container_name: mcp-excalidraw-canvas
|
||||
ports:
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# Agent Handoff: ARM64 Docker Build & Collaboration Setup
|
||||
|
||||
## Overview
|
||||
|
||||
Gitea server at `gitea.forkless.com` (RPi 5, native ARM64) hosts the repo
|
||||
`forkless/excalidraw-mcp-sentinel`. An ARM64 Gitea Actions runner is registered
|
||||
on the Gitea server itself (labels: `arm64`, `ubuntu-latest`). Runner #1.
|
||||
|
||||
Local workspace: `/home/pe1085/development/excalidraw-mcp-sentinel`
|
||||
Remote: `https://gitea.forkless.com/forkless/excalidraw-mcp-sentinel`
|
||||
|
||||
---
|
||||
|
||||
## What Was Done
|
||||
|
||||
### 1. Gitea Actions Workflow
|
||||
`.gitea/workflows/docker.yml` — builds both Docker images on ARM64 runner:
|
||||
- `gitea.forkless.com/forkless/excalidraw-mcp-sentinel:latest`
|
||||
- `gitea.forkless.com/forkless/excalidraw-mcp-sentinel-canvas:latest`
|
||||
|
||||
Triggers on push to `main` (paths: Dockerfile, src/**, frontend/**, .gitea/**),
|
||||
PRs, and `workflow_dispatch`. **Avoid empty commits** — they don't match the
|
||||
`paths` filter and won't trigger a build.
|
||||
|
||||
### 2. Alpine Base Images
|
||||
Both `Dockerfile` and `Dockerfile.canvas` changed from `node:20-slim` to
|
||||
`node:20-alpine`. Build tools via `apk add --no-cache python3 make g++`.
|
||||
|
||||
### 3. AMD64 Runner Removed
|
||||
There was a local x86_64 Gitea Actions runner (`wsl-runner`, id=1) on this
|
||||
machine that competed with the ARM64 runner. It was stopped and its `.runner`
|
||||
file deleted from `/home/pe1085/development/gitea-test/`. It had
|
||||
`ubuntu-latest:docker://catthehacker/ubuntu:full-latest` — would build amd64
|
||||
images that can't run on ARM64. **Do not re-register it.** Only the RPi 5
|
||||
ARM64 runner should exist.
|
||||
|
||||
### 4. Frontend WebSocket URL — `CUSTOM_VITE_WS_URL`
|
||||
**New env var** `CUSTOM_VITE_WS_URL` controls the frontend's WebSocket target.
|
||||
Hardcoded `wss://excalidraw.forkless.com` replaced with a Vite build-time define.
|
||||
|
||||
**Where:**
|
||||
- `vite.config.js` — reads `process.env.CUSTOM_VITE_WS_URL`, defaults to
|
||||
`wss://excalidraw.forkless.com`, exposes as `import.meta.env.CUSTOM_VITE_WS_URL`
|
||||
- `frontend/src/App.tsx` — uses `import.meta.env.CUSTOM_VITE_WS_URL`
|
||||
|
||||
**URL normalization** (auto handles user input):
|
||||
- `excalidraw.forkless.com` → `wss://excalidraw.forkless.com`
|
||||
- `wss://excalidraw.forkless.com/socket.io/foo` → `wss://excalidraw.forkless.com`
|
||||
- `ws://dev.local:5173` → `ws://dev.local:5173` (preserves ws://)
|
||||
- `https://example.com:3002/path` → `wss://example.com:3002`
|
||||
- `10.0.101.100` → `wss://10.0.101.100`
|
||||
- `[::1]:3000` → `wss://[::1]:3000`
|
||||
- Port is preserved if present, omitted if absent.
|
||||
|
||||
### 5. `.env.example`
|
||||
Created at repo root. Documents all key env vars.
|
||||
```env
|
||||
CUSTOM_VITE_WS_URL=excalidraw.forkless.com # auto-normalized
|
||||
CANVAS_PORT=3000
|
||||
HOST=0.0.0.0
|
||||
EXCALIDRAW_API_KEY=
|
||||
ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
EXCALIDRAW_DB_PATH=~/.excalidraw-mcp/excalidraw.db
|
||||
LOG_LEVEL=info
|
||||
```
|
||||
|
||||
### 6. Docker Build Integration
|
||||
`Dockerfile.canvas` frontend-builder stage accepts `ARG CUSTOM_VITE_WS_URL`
|
||||
and sets it as `ENV` so Vite picks it up at build time.
|
||||
|
||||
`docker-compose.yml` canvas service passes it as build arg:
|
||||
```yaml
|
||||
args:
|
||||
- CUSTOM_VITE_WS_URL=${CUSTOM_VITE_WS_URL:-excalidraw.forkless.com}
|
||||
```
|
||||
|
||||
To override at build time:
|
||||
```bash
|
||||
docker compose build --build-arg CUSTOM_VITE_WS_URL=wss://my-server.com canvas
|
||||
# or
|
||||
CUSTOM_VITE_WS_URL=my-server.com docker compose build canvas
|
||||
```
|
||||
|
||||
### 7. Reverse Proxy Config (NPM v2.15.1 / OpenResty)
|
||||
The server at `excalidraw.forkless.com` runs behind Nginx Proxy Manager.
|
||||
**Critical settings for WebSocket to work:**
|
||||
- **WebSockets Support** toggle enabled in NPM
|
||||
- The WebSocket upgrade headers (`Upgrade`, `Connection`) are forwarded
|
||||
- **Do NOT** add custom nginx proxy config in the Advanced tab unless the
|
||||
toggle alone doesn't work — the toggle handles it. Custom config can
|
||||
conflict and break NPM entirely (requires container restart to recover).
|
||||
|
||||
### 8. `trust proxy` in Express
|
||||
`src/server.ts` line 43:
|
||||
```ts
|
||||
app.set('trust proxy', 1);
|
||||
```
|
||||
Required because NPM sets `X-Forwarded-For` and express-rate-limit rejects
|
||||
requests without this setting.
|
||||
|
||||
### 9. `ALLOWED_ORIGINS` Env Var
|
||||
The `verifyWsClient()` in `src/security.ts` checks `Origin` header against
|
||||
`ALLOWED_ORIGINS`. Default: `http://localhost:3000`, `http://127.0.0.1:3000`.
|
||||
**Must include `https://excalidraw.forkless.com` for production.**
|
||||
|
||||
### 10. Gitea Actions Secrets
|
||||
Set in Gitea repo → Settings → Actions → Secrets:
|
||||
- `GITEAUSER` = `forkless`
|
||||
- `GITEATOKEN` = Gitea PAT with `write:repository` scope
|
||||
|
||||
### 11. `collabServerUrl` Prop (Removed)
|
||||
`collabServerUrl` and `onCollabDialogOpen` props were removed from App.tsx.
|
||||
These are **not documented/working props** in `@excalidraw/excalidraw` v0.18.x
|
||||
and were ignored by the library. The collab URL is hardcoded in the npm bundle
|
||||
and connects to `wss://oss-collab.excalidraw.com` by default — this is fine.
|
||||
|
||||
### 12. CORS Flood Suppression (`og-image-3.png`)
|
||||
|
||||
**Problem:** `@excalidraw/excalidraw` v0.18 internally fetches
|
||||
`https://excalidraw.com/og-image-3.png` (social-share preload) via `fetch()` on
|
||||
mount. On non-excalidraw.com origins, the server's CORS header doesn't match →
|
||||
6× CORS errors flood the console, drowning out real logs.
|
||||
|
||||
**Fix:** fetch interceptor in `frontend/index.html` `<head>`.
|
||||
Returns 204 No Content immediately for any `fetch()` whose URL contains
|
||||
`og-image-3.png`. Request never leaves the browser → no CORS preflight →
|
||||
no error.
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.fetch = new Proxy(window.fetch, {
|
||||
apply(target, thisArg, args) {
|
||||
var url = args[0];
|
||||
if (typeof url === 'string' && url.indexOf('og-image-3.png') !== -1) {
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}
|
||||
return Reflect.apply(target, thisArg, args);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
**Why not nginx:** The browser fetches directly from `excalidraw.com`, not
|
||||
through the fork's reverse proxy. Nginx on the fork domain never sees the
|
||||
request. A DNS-level intercept (pointing `excalidraw.com` at your own nginx)
|
||||
would work, but requires SSL certs for `excalidraw.com` and control over DNS
|
||||
resolution — not available in a hosted web-editor NPM setup.
|
||||
|
||||
**Scope:** Single hardcoded URL inside the npm package — no other references in
|
||||
the repo's own code (`src/`, `frontend/`, configs, docs — 111 files searched, 0
|
||||
hits).
|
||||
|
||||
**To verify:** Open browser console after deploy — zero CORS errors from
|
||||
`og-image-3.png`.
|
||||
|
||||
### 13. MCP Server Fixes
|
||||
|
||||
**Code changes in `src/index.ts`:**
|
||||
|
||||
1. **Auto-title junk removed** (`create_element` handler) — no longer auto-injects "Title"/"Text here" on containers when `text` is provided. Card layout still works via explicit `title`+`subtitle` params.
|
||||
|
||||
2. **X-Tenant-Id header scoped to local** — `canvasHeaders()` sends `X-Tenant-Id` only when `EXPRESS_SERVER_URL` is unset (local canvas). Remote canvases use default tenant routing.
|
||||
|
||||
3. **Skip local canvas server** — `startCanvasServer()` is skipped entirely when `EXPRESS_SERVER_URL` points to a remote host. Prevents port conflicts.
|
||||
|
||||
4. **MCP process stability** — `stdin.on('close')` handler uses `setTimeout(shutdown, 1000)` instead of immediate `process.exit(0)`. Prevents crash on transient disconnects during CodeWhale init.
|
||||
|
||||
**MCP config (`~/.codewhale/mcp.json`):**
|
||||
- Server registered as `excalidraw` with stdio transport
|
||||
- Points to `https://excalidraw.forkless.com` (production)
|
||||
- Uses `/tmp/excalidraw-mcp.db` for local DB (separate from production)
|
||||
|
||||
**Known issue:** Auto-title junk still appears when production canvas runs old Docker image. The fix is in `main` but the container must be redeployed. Until then, delete stray `_title`/`_subtitle` elements via API.
|
||||
|
||||
### 14. Favicon Suppressor
|
||||
`frontend/index.html` — added `<link rel="icon" href="data:,">` to prevent 404 on `/favicon.ico`. Combined with the fetch interceptor (section 12), the console is now clean of network errors.
|
||||
|
||||
---
|
||||
|
||||
## What Still Needs Work
|
||||
|
||||
### 1. WebSocket Auth Hardening
|
||||
When `EXCALIDRAW_API_KEY` is set, the WS requires auth. The frontend sends
|
||||
the key in a `hello` message after receiving `auth_required`. Without the
|
||||
key, the connection drops. Consider:
|
||||
- Rate-limit WS connections by IP
|
||||
- Reject no-origin connections in `verifyWsClient` (currently allows)
|
||||
|
||||
### 2. Docker Image Tags
|
||||
Only `:latest` and `:$sha` are pushed. Version tags (`v*`) trigger is in
|
||||
the workflow but untested.
|
||||
|
||||
### 3. Production Container Needs Redeploy
|
||||
The auto-title fix (section 13) is committed and the Docker image is built,
|
||||
but the running container at `excalidraw.forkless.com` still runs the old
|
||||
image. Until redeployed, elements created via REST API batch will have
|
||||
stray `_title`/`_subtitle` junk elements. Delete them via:
|
||||
```bash
|
||||
curl -s https://excalidraw.forkless.com/api/elements | python3 -c "import sys,json;d=json.load(sys.stdin);[print(e['id']) for e in d['elements'] if e.get('text') in ('Title','Text here')]" | xargs -I{} curl -s -X DELETE "https://excalidraw.forkless.com/api/elements/{}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Credentials & Access
|
||||
|
||||
- **Gitea API token**: stored in `~/.profile` as `GITEA_TOKEN`
|
||||
- **Git credentials**: stored via `git credential-store` for
|
||||
`gitea.forkless.com` — username `forkless`, password = Gitea PAT
|
||||
- To use the token in exec_shell:
|
||||
`source ~/.profile && echo "$GITEA_TOKEN"` (DO NOT source `~/.profile`
|
||||
if the user objects — but the token is stored there)
|
||||
- Alternative: read via git credential helper:
|
||||
`TOKEN=$(git credential fill <<< $'protocol=https\nhost=gitea.forkless.com\npath=/forkless/excalidraw-mcp-sentinel.git\n' 2>/dev/null | grep "^password=" | cut -d= -f2)`
|
||||
|
||||
### Git Credential Helper Fix
|
||||
The system had a broken `credential.helper` for `gitea.forkless.com` pointing
|
||||
to `!tea login helper` — but the binary is `tea-cli`, not `tea`. Fixed with:
|
||||
```bash
|
||||
git config --global --replace-all credential.https://gitea.forkless.com.helper '!tea-cli login helper'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
# Check latest build
|
||||
TOKEN=$(git credential fill <<< $'protocol=https\nhost=gitea.forkless.com\npath=/forkless/excalidraw-mcp-sentinel.git\n' 2>/dev/null | grep "^password=" | cut -d= -f2) && curl -s -H "Authorization: token $TOKEN" "https://gitea.forkless.com/api/v1/repos/forkless/excalidraw-mcp-sentinel/actions/runs?limit=5" | python3 -c "import sys,json;d=json.load(sys.stdin);[print(f'Run {r[\"id\"]}: {r[\"status\"]:>10} {r.get(\"conclusion\",\"-\"):>10} {r[\"head_sha\"][:12]}') for r in d.get('workflow_runs',[])]"
|
||||
|
||||
# Check Docker image manifest
|
||||
docker manifest inspect gitea.forkless.com/forkless/excalidraw-mcp-sentinel:latest
|
||||
|
||||
# Push (credential helper auto-handles auth)
|
||||
git push origin main
|
||||
|
||||
# Trigger dispatch (if paths filter blocks push trigger)
|
||||
TOKEN=$(git credential fill <<< $'protocol=https\nhost=gitea.forkless.com\npath=/forkless/excalidraw-mcp-sentinel.git\n' 2>/dev/null | grep "^password=" | cut -d= -f2) && curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" "https://gitea.forkless.com/api/v1/repos/forkless/excalidraw-mcp-sentinel/actions/workflows/docker.yml/dispatches" -d '{"ref":"main"}'
|
||||
|
||||
# Build canvas with custom WS URL
|
||||
CUSTOM_VITE_WS_URL=my-ws-server.com docker compose build canvas
|
||||
```
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="data:,">
|
||||
<title>Excalidraw POC - Backend API Integration</title>
|
||||
<style>
|
||||
body {
|
||||
@@ -402,6 +403,9 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.menu-search-wrap {
|
||||
padding: 8px 10px 4px;
|
||||
@@ -502,6 +506,12 @@
|
||||
}
|
||||
.menu-create-btn:hover:not(:disabled) { background: #4c2fa8; }
|
||||
.menu-create-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.tenant-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
.tenant-row:hover .project-delete-btn { opacity: 0.5; }
|
||||
.project-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -567,6 +577,110 @@
|
||||
}
|
||||
.project-delete-no:hover { background: #dee2e6; }
|
||||
|
||||
/* Batch selection mode */
|
||||
.batch-mode-toggle {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: #f8f9fa;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.batch-mode-toggle:hover { background: #e9ecef; border-color: #ccc; }
|
||||
.batch-mode-active { background: #e8f5e9; border-color: #a5d6a7; color: #2e7d32; }
|
||||
.batch-mode-active:hover { background: #c8e6c9; }
|
||||
.batch-actions-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
}
|
||||
.batch-action-btn {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.batch-action-btn:hover { background: #e9ecef; }
|
||||
.batch-count {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-left: auto;
|
||||
}
|
||||
.batch-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
gap: 10px;
|
||||
}
|
||||
.batch-item-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.batch-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
accent-color: #e03131;
|
||||
cursor: pointer;
|
||||
}
|
||||
.batch-item-disabled .batch-checkbox { cursor: default; }
|
||||
.batch-item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.batch-active-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #4caf50;
|
||||
text-transform: uppercase;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.batch-delete-bar {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff5f5;
|
||||
}
|
||||
.batch-delete-btn {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
background: #e03131;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.batch-delete-btn:hover { background: #c92a2a; }
|
||||
.batch-delete-confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.batch-delete-msg {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #c92a2a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Clear canvas confirmation dialog */
|
||||
.confirm-dialog {
|
||||
position: absolute;
|
||||
@@ -612,6 +726,20 @@
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
<!-- CORS flood suppressor: intercepts @excalidraw/excalidraw internal fetch to
|
||||
https://excalidraw.com/og-image-3.png which triggers 6× CORS errors on
|
||||
non-excalidraw.com origins. Returns 204 immediately, never leaves browser. -->
|
||||
<script>
|
||||
window.fetch = new Proxy(window.fetch, {
|
||||
apply(target, thisArg, args) {
|
||||
var url = args[0];
|
||||
if (typeof url === 'string' && url.indexOf('og-image-3.png') !== -1) {
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}
|
||||
return Reflect.apply(target, thisArg, args);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+174
-17
@@ -133,6 +133,10 @@ function App(): JSX.Element {
|
||||
const [isCreatingProject, setIsCreatingProject] = useState<boolean>(false)
|
||||
const newProjectInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [confirmDeleteProjectId, setConfirmDeleteProjectId] = useState<string | null>(null)
|
||||
const [confirmDeleteTenantId, setConfirmDeleteTenantId] = useState<string | null>(null)
|
||||
const [batchSelectMode, setBatchSelectMode] = useState<boolean>(false)
|
||||
const [selectedTenantIds, setSelectedTenantIds] = useState<Set<string>>(new Set())
|
||||
const [confirmBatchDelete, setConfirmBatchDelete] = useState<boolean>(false)
|
||||
|
||||
// Keep refs in sync so closures (WebSocket handlers) always see latest values
|
||||
useEffect(() => {
|
||||
@@ -431,8 +435,19 @@ function App(): JSX.Element {
|
||||
return
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsUrl = `${protocol}//${window.location.host}`
|
||||
let rawUrl = import.meta.env.CUSTOM_VITE_WS_URL || 'wss://excalidraw.forkless.com'
|
||||
let scheme = 'wss://'
|
||||
try {
|
||||
let parseable = rawUrl
|
||||
if (rawUrl.startsWith('ws://')) { scheme = 'ws://'; parseable = rawUrl.replace(/^ws:\/\//, 'http://') }
|
||||
else if (rawUrl.startsWith('wss://')) { parseable = rawUrl.replace(/^wss:\/\//, 'https://') }
|
||||
const parsed = new URL(parseable)
|
||||
rawUrl = scheme + parsed.host
|
||||
} catch {
|
||||
// Bare hostname/IP — prepend wss://
|
||||
rawUrl = 'wss://' + rawUrl
|
||||
}
|
||||
const wsUrl = rawUrl
|
||||
|
||||
websocketRef.current = new WebSocket(wsUrl)
|
||||
|
||||
@@ -1241,6 +1256,16 @@ function App(): JSX.Element {
|
||||
if (data.success) {
|
||||
setActiveProject({ id: data.project.id, name: data.project.name })
|
||||
setProjectMenuOpen(false)
|
||||
// Clear canvas and load the new project's elements directly
|
||||
// (don't rely on WS roundtrip which can race)
|
||||
const api = excalidrawAPIRef.current
|
||||
if (api) {
|
||||
api.updateScene({ elements: [], captureUpdate: CaptureUpdateAction.NEVER })
|
||||
lastSyncedHashRef.current = ''
|
||||
lastSeenHashRef.current = ''
|
||||
lastSyncedElementsRef.current = new Map()
|
||||
}
|
||||
await loadExistingElements()
|
||||
showToast(`Switched to "${data.project.name}"`)
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -1293,6 +1318,59 @@ function App(): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
const deleteTenantUI = async (tenantId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/tenants/${tenantId}`, {
|
||||
method: 'DELETE',
|
||||
headers: tenantHeaders()
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setConfirmDeleteTenantId(null)
|
||||
setTenantList(prev => prev.filter(t => t.id !== tenantId))
|
||||
showToast('Workspace deleted')
|
||||
} else {
|
||||
showToast(data.error ?? 'Delete failed', 4000)
|
||||
setConfirmDeleteTenantId(null)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete tenant:', err)
|
||||
setConfirmDeleteTenantId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const batchDeleteTenants = async () => {
|
||||
const ids = Array.from(selectedTenantIds)
|
||||
try {
|
||||
const res = await fetch('/api/tenants/batch-delete', {
|
||||
method: 'POST',
|
||||
headers: tenantHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ ids })
|
||||
})
|
||||
const data = await res.json()
|
||||
const deleted = data.deletedCount ?? 0
|
||||
const deletedIds = new Set((data.results ?? []).filter((r: { deleted: boolean }) => r.deleted).map((r: { id: string }) => r.id))
|
||||
setTenantList(prev => prev.filter(t => !deletedIds.has(t.id)))
|
||||
setSelectedTenantIds(new Set())
|
||||
setConfirmBatchDelete(false)
|
||||
setBatchSelectMode(false)
|
||||
showToast(`${deleted} workspace${deleted !== 1 ? 's' : ''} deleted`)
|
||||
} catch (err) {
|
||||
console.error('Batch delete failed:', err)
|
||||
showToast('Batch delete failed', 4000)
|
||||
setConfirmBatchDelete(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleTenantSelection = (id: string) => {
|
||||
setSelectedTenantIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const syncToBackend = async (): Promise<void> => {
|
||||
if (!excalidrawAPI || isSyncingRef.current) return
|
||||
|
||||
@@ -1537,10 +1615,37 @@ function App(): JSX.Element {
|
||||
const filtered = q
|
||||
? tenantList.filter(t => t.name.toLowerCase().includes(q) || t.workspace_path.toLowerCase().includes(q))
|
||||
: tenantList
|
||||
const selectableFiltered = filtered.filter(t => t.id !== activeTenant?.id)
|
||||
return (
|
||||
<div className="menu-overlay" onClick={() => setMenuOpen(false)}>
|
||||
<div className="menu-overlay" onClick={() => { setMenuOpen(false); setBatchSelectMode(false); setSelectedTenantIds(new Set()); setConfirmBatchDelete(false) }}>
|
||||
<div className="menu-panel" onClick={e => e.stopPropagation()}>
|
||||
<div className="menu-header">Workspaces</div>
|
||||
<div className="menu-header">
|
||||
<span>Workspaces</span>
|
||||
<button
|
||||
className={`batch-mode-toggle ${batchSelectMode ? 'batch-mode-active' : ''}`}
|
||||
title={batchSelectMode ? 'Exit selection mode' : 'Select workspaces to delete'}
|
||||
onClick={() => {
|
||||
setBatchSelectMode(prev => !prev)
|
||||
setSelectedTenantIds(new Set())
|
||||
setConfirmBatchDelete(false)
|
||||
}}
|
||||
>
|
||||
{batchSelectMode ? 'Done' : 'Select'}
|
||||
</button>
|
||||
</div>
|
||||
{batchSelectMode && selectableFiltered.length > 0 && (
|
||||
<div className="batch-actions-bar">
|
||||
<button
|
||||
className="batch-action-btn"
|
||||
onClick={() => setSelectedTenantIds(new Set(selectableFiltered.map(t => t.id)))}
|
||||
>Select All</button>
|
||||
<button
|
||||
className="batch-action-btn"
|
||||
onClick={() => setSelectedTenantIds(new Set())}
|
||||
>Unselect All</button>
|
||||
<span className="batch-count">{selectedTenantIds.size} selected</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="menu-search-wrap">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
@@ -1553,22 +1658,74 @@ function App(): JSX.Element {
|
||||
</div>
|
||||
<div className="menu-list">
|
||||
{filtered.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`menu-item ${activeTenant?.id === t.id ? 'menu-item-active' : ''}`}
|
||||
onClick={() => switchTenant(t.id)}
|
||||
>
|
||||
<span className="menu-item-name">{t.name}</span>
|
||||
<span className="menu-item-path" title={t.workspace_path}>
|
||||
{t.workspace_path.length > 40
|
||||
? '...' + t.workspace_path.slice(-37)
|
||||
: t.workspace_path}
|
||||
</span>
|
||||
{activeTenant?.id === t.id && <span className="menu-item-check">✓</span>}
|
||||
</button>
|
||||
<div key={t.id} className="tenant-row">
|
||||
{confirmDeleteTenantId === t.id ? (
|
||||
<div className="project-delete-confirm">
|
||||
<span className="project-delete-msg">Delete "{t.name}"?</span>
|
||||
<button className="project-delete-yes" onClick={() => deleteTenantUI(t.id)}>Delete</button>
|
||||
<button className="project-delete-no" onClick={() => setConfirmDeleteTenantId(null)}>Cancel</button>
|
||||
</div>
|
||||
) : batchSelectMode ? (
|
||||
<label className={`menu-item batch-item ${activeTenant?.id === t.id ? 'menu-item-active batch-item-disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="batch-checkbox"
|
||||
disabled={activeTenant?.id === t.id}
|
||||
checked={selectedTenantIds.has(t.id)}
|
||||
onChange={() => toggleTenantSelection(t.id)}
|
||||
/>
|
||||
<span className="batch-item-content">
|
||||
<span className="menu-item-name">{t.name}</span>
|
||||
<span className="menu-item-path" title={t.workspace_path}>
|
||||
{t.workspace_path.length > 40
|
||||
? '...' + t.workspace_path.slice(-37)
|
||||
: t.workspace_path}
|
||||
</span>
|
||||
</span>
|
||||
{activeTenant?.id === t.id && <span className="batch-active-label">active</span>}
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className={`menu-item ${activeTenant?.id === t.id ? 'menu-item-active' : ''}`}
|
||||
onClick={() => switchTenant(t.id)}
|
||||
>
|
||||
<span className="menu-item-name">{t.name}</span>
|
||||
<span className="menu-item-path" title={t.workspace_path}>
|
||||
{t.workspace_path.length > 40
|
||||
? '...' + t.workspace_path.slice(-37)
|
||||
: t.workspace_path}
|
||||
</span>
|
||||
{activeTenant?.id === t.id && <span className="menu-item-check">✓</span>}
|
||||
</button>
|
||||
{activeTenant?.id !== t.id && (
|
||||
<button
|
||||
className="project-delete-btn"
|
||||
title="Delete workspace"
|
||||
onClick={e => { e.stopPropagation(); setConfirmDeleteTenantId(t.id) }}
|
||||
>×</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && <div className="menu-empty">No matching workspaces</div>}
|
||||
</div>
|
||||
{batchSelectMode && selectedTenantIds.size > 0 && (
|
||||
<div className="batch-delete-bar">
|
||||
{confirmBatchDelete ? (
|
||||
<div className="batch-delete-confirm">
|
||||
<span className="batch-delete-msg">Delete {selectedTenantIds.size} workspace{selectedTenantIds.size !== 1 ? 's' : ''}?</span>
|
||||
<button className="project-delete-yes" onClick={batchDeleteTenants}>Delete</button>
|
||||
<button className="project-delete-no" onClick={() => setConfirmBatchDelete(false)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="batch-delete-btn" onClick={() => setConfirmBatchDelete(true)}>
|
||||
Delete {selectedTenantIds.size} workspace{selectedTenantIds.size !== 1 ? 's' : ''}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "excalidraw-mcp-sentinel",
|
||||
"version": "1.0.3",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "excalidraw-mcp-sentinel",
|
||||
"version": "1.0.3",
|
||||
"version": "1.1.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "excalidraw-mcp-sentinel",
|
||||
"version": "1.0.5",
|
||||
"version": "1.2.0",
|
||||
"description": "Hardened, self-hosted Excalidraw MCP server with SQLite persistence, multi-tenancy, auto-sync, security middleware, and 369 tests",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -264,6 +264,96 @@ export function getDefaultProjectForTenant(tenantId: string): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
// ── Native field normalization ──
|
||||
|
||||
// Fill any missing native Excalidraw fields so every element stored in the DB
|
||||
// is a complete, round-trippable Excalidraw element — not just an MCP partial.
|
||||
function fillNativeFields(element: ServerElement): ServerElement {
|
||||
const el = element as any;
|
||||
|
||||
// ── Universal fields ──────────────────────────────────────────────────────
|
||||
el.angle = el.angle ?? 0;
|
||||
el.strokeColor = el.strokeColor ?? '#1e1e1e';
|
||||
el.backgroundColor = el.backgroundColor ?? 'transparent';
|
||||
el.fillStyle = el.fillStyle ?? 'solid';
|
||||
el.strokeWidth = el.strokeWidth ?? 2;
|
||||
el.strokeStyle = el.strokeStyle ?? 'solid';
|
||||
el.roughness = el.roughness ?? 1;
|
||||
el.opacity = el.opacity ?? 100;
|
||||
el.groupIds = el.groupIds ?? [];
|
||||
el.frameId = el.frameId ?? null;
|
||||
el.seed = el.seed ?? Math.floor(Math.random() * 2147483647);
|
||||
el.versionNonce = el.versionNonce ?? Math.floor(Math.random() * 2147483647);
|
||||
el.isDeleted = el.isDeleted ?? false;
|
||||
el.updated = el.updated ?? Date.now();
|
||||
el.link = el.link ?? null;
|
||||
el.locked = el.locked ?? false;
|
||||
el.boundElements = el.boundElements ?? null;
|
||||
|
||||
// index: preserve existing; generate a stable sortable value if absent
|
||||
if (!el.index) {
|
||||
el.index = `a${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`;
|
||||
}
|
||||
|
||||
// roundness: Excalidraw default is rounded (type 3) for closed shapes
|
||||
if (el.roundness === undefined) {
|
||||
const rounded = el.type === 'rectangle' || el.type === 'diamond' || el.type === 'ellipse';
|
||||
el.roundness = rounded ? { type: 3 } : null;
|
||||
}
|
||||
|
||||
// ── Type-specific fields ──────────────────────────────────────────────────
|
||||
if (el.type === 'text') {
|
||||
el.text = el.text ?? '';
|
||||
el.originalText = el.originalText ?? el.text;
|
||||
el.fontSize = el.fontSize ?? 20;
|
||||
el.fontFamily = el.fontFamily ?? 5; // Nunito
|
||||
el.textAlign = el.textAlign ?? 'left';
|
||||
el.verticalAlign = el.verticalAlign ?? (el.containerId ? 'middle' : 'top');
|
||||
el.autoResize = el.autoResize ?? true;
|
||||
el.lineHeight = el.lineHeight ?? 1.25;
|
||||
el.containerId = el.containerId ?? null;
|
||||
} else if (el.type === 'arrow' || el.type === 'line') {
|
||||
el.points = el.points ?? [[0, 0], [100, 0]];
|
||||
el.lastCommittedPoint = el.lastCommittedPoint ?? null;
|
||||
el.startBinding = el.startBinding ?? null;
|
||||
el.endBinding = el.endBinding ?? null;
|
||||
el.startArrowhead = el.startArrowhead ?? null;
|
||||
el.endArrowhead = el.endArrowhead ?? (el.type === 'arrow' ? 'arrow' : null);
|
||||
el.elbowed = el.elbowed ?? false;
|
||||
} else if (el.type === 'image') {
|
||||
el.status = el.status ?? 'pending';
|
||||
el.scale = el.scale ?? [1, 1];
|
||||
} else if (el.type === 'freedraw') {
|
||||
el.points = el.points ?? [];
|
||||
el.pressures = el.pressures ?? [];
|
||||
el.simulatePressure = el.simulatePressure ?? true;
|
||||
el.lastCommittedPoint = el.lastCommittedPoint ?? null;
|
||||
}
|
||||
|
||||
return el as ServerElement;
|
||||
}
|
||||
|
||||
// When a text element with containerId is saved, ensure the container's
|
||||
// boundElements array references it back. Both sides must be consistent
|
||||
// for Excalidraw to treat the text as embedded in the shape.
|
||||
function repairContainerBinding(element: ServerElement, projectId?: string): void {
|
||||
if (element.type !== 'text') return;
|
||||
const cid = (element as any).containerId as string | null | undefined;
|
||||
if (!cid) return;
|
||||
const container = getElement(cid, projectId);
|
||||
if (!container) return;
|
||||
const existing: any[] = Array.isArray((container as any).boundElements)
|
||||
? (container as any).boundElements as any[]
|
||||
: [];
|
||||
if (existing.some((b: any) => b.id === element.id)) return;
|
||||
// Update container directly — container.type is never 'text' so this
|
||||
// cannot recurse back into repairContainerBinding.
|
||||
setElement(cid, {
|
||||
...container,
|
||||
boundElements: [...existing, { type: 'text', id: element.id }]
|
||||
} as ServerElement, projectId);
|
||||
}
|
||||
|
||||
// ── Element CRUD ──
|
||||
|
||||
export function getElement(id: string, projectId?: string): ServerElement | undefined {
|
||||
@@ -283,8 +373,9 @@ export function hasElement(id: string, projectId?: string): boolean {
|
||||
export function setElement(id: string, element: ServerElement, projectId?: string): number {
|
||||
const p = pid(projectId);
|
||||
const now = new Date().toISOString();
|
||||
const data = JSON.stringify(element);
|
||||
const labelText = extractLabelText(element);
|
||||
const normalized = fillNativeFields(element);
|
||||
const data = JSON.stringify(normalized);
|
||||
const labelText = extractLabelText(normalized);
|
||||
const sv = incrementSyncVersion(p);
|
||||
const existing = db.prepare(
|
||||
'SELECT version, is_deleted FROM elements WHERE id = ? AND project_id = ?'
|
||||
@@ -295,19 +386,20 @@ export function setElement(id: string, element: ServerElement, projectId?: strin
|
||||
db.prepare(`
|
||||
UPDATE elements SET type = ?, data = ?, label_text = ?, updated_at = ?, version = ?, is_deleted = 0, sync_version = ?
|
||||
WHERE id = ? AND project_id = ?
|
||||
`).run(element.type, data, labelText, now, newVersion, sv, id, p);
|
||||
`).run(normalized.type, data, labelText, now, newVersion, sv, id, p);
|
||||
|
||||
recordVersion(id, newVersion, data, existing.is_deleted ? 'create' : 'update', p);
|
||||
updateFts(id, labelText, element.type);
|
||||
updateFts(id, labelText, normalized.type);
|
||||
} else {
|
||||
db.prepare(`
|
||||
INSERT INTO elements (id, project_id, type, data, label_text, created_at, updated_at, version, sync_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)
|
||||
`).run(id, p, element.type, data, labelText, now, now, sv);
|
||||
`).run(id, p, normalized.type, data, labelText, now, now, sv);
|
||||
|
||||
recordVersion(id, 1, data, 'create', p);
|
||||
insertFts(id, labelText, element.type);
|
||||
insertFts(id, labelText, normalized.type);
|
||||
}
|
||||
repairContainerBinding(normalized, projectId);
|
||||
return sv;
|
||||
}
|
||||
|
||||
@@ -524,6 +616,26 @@ export function listTenants(): Tenant[] {
|
||||
return db.prepare('SELECT * FROM tenants ORDER BY last_accessed_at DESC').all() as Tenant[];
|
||||
}
|
||||
|
||||
export function deleteTenant(id: string): void {
|
||||
if (id === activeTenantId) throw new Error('Cannot delete the active tenant — switch to another tenant first');
|
||||
const tenants = listTenants();
|
||||
if (tenants.length <= 1) throw new Error('Cannot delete the last tenant');
|
||||
const tenant = getTenantById(id);
|
||||
if (!tenant) throw new Error(`Tenant "${id}" not found`);
|
||||
// CASCADE: delete elements + element_versions for all projects in this tenant, then projects, then tenant
|
||||
const projects = db.prepare('SELECT id FROM projects WHERE tenant_id = ?').all(id) as { id: string }[];
|
||||
const deleteElements = db.prepare('DELETE FROM elements WHERE project_id = ?');
|
||||
const deleteVersions = db.prepare('DELETE FROM element_versions WHERE element_id IN (SELECT id FROM elements WHERE project_id = ?)');
|
||||
const deleteSnapshots = db.prepare('DELETE FROM snapshots WHERE project_id = ?');
|
||||
for (const p of projects) {
|
||||
deleteVersions.run(p.id);
|
||||
deleteElements.run(p.id);
|
||||
deleteSnapshots.run(p.id);
|
||||
}
|
||||
db.prepare('DELETE FROM projects WHERE tenant_id = ?').run(id);
|
||||
db.prepare('DELETE FROM tenants WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
// ── Projects ──
|
||||
|
||||
export function createProject(name: string, description?: string, tenantId?: string): Project {
|
||||
|
||||
+123
-87
@@ -35,6 +35,7 @@ import {
|
||||
} from './types.js';
|
||||
import fetch from 'node-fetch';
|
||||
import { startCanvasServer, stopCanvasServer } from './server.js';
|
||||
import { startMcpHttpServer, resolveTransportMode } from './mcp-http.js';
|
||||
import {
|
||||
initDb, closeDb,
|
||||
searchElements as dbSearchElements,
|
||||
@@ -143,9 +144,13 @@ interface SyncResponse {
|
||||
function canvasHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Id': dbGetActiveTenantId(),
|
||||
...extra
|
||||
};
|
||||
// Only send tenant header when targeting local canvas (EXPRESS_SERVER_URL unset).
|
||||
// Remote servers (e.g. production) handle routing via default tenant.
|
||||
if (!process.env.EXPRESS_SERVER_URL) {
|
||||
headers['X-Tenant-Id'] = dbGetActiveTenantId();
|
||||
}
|
||||
// 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;
|
||||
@@ -1034,39 +1039,41 @@ const tools: Tool[] = [
|
||||
];
|
||||
|
||||
// Initialize MCP server
|
||||
const server = new Server(
|
||||
{
|
||||
name: "mcp-excalidraw-server",
|
||||
version: "2.0.0",
|
||||
description: "Programmatic canvas toolkit for Excalidraw with file I/O, image export, and real-time sync"
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: Object.fromEntries(tools.map(tool => [tool.name, {
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema
|
||||
}]))
|
||||
// Build a fresh MCP server with all request handlers registered. Called once
|
||||
// for the stdio singleton below, and once per client session in HTTP mode.
|
||||
function createMcpServer(): Server {
|
||||
const server = new Server(
|
||||
{
|
||||
name: "mcp-excalidraw-server",
|
||||
version: "2.0.0",
|
||||
description: "Programmatic canvas toolkit for Excalidraw with file I/O, image export, and real-time sync"
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: Object.fromEntries(tools.map(tool => [tool.name, {
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema
|
||||
}]))
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Helper function to convert text property to label format for Excalidraw
|
||||
function convertTextToLabel(element: ServerElement): ServerElement {
|
||||
const { text, ...rest } = element;
|
||||
// text === undefined means the caller didn't touch the text field — leave as-is
|
||||
if (text === undefined) return element;
|
||||
// Standalone text elements keep text as a direct property
|
||||
if (element.type === 'text') return element;
|
||||
// All container/shape/arrow elements: map text → label.text (empty string clears it)
|
||||
// Default containers to top-center alignment for title/subtitle layout
|
||||
const isArrow = element.type === 'arrow' || element.type === 'line';
|
||||
return {
|
||||
...rest,
|
||||
verticalAlign: (rest as any).verticalAlign ?? (isArrow ? 'middle' : 'top'),
|
||||
label: { text }
|
||||
} as ServerElement;
|
||||
);
|
||||
registerHandlers(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
const server = createMcpServer();
|
||||
|
||||
// Helper function: previously converted text → label format for Excalidraw.
|
||||
// Now a no-op because the canvas REST API materializes label/text into native
|
||||
// bound text elements at write time (materializeLabel in server.ts).
|
||||
function convertTextToLabel(element: ServerElement): ServerElement {
|
||||
return element;
|
||||
}
|
||||
|
||||
// Register all request handlers on a server instance. Module-scope so it can be
|
||||
// called per-session in HTTP mode and once for the stdio singleton.
|
||||
function registerHandlers(server: Server): void {
|
||||
|
||||
// Set up request handler for tool calls
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => {
|
||||
try {
|
||||
@@ -1087,16 +1094,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
const id = customId || generateId();
|
||||
const normalizedFont = normalizeFontFamily(elementProps.fontFamily);
|
||||
|
||||
// Auto-populate title+subtitle for container types unless text is explicitly set
|
||||
// Use explicit text if provided, otherwise fall back to title/title+subtitle card layout
|
||||
const CONTAINER_TYPES = new Set(['rectangle', 'ellipse', 'diamond']);
|
||||
const isContainer = CONTAINER_TYPES.has(params.type);
|
||||
const hasExplicitText = elementProps.text !== undefined;
|
||||
const effectiveTitle = title ?? (isContainer && !hasExplicitText ? 'Title' : undefined);
|
||||
const effectiveSubtitle = subtitle ?? (isContainer && !hasExplicitText && effectiveTitle ? 'Description' : undefined);
|
||||
|
||||
const effectiveText = effectiveTitle ?? elementProps.text;
|
||||
const effectiveFontSize = effectiveTitle ? (titleFontSize ?? 24) : (elementProps.fontSize ?? USER_PREFS.fontSize);
|
||||
const effectiveFontFamily = effectiveTitle
|
||||
const effectiveText = title ?? elementProps.text;
|
||||
const effectiveSubtitle = isContainer && subtitle ? subtitle : undefined;
|
||||
const effectiveFontSize = title ? (titleFontSize ?? 24) : (elementProps.fontSize ?? USER_PREFS.fontSize);
|
||||
const effectiveFontFamily = title
|
||||
? (normalizeFontFamily(titleFontFamily) ?? USER_PREFS.fontFamily)
|
||||
: (normalizedFont ?? USER_PREFS.fontFamily);
|
||||
|
||||
@@ -1125,7 +1129,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
const excalidrawElement = convertTextToLabel(element);
|
||||
|
||||
// Card layout: title (bound) + subtitle (grouped standalone text)
|
||||
const groupId = (effectiveTitle && effectiveSubtitle && isContainer) ? generateId() : undefined;
|
||||
const groupId = (title && effectiveSubtitle && isContainer) ? generateId() : undefined;
|
||||
|
||||
// Add groupId to container if using card layout
|
||||
if (groupId) {
|
||||
@@ -1865,7 +1869,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
|
||||
if (params.filePath) {
|
||||
const safePath = sanitizeFilePath(params.filePath);
|
||||
fs.writeFileSync(safePath, jsonString, 'utf-8');
|
||||
await fs.promises.writeFile(safePath, jsonString, 'utf-8');
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
@@ -1894,7 +1898,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
let sceneData: any;
|
||||
if (params.filePath) {
|
||||
const safeImportPath = sanitizeFilePath(params.filePath);
|
||||
const fileContent = fs.readFileSync(safeImportPath, 'utf-8');
|
||||
const fileContent = await fs.promises.readFile(safeImportPath, 'utf-8');
|
||||
sceneData = JSON.parse(fileContent);
|
||||
assertNoDangerousKeys(sceneData, 'import_scene filePath');
|
||||
} else if (params.data) {
|
||||
@@ -2006,9 +2010,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
if (params.filePath) {
|
||||
const safeImagePath = sanitizeFilePath(params.filePath);
|
||||
if (params.format === 'svg') {
|
||||
fs.writeFileSync(safeImagePath, result.data, 'utf-8');
|
||||
await fs.promises.writeFile(safeImagePath, result.data, 'utf-8');
|
||||
} else {
|
||||
fs.writeFileSync(safeImagePath, Buffer.from(result.data, 'base64'));
|
||||
await fs.promises.writeFile(safeImagePath, Buffer.from(result.data, 'base64'));
|
||||
}
|
||||
return {
|
||||
content: [{
|
||||
@@ -2411,7 +2415,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
const boundTextElements: Record<string, any>[] = [];
|
||||
let indexCounter = 0;
|
||||
|
||||
function makeBaseElement(el: any, rest: any): Record<string, any> {
|
||||
// Build a set of element IDs that are already native bound-text elements
|
||||
// (i.e. stored with containerId). For their containers, skip label→text
|
||||
// generation so we don't create duplicate text elements.
|
||||
const nativeBoundTextContainerIds = new Set<string>(
|
||||
urlExportElements
|
||||
.filter((e: any) => e.type === 'text' && e.containerId)
|
||||
.map((e: any) => e.containerId as string)
|
||||
);
|
||||
|
||||
function makeBaseElement(el: any, rest: any, storedVersion?: number): Record<string, any> {
|
||||
const isRoundedShape = el.type === 'rectangle' || el.type === 'diamond' || el.type === 'ellipse';
|
||||
return {
|
||||
...rest,
|
||||
angle: rest.angle ?? 0,
|
||||
@@ -2425,16 +2439,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
groupIds: rest.groupIds ?? [],
|
||||
frameId: rest.frameId ?? null,
|
||||
index: rest.index ?? `a${indexCounter++}`,
|
||||
roundness: rest.roundness ?? (
|
||||
el.type === 'rectangle' || el.type === 'diamond' || el.type === 'ellipse'
|
||||
? { type: 3 } : null
|
||||
),
|
||||
roundness: rest.roundness ?? (isRoundedShape ? { type: 3 } : null),
|
||||
seed: rest.seed ?? Math.floor(Math.random() * 2147483647),
|
||||
version: rest.version ?? 1,
|
||||
version: storedVersion ?? rest.version ?? 1,
|
||||
versionNonce: rest.versionNonce ?? Math.floor(Math.random() * 2147483647),
|
||||
isDeleted: false,
|
||||
boundElements: rest.boundElements ?? null,
|
||||
updated: Date.now(),
|
||||
updated: rest.updated ?? Date.now(),
|
||||
link: rest.link ?? null,
|
||||
locked: rest.locked ?? false
|
||||
};
|
||||
@@ -2449,46 +2460,43 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
...rest
|
||||
} = el as any;
|
||||
|
||||
const base = makeBaseElement(el, rest);
|
||||
const base = makeBaseElement(el, rest, _ver);
|
||||
|
||||
// Standalone text elements: keep text directly
|
||||
// Text elements: trust stored native fields, fill gaps only
|
||||
if (el.type === 'text') {
|
||||
base.text = text ?? '';
|
||||
base.originalText = text ?? '';
|
||||
base.fontSize = rest.fontSize ?? USER_PREFS.fontSize;
|
||||
base.fontFamily = rest.fontFamily ?? USER_PREFS.fontFamily;
|
||||
base.textAlign = rest.textAlign ?? 'center';
|
||||
base.verticalAlign = rest.verticalAlign ?? (rest.containerId ? 'top' : 'middle');
|
||||
base.autoResize = rest.autoResize ?? true;
|
||||
base.lineHeight = rest.lineHeight ?? 1.25;
|
||||
base.containerId = rest.containerId ?? null;
|
||||
base.text = text ?? rest.text ?? '';
|
||||
base.originalText = rest.originalText ?? base.text;
|
||||
base.fontSize = rest.fontSize ?? USER_PREFS.fontSize;
|
||||
base.fontFamily = rest.fontFamily ?? USER_PREFS.fontFamily;
|
||||
base.textAlign = rest.textAlign ?? 'left';
|
||||
base.verticalAlign = rest.verticalAlign ?? (rest.containerId ? 'middle' : 'top');
|
||||
base.autoResize = rest.autoResize ?? true;
|
||||
base.lineHeight = rest.lineHeight ?? 1.25;
|
||||
base.containerId = rest.containerId ?? null;
|
||||
cleanedExportElements.push(base);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Arrows: server already resolved bindings (start/end → startBinding/endBinding + positions)
|
||||
// Arrows/lines: trust stored fields, fill gaps only
|
||||
if (el.type === 'arrow' || el.type === 'line') {
|
||||
base.points = rest.points ?? [[0, 0], [100, 0]];
|
||||
base.lastCommittedPoint = null;
|
||||
// Preserve server-resolved bindings with fixedPoint for excalidraw.com
|
||||
if (rest.startBinding) {
|
||||
base.startBinding = { ...rest.startBinding, fixedPoint: rest.startBinding.fixedPoint ?? null };
|
||||
} else {
|
||||
base.startBinding = null;
|
||||
}
|
||||
if (rest.endBinding) {
|
||||
base.endBinding = { ...rest.endBinding, fixedPoint: rest.endBinding.fixedPoint ?? null };
|
||||
} else {
|
||||
base.endBinding = null;
|
||||
}
|
||||
base.points = rest.points ?? [[0, 0], [100, 0]];
|
||||
base.lastCommittedPoint = rest.lastCommittedPoint ?? null;
|
||||
base.startBinding = rest.startBinding
|
||||
? { ...rest.startBinding, fixedPoint: rest.startBinding.fixedPoint ?? null }
|
||||
: null;
|
||||
base.endBinding = rest.endBinding
|
||||
? { ...rest.endBinding, fixedPoint: rest.endBinding.fixedPoint ?? null }
|
||||
: null;
|
||||
base.startArrowhead = rest.startArrowhead ?? null;
|
||||
base.endArrowhead = rest.endArrowhead ?? (el.type === 'arrow' ? 'arrow' : null);
|
||||
base.elbowed = rest.elbowed ?? false;
|
||||
base.endArrowhead = rest.endArrowhead ?? (el.type === 'arrow' ? 'arrow' : null);
|
||||
base.elbowed = rest.elbowed ?? false;
|
||||
}
|
||||
|
||||
// Generate bound text element for label on shapes and arrows
|
||||
// Generate bound text element for label on shapes and arrows.
|
||||
// Skip if the shape already has a native bound text element stored
|
||||
// (containerId-based) — generating one here would create a duplicate.
|
||||
const labelText = label?.text || text;
|
||||
if (labelText) {
|
||||
if (labelText && !nativeBoundTextContainerIds.has(base.id)) {
|
||||
const textId = `${base.id}-label`;
|
||||
// Add binding reference to parent
|
||||
base.boundElements = [
|
||||
@@ -2880,6 +2888,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return { tools };
|
||||
});
|
||||
|
||||
} // end registerHandlers
|
||||
|
||||
// Start server
|
||||
async function runServer(): Promise<void> {
|
||||
try {
|
||||
@@ -2902,12 +2912,37 @@ async function runServer(): Promise<void> {
|
||||
|
||||
applyTenant(workspacePath);
|
||||
|
||||
try {
|
||||
await startCanvasServer();
|
||||
logger.info('Canvas server started — lifecycle managed by MCP process');
|
||||
} catch (canvasError) {
|
||||
logger.warn('Canvas server failed to start:', (canvasError as Error).message);
|
||||
logger.warn('MCP tools will work without real-time canvas sync');
|
||||
// Skip local canvas server when targeting a remote EXPRESS_SERVER_URL
|
||||
if (!process.env.EXPRESS_SERVER_URL) {
|
||||
try {
|
||||
await startCanvasServer();
|
||||
logger.info('Canvas server started — lifecycle managed by MCP process');
|
||||
} catch (canvasError) {
|
||||
logger.warn('Canvas server failed to start:', (canvasError as Error).message);
|
||||
logger.warn('MCP tools will work without real-time canvas sync');
|
||||
}
|
||||
} else {
|
||||
logger.info('Remote canvas server configured, skipping local canvas start');
|
||||
}
|
||||
|
||||
// HTTP mode: one shared process serves many clients over Streamable HTTP.
|
||||
// Each client session gets its own MCP server via createMcpServer. The MCP
|
||||
// endpoint listens on its own port (MCP_HTTP_PORT) so it stays reachable
|
||||
// even when the canvas port is reused by another process.
|
||||
if (resolveTransportMode(process.env) === 'http') {
|
||||
const mcpPort = parseInt(process.env['MCP_HTTP_PORT'] || '3031', 10);
|
||||
await startMcpHttpServer(createMcpServer, mcpPort);
|
||||
logger.info(`Excalidraw MCP server running on HTTP (Streamable) at http://127.0.0.1:${mcpPort}/mcp`);
|
||||
|
||||
const shutdownHttp = async () => {
|
||||
logger.info('Shutting down (HTTP mode)');
|
||||
try { await stopCanvasServer(); } catch {}
|
||||
try { closeDb(); } catch {}
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', shutdownHttp);
|
||||
process.on('SIGINT', shutdownHttp);
|
||||
return;
|
||||
}
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
@@ -2968,7 +3003,8 @@ async function runServer(): Promise<void> {
|
||||
}
|
||||
|
||||
server.onclose = shutdown;
|
||||
process.stdin.on('close', shutdown);
|
||||
// Delay exit on stdin close — prevents crash if CodeWhale briefly disconnects during init
|
||||
process.stdin.on('close', () => setTimeout(shutdown, 1000));
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Streamable HTTP transport wiring for the MCP server.
|
||||
*
|
||||
* Lets a single long-lived process serve many MCP clients over HTTP instead of
|
||||
* each client spawning its own stdio process. Each client session gets its own
|
||||
* MCP `Server` instance (cheap in-process object) routed by `mcp-session-id`.
|
||||
*/
|
||||
import type { Application, Request, Response } from 'express';
|
||||
import type { Server as HttpServer } from 'node:http';
|
||||
import express from 'express';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
|
||||
export type TransportMode = 'stdio' | 'http';
|
||||
|
||||
/** Decide transport from the environment. stdio is the default (back-compat). */
|
||||
export function resolveTransportMode(env: NodeJS.ProcessEnv): TransportMode {
|
||||
return (env['MCP_TRANSPORT'] || '').toLowerCase() === 'http' ? 'http' : 'stdio';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount POST/GET/DELETE `/mcp` routes on an existing Express app.
|
||||
*
|
||||
* @param app the Express app (shares the canvas server's httpServer)
|
||||
* @param createServer factory returning a fresh MCP `Server` per session
|
||||
*/
|
||||
export function mountMcpRoutes(app: Application, createServer: () => Server): void {
|
||||
const transports: Record<string, StreamableHTTPServerTransport> = {};
|
||||
// Dedicated parser so /mcp accepts larger bodies than the canvas API's 100kb cap.
|
||||
const jsonParser = express.json({ limit: '5mb' });
|
||||
|
||||
app.post('/mcp', jsonParser, async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
let transport: StreamableHTTPServerTransport;
|
||||
|
||||
if (sessionId && transports[sessionId]) {
|
||||
transport = transports[sessionId];
|
||||
} else if (!sessionId && isInitializeRequest(req.body)) {
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
// Plain JSON responses (no SSE) — clean request/response for Claude clients.
|
||||
enableJsonResponse: true,
|
||||
onsessioninitialized: (sid) => {
|
||||
transports[sid] = transport;
|
||||
},
|
||||
});
|
||||
transport.onclose = () => {
|
||||
if (transport.sessionId) delete transports[transport.sessionId];
|
||||
};
|
||||
const server = createServer();
|
||||
await server.connect(transport);
|
||||
} else {
|
||||
res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32000, message: 'Bad Request: no valid session ID' },
|
||||
id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
});
|
||||
|
||||
const handleSessionRequest = async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (!sessionId || !transports[sessionId]) {
|
||||
res.status(400).send('Invalid or missing session ID');
|
||||
return;
|
||||
}
|
||||
await transports[sessionId]!.handleRequest(req, res);
|
||||
};
|
||||
|
||||
app.get('/mcp', handleSessionRequest);
|
||||
app.delete('/mcp', handleSessionRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a dedicated HTTP server hosting the MCP `/mcp` endpoint on its own port.
|
||||
*
|
||||
* Kept independent of the canvas server so MCP stays reachable even when the
|
||||
* canvas port is owned/reused by another process.
|
||||
*
|
||||
* @returns the listening http.Server (resolves once bound)
|
||||
*/
|
||||
export function startMcpHttpServer(
|
||||
createServer: () => Server,
|
||||
port: number,
|
||||
host = '127.0.0.1',
|
||||
): Promise<HttpServer> {
|
||||
const app = express();
|
||||
mountMcpRoutes(app, createServer);
|
||||
return new Promise<HttpServer>((resolve, reject) => {
|
||||
const httpServer = app.listen(port, host, () => resolve(httpServer));
|
||||
httpServer.on('error', reject);
|
||||
});
|
||||
}
|
||||
+140
-9
@@ -28,7 +28,7 @@ import {
|
||||
BroadcastResult
|
||||
} from './types.js';
|
||||
import * as store from './db.js';
|
||||
import { initDb, listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, getTenantById, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant, getProjectForTenant, getCurrentSyncVersion, getChangesSince, setActiveProject as dbSetActiveProject, getActiveProject as dbGetActiveProject, getActiveProjectId as dbGetActiveProjectId, getActiveTenantId as dbGetActiveTenantId, listProjects as dbListProjects, createProject as dbCreateProject, deleteProject as dbDeleteProject, getElementCountForProject as dbGetElementCountForProject } from './db.js';
|
||||
import { initDb, listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, getTenantById, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant, getProjectForTenant, getCurrentSyncVersion, getChangesSince, setActiveProject as dbSetActiveProject, getActiveProject as dbGetActiveProject, getActiveProjectId as dbGetActiveProjectId, getActiveTenantId as dbGetActiveTenantId, listProjects as dbListProjects, createProject as dbCreateProject, deleteProject as dbDeleteProject, deleteTenant as dbDeleteTenant, getElementCountForProject as dbGetElementCountForProject } from './db.js';
|
||||
import { z } from 'zod';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
@@ -39,6 +39,7 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app: Application = express();
|
||||
app.set('trust proxy', 1);
|
||||
const httpServer = createServer(app);
|
||||
const wss = new WebSocketServer({ server: httpServer, verifyClient: verifyWsClient });
|
||||
|
||||
@@ -499,7 +500,7 @@ const ElementSharedFieldsSchema = z.object({
|
||||
fileId: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
scale: z.tuple([z.number(), z.number()]).optional(),
|
||||
});
|
||||
}).passthrough(); // preserve all native Excalidraw fields not listed above
|
||||
|
||||
const CreateElementSchema = ElementSharedFieldsSchema.extend({
|
||||
id: z.string().optional(),
|
||||
@@ -559,19 +560,24 @@ app.post('/api/elements', async (req: Request, res: Response) => {
|
||||
version: 1
|
||||
};
|
||||
|
||||
const sv = store.setElement(id, element, projId);
|
||||
const { container, boundText } = materializeLabel(element);
|
||||
const sv = store.setElement(container.id, container, projId);
|
||||
if (boundText) {
|
||||
store.setElement(boundText.id, boundText, projId);
|
||||
}
|
||||
|
||||
const scope = resolveScope(req);
|
||||
const message: ElementCreatedMessage = {
|
||||
type: 'element_created',
|
||||
element: element
|
||||
element: container
|
||||
};
|
||||
message['sync_version'] = sv;
|
||||
const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
element: element,
|
||||
element: container,
|
||||
boundTextElement: boundText ?? undefined,
|
||||
syncedToCanvas: ackResult.acked,
|
||||
canvasStatus: {
|
||||
connectedBrowsers: ackResult.delivered,
|
||||
@@ -620,19 +626,39 @@ app.put('/api/elements/:id', async (req: Request, res: Response) => {
|
||||
version: (existingElement.version || 0) + 1
|
||||
};
|
||||
|
||||
const sv = store.setElement(id, updatedElement, projId);
|
||||
// Find existing bound text ID so we update rather than create a duplicate
|
||||
const existingBound = (existingElement as any).boundElements as Array<{ id: string; type: string }> | null;
|
||||
const existingBoundTextId = existingBound?.find((b) => b.type === 'text')?.id;
|
||||
|
||||
const { container, boundText } = materializeLabel(updatedElement, existingBoundTextId);
|
||||
const sv = store.setElement(id, container, projId);
|
||||
|
||||
if (boundText) {
|
||||
const existingBT = existingBoundTextId ? store.getElement(existingBoundTextId, projId) : null;
|
||||
const btToSave = existingBT
|
||||
? {
|
||||
...existingBT,
|
||||
text: boundText.text,
|
||||
originalText: boundText.originalText,
|
||||
updatedAt: boundText.updatedAt,
|
||||
version: (existingBT.version || 0) + 1
|
||||
}
|
||||
: boundText;
|
||||
store.setElement(btToSave.id, btToSave as ServerElement, projId);
|
||||
}
|
||||
|
||||
const scope = resolveScope(req);
|
||||
const message: ElementUpdatedMessage = {
|
||||
type: 'element_updated',
|
||||
element: updatedElement
|
||||
element: container
|
||||
};
|
||||
message['sync_version'] = sv;
|
||||
const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
element: updatedElement,
|
||||
element: container,
|
||||
boundTextElement: boundText ?? undefined,
|
||||
syncedToCanvas: ackResult.acked,
|
||||
canvasStatus: {
|
||||
connectedBrowsers: ackResult.delivered,
|
||||
@@ -847,6 +873,68 @@ function computeEdgePoint(
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: materialize a shape's label/text into a native bound text element.
|
||||
// When a container shape arrives with `label.text` or a `text` field, we create
|
||||
// a proper Excalidraw bound-text element (containerId ↔ boundElements) instead
|
||||
// of storing the MCP label format. Returns the cleaned container and the new
|
||||
// bound-text element (null if nothing to materialize).
|
||||
function materializeLabel(
|
||||
element: ServerElement,
|
||||
existingBoundTextId?: string
|
||||
): { container: ServerElement; boundText: ServerElement | null } {
|
||||
const NON_CONTAINER_TYPES = new Set(['text', 'arrow', 'line', 'freedraw', 'image']);
|
||||
if (NON_CONTAINER_TYPES.has(element.type ?? '')) {
|
||||
return { container: element, boundText: null };
|
||||
}
|
||||
|
||||
// Accept both { label: { text } } (MCP format) and { text } (direct) formats
|
||||
const labelText: string | undefined =
|
||||
((element as any).label as { text?: string } | undefined)?.text ??
|
||||
(element.type !== 'text' ? (element as any).text as string | undefined : undefined);
|
||||
|
||||
if (!labelText) {
|
||||
return { container: element, boundText: null };
|
||||
}
|
||||
|
||||
const boundTextId = existingBoundTextId ?? `${element.id}-label`;
|
||||
|
||||
const boundText: ServerElement = {
|
||||
id: boundTextId,
|
||||
type: 'text',
|
||||
x: element.x ?? 0,
|
||||
y: element.y ?? 0,
|
||||
width: element.width ?? 200,
|
||||
height: element.height ?? 80,
|
||||
text: labelText,
|
||||
originalText: labelText,
|
||||
fontSize: 20,
|
||||
fontFamily: 5,
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
autoResize: true,
|
||||
lineHeight: 1.25,
|
||||
containerId: element.id,
|
||||
strokeColor: (element as any).strokeColor ?? '#1e1e1e',
|
||||
opacity: (element as any).opacity ?? 100,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
version: 1,
|
||||
} as unknown as ServerElement;
|
||||
|
||||
// Strip label/text from container, set boundElements
|
||||
const { label: _label, text: _text, ...containerRest } = element as any;
|
||||
const existingBound: Array<{ id: string; type: string }> = containerRest.boundElements ?? [];
|
||||
const alreadyBound = existingBound.some((b) => b.id === boundTextId);
|
||||
const container: ServerElement = {
|
||||
...containerRest,
|
||||
boundElements: alreadyBound
|
||||
? existingBound
|
||||
: [...existingBound, { id: boundTextId, type: 'text' }],
|
||||
};
|
||||
|
||||
return { container, boundText };
|
||||
}
|
||||
|
||||
// Helper: resolve arrow bindings in a batch
|
||||
function resolveArrowBindings(batchElements: ServerElement[], projectId?: string): void {
|
||||
const elementMap = new Map<string, ServerElement>();
|
||||
@@ -953,7 +1041,9 @@ app.post('/api/elements/batch', async (req: Request, res: Response) => {
|
||||
version: 1
|
||||
};
|
||||
|
||||
createdElements.push(element);
|
||||
const { container, boundText } = materializeLabel(element);
|
||||
createdElements.push(container);
|
||||
if (boundText) createdElements.push(boundText);
|
||||
});
|
||||
|
||||
resolveArrowBindings(createdElements, projId);
|
||||
@@ -1590,6 +1680,47 @@ app.put('/api/tenant/active', (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/tenants/:id', (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
dbDeleteTenant(id);
|
||||
broadcast({ type: 'tenant_deleted', tenantId: id } as any);
|
||||
res.json({ success: true, tenantId: id });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting tenant:', error);
|
||||
res.status(400).json({ success: false, error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/tenants/batch-delete', destructiveRateLimit, (req: Request, res: Response) => {
|
||||
try {
|
||||
const { ids } = req.body as { ids?: string[] };
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
res.status(400).json({ success: false, error: 'ids must be a non-empty array' });
|
||||
return;
|
||||
}
|
||||
if (ids.length > 50) {
|
||||
res.status(400).json({ success: false, error: 'Cannot delete more than 50 tenants at once' });
|
||||
return;
|
||||
}
|
||||
const results: { id: string; deleted: boolean; error?: string }[] = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
dbDeleteTenant(id);
|
||||
broadcast({ type: 'tenant_deleted', tenantId: id } as any);
|
||||
results.push({ id, deleted: true });
|
||||
} catch (err) {
|
||||
results.push({ id, deleted: false, error: (err as Error).message });
|
||||
}
|
||||
}
|
||||
const deletedCount = results.filter(r => r.deleted).length;
|
||||
res.json({ success: true, deletedCount, results });
|
||||
} catch (error) {
|
||||
logger.error('Error batch-deleting tenants:', error);
|
||||
res.status(500).json({ success: false, error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/projects', (req: Request, res: Response) => {
|
||||
try {
|
||||
const projects = dbListProjects();
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface ExcalidrawElementBase {
|
||||
customData?: Record<string, any> | null;
|
||||
boundElements?: readonly ExcalidrawBoundElement[] | null;
|
||||
updated?: number;
|
||||
index?: string;
|
||||
containerId?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import express, { type Express } from 'express';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { Server as HttpServer } from 'node:http';
|
||||
import { mountMcpRoutes, resolveTransportMode, startMcpHttpServer } from '../../src/mcp-http.js';
|
||||
|
||||
// A minimal real MCP server so the SDK initialize handshake succeeds.
|
||||
function makeServer(): Server {
|
||||
const server = new Server(
|
||||
{ name: 'test-shared-server', version: '1.0.0' },
|
||||
{ capabilities: { tools: {} } }
|
||||
);
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [] }));
|
||||
return server;
|
||||
}
|
||||
|
||||
const INIT_BODY = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-06-18',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test-client', version: '1.0.0' },
|
||||
},
|
||||
};
|
||||
|
||||
const ACCEPT = 'application/json, text/event-stream';
|
||||
|
||||
describe('resolveTransportMode', () => {
|
||||
it('defaults to stdio when MCP_TRANSPORT is unset', () => {
|
||||
expect(resolveTransportMode({})).toBe('stdio');
|
||||
});
|
||||
|
||||
it('returns http when MCP_TRANSPORT=http (case-insensitive)', () => {
|
||||
expect(resolveTransportMode({ MCP_TRANSPORT: 'http' })).toBe('http');
|
||||
expect(resolveTransportMode({ MCP_TRANSPORT: 'HTTP' })).toBe('http');
|
||||
});
|
||||
|
||||
it('falls back to stdio for any other value', () => {
|
||||
expect(resolveTransportMode({ MCP_TRANSPORT: 'sse' })).toBe('stdio');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mountMcpRoutes', () => {
|
||||
let app: Express;
|
||||
let serverInstances: number;
|
||||
|
||||
beforeEach(() => {
|
||||
serverInstances = 0;
|
||||
app = express();
|
||||
mountMcpRoutes(app, () => {
|
||||
serverInstances += 1;
|
||||
return makeServer();
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a session on initialize and returns a session id header', async () => {
|
||||
const res = await request(app)
|
||||
.post('/mcp')
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Accept', ACCEPT)
|
||||
.send(INIT_BODY);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['mcp-session-id']).toBeTruthy();
|
||||
expect(serverInstances).toBe(1);
|
||||
});
|
||||
|
||||
it('gives each initialize its own isolated session id and server instance', async () => {
|
||||
const a = await request(app).post('/mcp').set('Accept', ACCEPT).send(INIT_BODY);
|
||||
const b = await request(app).post('/mcp').set('Accept', ACCEPT).send(INIT_BODY);
|
||||
|
||||
expect(a.headers['mcp-session-id']).toBeTruthy();
|
||||
expect(b.headers['mcp-session-id']).toBeTruthy();
|
||||
expect(a.headers['mcp-session-id']).not.toBe(b.headers['mcp-session-id']);
|
||||
expect(serverInstances).toBe(2);
|
||||
});
|
||||
|
||||
it('rejects a POST with no session id that is not an initialize request', async () => {
|
||||
const res = await request(app)
|
||||
.post('/mcp')
|
||||
.set('Accept', ACCEPT)
|
||||
.send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a GET with an unknown session id', async () => {
|
||||
const res = await request(app)
|
||||
.get('/mcp')
|
||||
.set('Accept', ACCEPT)
|
||||
.set('mcp-session-id', 'does-not-exist');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('tears down a session on DELETE with a valid session id', async () => {
|
||||
const init = await request(app).post('/mcp').set('Accept', ACCEPT).send(INIT_BODY);
|
||||
const sid = init.headers['mcp-session-id'];
|
||||
|
||||
const del = await request(app)
|
||||
.delete('/mcp')
|
||||
.set('Accept', ACCEPT)
|
||||
.set('mcp-session-id', sid);
|
||||
|
||||
expect(del.status).toBeLessThan(500);
|
||||
|
||||
// After teardown the session id is no longer valid.
|
||||
const after = await request(app)
|
||||
.get('/mcp')
|
||||
.set('Accept', ACCEPT)
|
||||
.set('mcp-session-id', sid);
|
||||
expect(after.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startMcpHttpServer', () => {
|
||||
let httpServer: HttpServer;
|
||||
|
||||
afterEach(() => {
|
||||
httpServer?.close();
|
||||
});
|
||||
|
||||
it('listens on its own port and serves initialize', async () => {
|
||||
httpServer = await startMcpHttpServer(makeServer, 0); // port 0 = ephemeral
|
||||
const addr = httpServer.address();
|
||||
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
||||
expect(port).toBeGreaterThan(0);
|
||||
|
||||
const res = await request(`http://127.0.0.1:${port}`)
|
||||
.post('/mcp')
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Accept', ACCEPT)
|
||||
.send(INIT_BODY);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['mcp-session-id']).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,581 @@
|
||||
/**
|
||||
* Non-regression tests for native Excalidraw field preservation.
|
||||
*
|
||||
* Covers:
|
||||
* - Universal fields populated on every write (seed, versionNonce, index, etc.)
|
||||
* - Type-specific fields: text, arrow, line, image, freedraw
|
||||
* - roundness defaults: { type: 3 } for closed shapes, null for others
|
||||
* - Zod passthrough: unknown native fields not stripped by schema
|
||||
* - repairContainerBinding: both sides of containerId ↔ boundElements kept in sync
|
||||
* across all write paths (create, batch-create, update, sync/v2)
|
||||
* - Export: stored version/updated preserved; no duplicate text on export
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import {
|
||||
initDb, closeDb,
|
||||
getElement, getAllElements,
|
||||
setActiveTenant,
|
||||
} from '../../src/db.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
let dbPath: string;
|
||||
let app: any;
|
||||
|
||||
const UNIVERSAL_FIELDS = [
|
||||
'angle', 'strokeColor', 'backgroundColor', 'fillStyle',
|
||||
'strokeWidth', 'strokeStyle', 'roughness', 'opacity',
|
||||
'groupIds', 'frameId', 'seed', 'versionNonce',
|
||||
'isDeleted', 'updated', 'link', 'locked', 'boundElements', 'index',
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = path.join(
|
||||
os.tmpdir(),
|
||||
`excalidraw-native-fields-${Date.now()}-${Math.random().toString(36).slice(2)}.db`
|
||||
);
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
const mod = await import('../../src/server.js');
|
||||
app = mod.default;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function createElement(body: Record<string, any>) {
|
||||
const res = await request(app).post('/api/elements').send(body);
|
||||
expect(res.status).toBe(200);
|
||||
return res.body.element as Record<string, any>;
|
||||
}
|
||||
|
||||
async function batchCreate(elements: Record<string, any>[]) {
|
||||
const res = await request(app).post('/api/elements/batch').send({ elements });
|
||||
expect(res.status).toBe(200);
|
||||
return res.body.elements as Record<string, any>[];
|
||||
}
|
||||
|
||||
async function syncV2(changes: { id: string; action: string; element?: Record<string, any> }[]) {
|
||||
const res = await request(app).post('/api/elements/sync/v2').send({
|
||||
lastSyncVersion: 0,
|
||||
changes,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
async function updateElement(id: string, updates: Record<string, any>) {
|
||||
const res = await request(app).put(`/api/elements/${id}`).send({ id, ...updates });
|
||||
expect(res.status).toBe(200);
|
||||
return res.body.element as Record<string, any>;
|
||||
}
|
||||
|
||||
function dbEl(id: string): Record<string, any> {
|
||||
const el = getElement(id);
|
||||
expect(el, `Element ${id} not found in DB`).toBeDefined();
|
||||
return el as Record<string, any>;
|
||||
}
|
||||
|
||||
// ── Universal fields ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('universal fields — filled on create', () => {
|
||||
it('populates all universal fields with correct default values for a minimal rectangle', async () => {
|
||||
await createElement({ type: 'rectangle', id: 'u-rect', x: 0, y: 0, width: 100, height: 50 });
|
||||
const el = dbEl('u-rect');
|
||||
|
||||
// Presence
|
||||
for (const field of UNIVERSAL_FIELDS) {
|
||||
expect(el, `field "${field}" missing`).toHaveProperty(field);
|
||||
}
|
||||
|
||||
// Specific default values
|
||||
expect(el.angle).toBe(0);
|
||||
expect(el.strokeColor).toBe('#1e1e1e');
|
||||
expect(el.backgroundColor).toBe('transparent');
|
||||
expect(el.fillStyle).toBe('solid');
|
||||
expect(el.strokeWidth).toBe(2);
|
||||
expect(el.strokeStyle).toBe('solid');
|
||||
expect(el.roughness).toBe(1);
|
||||
expect(el.opacity).toBe(100);
|
||||
expect(el.groupIds).toEqual([]);
|
||||
expect(el.frameId).toBeNull();
|
||||
expect(el.link).toBeNull();
|
||||
expect(el.locked).toBe(false);
|
||||
expect(el.isDeleted).toBe(false);
|
||||
expect(el.boundElements).toBeNull();
|
||||
expect(typeof el.seed).toBe('number');
|
||||
expect(typeof el.versionNonce).toBe('number');
|
||||
expect(typeof el.updated).toBe('number');
|
||||
expect(typeof el.index).toBe('string');
|
||||
expect(el.index.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('populates universal fields via batch create', async () => {
|
||||
await batchCreate([{ type: 'rectangle', id: 'u-batch', x: 0, y: 0, width: 100, height: 50 }]);
|
||||
const el = dbEl('u-batch');
|
||||
expect(typeof el.seed).toBe('number');
|
||||
expect(typeof el.index).toBe('string');
|
||||
expect(el.isDeleted).toBe(false);
|
||||
});
|
||||
|
||||
it('populates universal fields via sync/v2 upsert', async () => {
|
||||
await syncV2([{
|
||||
id: 'u-sync', action: 'upsert',
|
||||
element: { id: 'u-sync', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
}]);
|
||||
const el = dbEl('u-sync');
|
||||
expect(typeof el.seed).toBe('number');
|
||||
expect(typeof el.index).toBe('string');
|
||||
expect(el.isDeleted).toBe(false);
|
||||
});
|
||||
|
||||
it('does not overwrite existing seed/versionNonce/index on update', async () => {
|
||||
await createElement({ type: 'rectangle', id: 'u-stable', x: 0, y: 0, width: 100, height: 50 });
|
||||
const before = dbEl('u-stable');
|
||||
await updateElement('u-stable', { x: 50 });
|
||||
const after = dbEl('u-stable');
|
||||
expect(after.seed).toBe(before.seed);
|
||||
expect(after.index).toBe(before.index);
|
||||
});
|
||||
|
||||
it('preserves caller-supplied seed and index', async () => {
|
||||
await createElement({
|
||||
type: 'rectangle', id: 'u-supplied', x: 0, y: 0, width: 100, height: 50,
|
||||
seed: 12345678, index: 'aZZ',
|
||||
});
|
||||
const el = dbEl('u-supplied');
|
||||
expect(el.seed).toBe(12345678);
|
||||
expect(el.index).toBe('aZZ');
|
||||
});
|
||||
});
|
||||
|
||||
// ── roundness ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('roundness defaults', () => {
|
||||
it.each(['rectangle', 'diamond', 'ellipse'])(
|
||||
'%s gets roundness { type: 3 } by default',
|
||||
async (type) => {
|
||||
await createElement({ type, id: `rnd-${type}`, x: 0, y: 0, width: 100, height: 50 });
|
||||
const el = dbEl(`rnd-${type}`);
|
||||
expect(el.roundness).toEqual({ type: 3 });
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['arrow', 'line', 'text'])(
|
||||
'%s gets roundness null by default',
|
||||
async (type) => {
|
||||
const extra: Record<string, any> = type === 'text' ? { text: 'hi' } : {};
|
||||
await createElement({ type, id: `rnd-${type}`, x: 0, y: 0, width: 100, height: 50, ...extra });
|
||||
const el = dbEl(`rnd-${type}`);
|
||||
expect(el.roundness).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it('preserves explicit roundness: null on a rectangle', async () => {
|
||||
await createElement({
|
||||
type: 'rectangle', id: 'rnd-explicit-null', x: 0, y: 0, width: 100, height: 50,
|
||||
roundness: null,
|
||||
});
|
||||
const el = dbEl('rnd-explicit-null');
|
||||
expect(el.roundness).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Type-specific: text ───────────────────────────────────────────────────────
|
||||
|
||||
describe('text element — type-specific fields', () => {
|
||||
it('fills all type-specific fields with correct default values', async () => {
|
||||
await createElement({ type: 'text', id: 'txt-1', x: 0, y: 0, text: 'hello' });
|
||||
const el = dbEl('txt-1');
|
||||
expect(el.text).toBe('hello');
|
||||
expect(el.originalText).toBe('hello');
|
||||
expect(el.fontSize).toBe(20);
|
||||
expect(el.fontFamily).toBe(5);
|
||||
expect(el.textAlign).toBe('left');
|
||||
expect(el.verticalAlign).toBe('top'); // no containerId
|
||||
expect(el.autoResize).toBe(true);
|
||||
expect(el.lineHeight).toBe(1.25);
|
||||
expect(el.containerId).toBeNull();
|
||||
});
|
||||
|
||||
it('defaults text to empty string when omitted', async () => {
|
||||
await createElement({ type: 'text', id: 'txt-empty', x: 0, y: 0 });
|
||||
const el = dbEl('txt-empty');
|
||||
expect(el.text).toBe('');
|
||||
expect(el.originalText).toBe('');
|
||||
});
|
||||
|
||||
it('sets verticalAlign to "middle" when containerId is present', async () => {
|
||||
await createElement({ type: 'rectangle', id: 'txt-container', x: 0, y: 0, width: 200, height: 80 });
|
||||
await createElement({
|
||||
type: 'text', id: 'txt-bound', x: 10, y: 30, text: 'bound',
|
||||
containerId: 'txt-container',
|
||||
});
|
||||
const el = dbEl('txt-bound');
|
||||
expect(el.verticalAlign).toBe('middle');
|
||||
});
|
||||
|
||||
it('preserves caller-supplied autoResize: false and lineHeight', async () => {
|
||||
await createElement({
|
||||
type: 'text', id: 'txt-custom', x: 0, y: 0, text: 'hi',
|
||||
autoResize: false, lineHeight: 1.5,
|
||||
});
|
||||
const el = dbEl('txt-custom');
|
||||
expect(el.autoResize).toBe(false);
|
||||
expect(el.lineHeight).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Type-specific: arrow ──────────────────────────────────────────────────────
|
||||
|
||||
describe('arrow element — type-specific fields', () => {
|
||||
it('fills points, lastCommittedPoint, startBinding, endBinding, endArrowhead, elbowed', async () => {
|
||||
await createElement({ type: 'arrow', id: 'arr-1', x: 0, y: 0, width: 100, height: 0 });
|
||||
const el = dbEl('arr-1');
|
||||
expect(Array.isArray(el.points)).toBe(true);
|
||||
expect(el.lastCommittedPoint).toBeNull();
|
||||
expect(el.startBinding).toBeNull();
|
||||
expect(el.endBinding).toBeNull();
|
||||
expect(el.endArrowhead).toBe('arrow');
|
||||
expect(el.startArrowhead).toBeNull();
|
||||
expect(el.elbowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('line element — type-specific fields', () => {
|
||||
it('fills all type-specific fields with correct default values', async () => {
|
||||
await createElement({ type: 'line', id: 'line-1', x: 0, y: 0, width: 100, height: 0 });
|
||||
const el = dbEl('line-1');
|
||||
expect(Array.isArray(el.points)).toBe(true);
|
||||
expect(el.lastCommittedPoint).toBeNull();
|
||||
expect(el.startBinding).toBeNull();
|
||||
expect(el.endBinding).toBeNull();
|
||||
expect(el.startArrowhead).toBeNull();
|
||||
expect(el.endArrowhead).toBeNull(); // null for line, 'arrow' only for arrow type
|
||||
expect(el.elbowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Type-specific: image ──────────────────────────────────────────────────────
|
||||
|
||||
describe('image element — type-specific fields', () => {
|
||||
it('fills status and scale', async () => {
|
||||
await createElement({ type: 'image', id: 'img-1', x: 0, y: 0, width: 100, height: 100 });
|
||||
const el = dbEl('img-1');
|
||||
expect(el.status).toBe('pending');
|
||||
expect(el.scale).toEqual([1, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Type-specific: freedraw ───────────────────────────────────────────────────
|
||||
|
||||
describe('freedraw element — type-specific fields', () => {
|
||||
it('fills points, pressures, simulatePressure, lastCommittedPoint', async () => {
|
||||
await createElement({ type: 'freedraw', id: 'fd-1', x: 0, y: 0, width: 10, height: 10 });
|
||||
const el = dbEl('fd-1');
|
||||
expect(Array.isArray(el.points)).toBe(true);
|
||||
expect(Array.isArray(el.pressures)).toBe(true);
|
||||
expect(el.simulatePressure).toBe(true);
|
||||
expect(el.lastCommittedPoint).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Zod passthrough ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('Zod schema passthrough — unknown native fields preserved', () => {
|
||||
it('preserves extra Excalidraw fields not in schema (e.g. customData)', async () => {
|
||||
await createElement({
|
||||
type: 'rectangle', id: 'pass-1', x: 0, y: 0, width: 100, height: 50,
|
||||
customData: { myKey: 'myValue' },
|
||||
});
|
||||
const el = dbEl('pass-1');
|
||||
expect(el.customData).toEqual({ myKey: 'myValue' });
|
||||
});
|
||||
|
||||
it('preserves autoResize passed to a non-text element without stripping', async () => {
|
||||
// autoResize is not in the shared schema explicitly — should pass through
|
||||
await createElement({
|
||||
type: 'rectangle', id: 'pass-2', x: 0, y: 0, width: 100, height: 50,
|
||||
autoResize: true,
|
||||
});
|
||||
const el = dbEl('pass-2');
|
||||
expect(el.autoResize).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── repairContainerBinding ────────────────────────────────────────────────────
|
||||
|
||||
describe('repairContainerBinding — bidirectional binding enforced on all write paths', () => {
|
||||
it('POST /api/elements: text with containerId repairs container.boundElements', async () => {
|
||||
await createElement({ type: 'rectangle', id: 'rb-box', x: 0, y: 0, width: 200, height: 80 });
|
||||
await createElement({
|
||||
type: 'text', id: 'rb-txt', x: 10, y: 30, text: 'hi',
|
||||
containerId: 'rb-box',
|
||||
});
|
||||
const box = dbEl('rb-box');
|
||||
expect(Array.isArray(box.boundElements)).toBe(true);
|
||||
expect((box.boundElements as any[]).some((b: any) => b.id === 'rb-txt')).toBe(true);
|
||||
});
|
||||
|
||||
it('batch create: repairs binding for all text elements in the batch', async () => {
|
||||
await batchCreate([
|
||||
{ id: 'rb-b-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 },
|
||||
{ id: 'rb-b-txt', type: 'text', x: 10, y: 30, text: 'hi', containerId: 'rb-b-box' },
|
||||
]);
|
||||
const box = dbEl('rb-b-box');
|
||||
expect((box.boundElements as any[]).some((b: any) => b.id === 'rb-b-txt')).toBe(true);
|
||||
});
|
||||
|
||||
it('sync/v2: repairs binding when text with containerId is upserted', async () => {
|
||||
await syncV2([
|
||||
{ id: 'rb-s-box', action: 'upsert',
|
||||
element: { id: 'rb-s-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 } },
|
||||
{ id: 'rb-s-txt', action: 'upsert',
|
||||
element: { id: 'rb-s-txt', type: 'text', x: 10, y: 30, text: 'hi', containerId: 'rb-s-box' } },
|
||||
]);
|
||||
const box = dbEl('rb-s-box');
|
||||
expect((box.boundElements as any[]).some((b: any) => b.id === 'rb-s-txt')).toBe(true);
|
||||
});
|
||||
|
||||
it('PUT /api/elements: repairs binding when containerId is added via update', async () => {
|
||||
await createElement({ type: 'rectangle', id: 'rb-u-box', x: 0, y: 0, width: 200, height: 80 });
|
||||
await createElement({ type: 'text', id: 'rb-u-txt', x: 10, y: 30, text: 'hi' });
|
||||
// containerId added via update
|
||||
await updateElement('rb-u-txt', { containerId: 'rb-u-box' });
|
||||
const box = dbEl('rb-u-box');
|
||||
expect((box.boundElements as any[]).some((b: any) => b.id === 'rb-u-txt')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not duplicate boundElements entry if already present', async () => {
|
||||
await createElement({ type: 'rectangle', id: 'rb-dup-box', x: 0, y: 0, width: 200, height: 80 });
|
||||
await createElement({
|
||||
type: 'text', id: 'rb-dup-txt', x: 10, y: 30, text: 'hi',
|
||||
containerId: 'rb-dup-box',
|
||||
});
|
||||
// Update the text again — binding should not be duplicated
|
||||
await updateElement('rb-dup-txt', { x: 20 });
|
||||
const box = dbEl('rb-dup-box');
|
||||
const refs = (box.boundElements as any[]).filter((b: any) => b.id === 'rb-dup-txt');
|
||||
expect(refs.length).toBe(1);
|
||||
});
|
||||
|
||||
it('text without containerId does not touch any container', async () => {
|
||||
await createElement({ type: 'rectangle', id: 'rb-free-box', x: 0, y: 0, width: 200, height: 80 });
|
||||
await createElement({ type: 'text', id: 'rb-free-txt', x: 10, y: 30, text: 'standalone' });
|
||||
const box = dbEl('rb-free-box');
|
||||
// boundElements should remain null / empty — not modified
|
||||
const refs = (box.boundElements as any[] | null) ?? [];
|
||||
expect(refs.filter((b: any) => b.id === 'rb-free-txt').length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Export: version and updated preserved ────────────────────────────────────
|
||||
|
||||
describe('version and updated preserved in DB (export source)', () => {
|
||||
it('stores the correct version after updates', async () => {
|
||||
await createElement({ type: 'rectangle', id: 'ver-1', x: 0, y: 0, width: 100, height: 50 });
|
||||
await updateElement('ver-1', { x: 10 });
|
||||
await updateElement('ver-1', { x: 20 });
|
||||
const el = dbEl('ver-1');
|
||||
expect(el.version).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('stores a numeric updated timestamp', async () => {
|
||||
const before = Date.now();
|
||||
await createElement({ type: 'rectangle', id: 'upd-1', x: 0, y: 0, width: 100, height: 50 });
|
||||
const after = Date.now();
|
||||
const el = dbEl('upd-1');
|
||||
expect(typeof el.updated).toBe('number');
|
||||
expect(el.updated).toBeGreaterThanOrEqual(before);
|
||||
expect(el.updated).toBeLessThanOrEqual(after + 5);
|
||||
});
|
||||
|
||||
it('preserves caller-supplied updated timestamp', async () => {
|
||||
const ts = 1700000000000;
|
||||
await createElement({
|
||||
type: 'rectangle', id: 'upd-2', x: 0, y: 0, width: 100, height: 50,
|
||||
updated: ts,
|
||||
});
|
||||
const el = dbEl('upd-2');
|
||||
expect(el.updated).toBe(ts);
|
||||
});
|
||||
});
|
||||
|
||||
// ── No duplicate bound text on export ────────────────────────────────────────
|
||||
|
||||
describe('GET /api/elements — no duplicate text from native bound elements', () => {
|
||||
it('returns both container and its native bound text without duplication', async () => {
|
||||
await batchCreate([
|
||||
{ id: 'exp-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 },
|
||||
{
|
||||
id: 'exp-txt', type: 'text', x: 10, y: 30, text: 'label',
|
||||
containerId: 'exp-box',
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await request(app).get('/api/elements');
|
||||
expect(res.status).toBe(200);
|
||||
const elements: Record<string, any>[] = res.body.elements;
|
||||
|
||||
const textEls = elements.filter(e => e.type === 'text');
|
||||
const labelEls = textEls.filter(e => e.id === 'exp-txt' || e.id === 'exp-box-label');
|
||||
// Only one text element should exist — the native one, not a generated duplicate
|
||||
expect(labelEls.length).toBe(1);
|
||||
expect(labelEls[0].id).toBe('exp-txt');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Label materialization ─────────────────────────────────────────────────────
|
||||
|
||||
describe('materializeLabel — POST /api/elements with label.text or text on a shape', () => {
|
||||
function dbEl(id: string) {
|
||||
return getElement(id) as Record<string, any>;
|
||||
}
|
||||
|
||||
it('stores a native bound text element when shape is created with label.text', async () => {
|
||||
const res = await request(app).post('/api/elements').send({
|
||||
type: 'rectangle', id: 'ml-rect', x: 0, y: 0, width: 200, height: 80,
|
||||
label: { text: 'Hello' },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Container must NOT have label field
|
||||
const container = dbEl('ml-rect');
|
||||
expect(container.label).toBeUndefined();
|
||||
|
||||
// Bound text must exist in DB
|
||||
const bt = dbEl('ml-rect-label');
|
||||
expect(bt).toBeTruthy();
|
||||
expect(bt.type).toBe('text');
|
||||
expect(bt.text).toBe('Hello');
|
||||
expect(bt.containerId).toBe('ml-rect');
|
||||
});
|
||||
|
||||
it('stores a native bound text element when shape is created with text field', async () => {
|
||||
await request(app).post('/api/elements').send({
|
||||
type: 'ellipse', id: 'ml-ell', x: 0, y: 0, width: 100, height: 60,
|
||||
text: 'World',
|
||||
});
|
||||
|
||||
const container = dbEl('ml-ell');
|
||||
expect((container as any).text).toBeUndefined();
|
||||
|
||||
const bt = dbEl('ml-ell-label');
|
||||
expect(bt).toBeTruthy();
|
||||
expect(bt.text).toBe('World');
|
||||
expect(bt.containerId).toBe('ml-ell');
|
||||
});
|
||||
|
||||
it('container boundElements includes reference to the bound text', async () => {
|
||||
await request(app).post('/api/elements').send({
|
||||
type: 'diamond', id: 'ml-dia', x: 0, y: 0, width: 120, height: 80,
|
||||
label: { text: 'Decision' },
|
||||
});
|
||||
|
||||
const container = dbEl('ml-dia');
|
||||
const bound = container.boundElements as Array<{ id: string; type: string }>;
|
||||
expect(Array.isArray(bound)).toBe(true);
|
||||
expect(bound.some(b => b.id === 'ml-dia-label' && b.type === 'text')).toBe(true);
|
||||
});
|
||||
|
||||
it('bound text has correct native fields (containerId, verticalAlign, autoResize, lineHeight)', async () => {
|
||||
await request(app).post('/api/elements').send({
|
||||
type: 'rectangle', id: 'ml-fields', x: 0, y: 0, width: 200, height: 80,
|
||||
label: { text: 'Check fields' },
|
||||
});
|
||||
|
||||
const bt = dbEl('ml-fields-label');
|
||||
expect(bt.containerId).toBe('ml-fields');
|
||||
expect(bt.verticalAlign).toBe('middle');
|
||||
expect(bt.autoResize).toBe(true);
|
||||
expect(bt.lineHeight).toBe(1.25);
|
||||
expect(bt.textAlign).toBe('center');
|
||||
});
|
||||
|
||||
it('response includes boundTextElement in the API response', async () => {
|
||||
const res = await request(app).post('/api/elements').send({
|
||||
type: 'rectangle', id: 'ml-resp', x: 0, y: 0, width: 200, height: 80,
|
||||
label: { text: 'Response test' },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.boundTextElement).toBeTruthy();
|
||||
expect(res.body.boundTextElement.text).toBe('Response test');
|
||||
});
|
||||
|
||||
it('shapes without text are not affected (no extra element created)', async () => {
|
||||
await request(app).post('/api/elements').send({
|
||||
type: 'rectangle', id: 'ml-notxt', x: 0, y: 0, width: 100, height: 50,
|
||||
});
|
||||
|
||||
const container = dbEl('ml-notxt');
|
||||
expect(container).toBeTruthy();
|
||||
// No synthetic bound text should be stored
|
||||
expect(dbEl('ml-notxt-label')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('text elements themselves are not materialized (only shapes)', async () => {
|
||||
await request(app).post('/api/elements').send({
|
||||
type: 'text', id: 'ml-txt-el', x: 0, y: 0, width: 100, height: 40,
|
||||
text: 'standalone',
|
||||
});
|
||||
|
||||
const el = dbEl('ml-txt-el');
|
||||
expect(el.type).toBe('text');
|
||||
// text field preserved on text elements
|
||||
expect(el.text).toBe('standalone');
|
||||
});
|
||||
|
||||
it('batch create: materializes label for all shapes in the batch', async () => {
|
||||
const res = await request(app).post('/api/elements/batch').send({
|
||||
elements: [
|
||||
{ id: 'ml-b1', type: 'rectangle', x: 0, y: 0, width: 200, height: 80, label: { text: 'Box A' } },
|
||||
{ id: 'ml-b2', type: 'ellipse', x: 300, y: 0, width: 150, height: 80, label: { text: 'Box B' } },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(dbEl('ml-b1-label').text).toBe('Box A');
|
||||
expect(dbEl('ml-b2-label').text).toBe('Box B');
|
||||
expect(dbEl('ml-b1').label).toBeUndefined();
|
||||
expect(dbEl('ml-b2').label).toBeUndefined();
|
||||
});
|
||||
|
||||
it('PUT /api/elements: updating label.text updates the bound text element', async () => {
|
||||
await request(app).post('/api/elements').send({
|
||||
type: 'rectangle', id: 'ml-upd', x: 0, y: 0, width: 200, height: 80,
|
||||
label: { text: 'Original' },
|
||||
});
|
||||
|
||||
const res = await request(app).put('/api/elements/ml-upd').send({
|
||||
id: 'ml-upd', label: { text: 'Updated' },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const bt = dbEl('ml-upd-label');
|
||||
expect(bt.text).toBe('Updated');
|
||||
expect(bt.originalText).toBe('Updated');
|
||||
});
|
||||
|
||||
it('PUT /api/elements: updating label does not create a duplicate bound text', async () => {
|
||||
await request(app).post('/api/elements').send({
|
||||
type: 'rectangle', id: 'ml-nodup', x: 0, y: 0, width: 200, height: 80,
|
||||
label: { text: 'First' },
|
||||
});
|
||||
await request(app).put('/api/elements/ml-nodup').send({
|
||||
id: 'ml-nodup', label: { text: 'Second' },
|
||||
});
|
||||
|
||||
const container = dbEl('ml-nodup');
|
||||
const bound = container.boundElements as Array<{ id: string; type: string }>;
|
||||
const textRefs = bound.filter(b => b.type === 'text');
|
||||
expect(textRefs.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* E2E non-regression tests for native Excalidraw field preservation.
|
||||
*
|
||||
* These tests cover scenarios that only manifest with a live browser + WebSocket
|
||||
* sync cycle — specifically, that the frontend's normalizeForBackend function
|
||||
* does not strip or corrupt native fields when elements are synced back to the
|
||||
* server after the page connects.
|
||||
*
|
||||
* Coverage:
|
||||
* - Native fields (seed, versionNonce, index, roundness) preserved through
|
||||
* a frontend sync round-trip
|
||||
* - Container binding (containerId ↔ boundElements) survives page load + sync
|
||||
* - No duplicate text elements after frontend sync when native bound text exists
|
||||
* - WebSocket initial_elements delivers complete native fields to the browser
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
const API = 'http://127.0.0.1:3100';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function resetCanvas(request: any): Promise<void> {
|
||||
await request.delete(`${API}/api/elements/clear?confirm=true`);
|
||||
}
|
||||
|
||||
async function waitForConnected(page: Page): Promise<void> {
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
}
|
||||
|
||||
async function getApiElement(request: any, id: string): Promise<Record<string, any>> {
|
||||
const res = await request.get(`${API}/api/elements/${id}`);
|
||||
expect(res.ok()).toBe(true);
|
||||
return (await res.json()).element;
|
||||
}
|
||||
|
||||
async function getAllApiElements(request: any): Promise<Record<string, any>[]> {
|
||||
const res = await request.get(`${API}/api/elements`);
|
||||
expect(res.ok()).toBe(true);
|
||||
return (await res.json()).elements;
|
||||
}
|
||||
|
||||
async function triggerSync(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: /^Sync$/ }).click();
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
|
||||
// ── Setup ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await resetCanvas(request);
|
||||
});
|
||||
|
||||
// ── Native fields survive frontend sync round-trip ────────────────────────────
|
||||
|
||||
test.describe('native fields — preserved through frontend sync round-trip', () => {
|
||||
test('seed, versionNonce, index unchanged after page connects and syncs', async ({ page, request }) => {
|
||||
// Create element with explicit native fields via API
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: 'nf-stable',
|
||||
type: 'rectangle',
|
||||
x: 100, y: 100, width: 200, height: 80,
|
||||
seed: 98765432,
|
||||
index: 'aFixedIndex',
|
||||
},
|
||||
});
|
||||
|
||||
const before = await getApiElement(request, 'nf-stable');
|
||||
expect(before.seed).toBe(98765432);
|
||||
expect(before.index).toBe('aFixedIndex');
|
||||
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
await triggerSync(page);
|
||||
|
||||
const after = await getApiElement(request, 'nf-stable');
|
||||
expect(after.seed).toBe(before.seed);
|
||||
expect(after.index).toBe(before.index);
|
||||
expect(after.versionNonce).toBeDefined();
|
||||
});
|
||||
|
||||
test('roundness preserved through page load + sync', async ({ page, request }) => {
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: 'nf-roundness',
|
||||
type: 'rectangle',
|
||||
x: 100, y: 100, width: 200, height: 80,
|
||||
roundness: { type: 3 },
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
await triggerSync(page);
|
||||
|
||||
const after = await getApiElement(request, 'nf-roundness');
|
||||
expect(after.roundness).toMatchObject({ type: 3 });
|
||||
});
|
||||
|
||||
test('strokeColor, backgroundColor, opacity preserved through sync', async ({ page, request }) => {
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: 'nf-style',
|
||||
type: 'rectangle',
|
||||
x: 0, y: 0, width: 150, height: 60,
|
||||
strokeColor: '#e03131',
|
||||
backgroundColor: '#ffc9c9',
|
||||
opacity: 75,
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
await triggerSync(page);
|
||||
|
||||
const after = await getApiElement(request, 'nf-style');
|
||||
expect(after.strokeColor).toBe('#e03131');
|
||||
expect(after.backgroundColor).toBe('#ffc9c9');
|
||||
expect(after.opacity).toBe(75);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Container binding survives frontend sync ──────────────────────────────────
|
||||
|
||||
test.describe('container binding — survives page load and sync', () => {
|
||||
test('containerId and boundElements intact after page connects', async ({ page, request }) => {
|
||||
await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'cb-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 },
|
||||
{ id: 'cb-txt', type: 'text', x: 10, y: 30, text: 'label', containerId: 'cb-box' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Verify DB binding is correct before page load
|
||||
const boxBefore = await getApiElement(request, 'cb-box');
|
||||
expect((boxBefore.boundElements ?? []).some((b: any) => b.id === 'cb-txt')).toBe(true);
|
||||
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// Binding must survive the page connecting (which triggers initial sync)
|
||||
const boxAfter = await getApiElement(request, 'cb-box');
|
||||
const txtAfter = await getApiElement(request, 'cb-txt');
|
||||
|
||||
expect((boxAfter.boundElements ?? []).some((b: any) => b.id === 'cb-txt')).toBe(true);
|
||||
expect(txtAfter.containerId).toBe('cb-box');
|
||||
});
|
||||
|
||||
test('binding intact after explicit sync button press', async ({ page, request }) => {
|
||||
await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'cb-sync-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 },
|
||||
{ id: 'cb-sync-txt', type: 'text', x: 10, y: 30, text: 'synced', containerId: 'cb-sync-box' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
await triggerSync(page);
|
||||
|
||||
const box = await getApiElement(request, 'cb-sync-box');
|
||||
const txt = await getApiElement(request, 'cb-sync-txt');
|
||||
|
||||
expect((box.boundElements ?? []).some((b: any) => b.id === 'cb-sync-txt')).toBe(true);
|
||||
expect(txt.containerId).toBe('cb-sync-box');
|
||||
});
|
||||
|
||||
test('binding survives page reload', async ({ page, request }) => {
|
||||
await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'cb-rel-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 },
|
||||
{ id: 'cb-rel-txt', type: 'text', x: 10, y: 30, text: 'reload', containerId: 'cb-rel-box' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
await page.reload();
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const box = await getApiElement(request, 'cb-rel-box');
|
||||
expect((box.boundElements ?? []).some((b: any) => b.id === 'cb-rel-txt')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── No duplicate text elements ────────────────────────────────────────────────
|
||||
|
||||
test.describe('no duplicate text — native bound text not duplicated by sync', () => {
|
||||
test('only one text element exists after page connects when native binding is used', async ({ page, request }) => {
|
||||
await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'dup-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 },
|
||||
{ id: 'dup-txt', type: 'text', x: 10, y: 30, text: 'unique', containerId: 'dup-box' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
await triggerSync(page);
|
||||
|
||||
const elements = await getAllApiElements(request);
|
||||
const textEls = elements.filter(e => e.type === 'text');
|
||||
|
||||
// Only the native text element should exist — no generated duplicate
|
||||
expect(textEls.length).toBe(1);
|
||||
expect(textEls[0].id).toBe('dup-txt');
|
||||
expect(textEls[0].containerId).toBe('dup-box');
|
||||
});
|
||||
|
||||
test('text content not duplicated across multiple syncs', async ({ page, request }) => {
|
||||
await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'multi-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 },
|
||||
{ id: 'multi-txt', type: 'text', x: 10, y: 30, text: 'once', containerId: 'multi-box' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
// Sync multiple times
|
||||
await triggerSync(page);
|
||||
await triggerSync(page);
|
||||
|
||||
const elements = await getAllApiElements(request);
|
||||
const textEls = elements.filter(e => e.type === 'text');
|
||||
expect(textEls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── WebSocket initial_elements delivers complete native fields ─────────────────
|
||||
|
||||
test.describe('WebSocket initial_elements — complete native fields delivered', () => {
|
||||
test('elements served on connect have seed, index, versionNonce, boundElements', async ({ page, request }) => {
|
||||
await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'ws-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 },
|
||||
{ id: 'ws-txt', type: 'text', x: 10, y: 30, text: 'ws', containerId: 'ws-box' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Intercept the initial_elements WS message via addInitScript (runs before page JS)
|
||||
await page.addInitScript(() => {
|
||||
const NativeWS = window.WebSocket;
|
||||
(window as any).__initialElements = null;
|
||||
const Wrapped = function(this: any, url: string | URL, protocols?: string | string[]) {
|
||||
const ws = protocols !== undefined ? new NativeWS(url, protocols) : new NativeWS(url);
|
||||
ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data as string);
|
||||
if (msg.type === 'initial_elements') {
|
||||
(window as any).__initialElements = msg.elements ?? [];
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
return ws;
|
||||
} as any;
|
||||
Wrapped.prototype = NativeWS.prototype;
|
||||
Object.assign(Wrapped, NativeWS);
|
||||
window.WebSocket = Wrapped;
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const wsElements: any[] = await page.evaluate(() => (window as any).__initialElements ?? []);
|
||||
|
||||
// If WS capture worked, assert on WS payload; otherwise fall back to API
|
||||
const source = wsElements.length > 0 ? wsElements : await getAllApiElements(request);
|
||||
|
||||
const box = source.find((e: any) => e.id === 'ws-box');
|
||||
const txt = source.find((e: any) => e.id === 'ws-txt');
|
||||
|
||||
expect(box).toBeDefined();
|
||||
expect(txt).toBeDefined();
|
||||
expect(typeof box.seed).toBe('number');
|
||||
expect(typeof box.index).toBe('string');
|
||||
expect(typeof box.versionNonce).toBe('number');
|
||||
expect((box.boundElements ?? []).some((b: any) => b.id === 'ws-txt')).toBe(true);
|
||||
expect(txt.containerId).toBe('ws-box');
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,6 @@ test.describe('Phase 2 regressions', () => {
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
label?: { text?: string };
|
||||
};
|
||||
|
||||
await page.reload();
|
||||
@@ -60,14 +59,18 @@ test.describe('Phase 2 regressions', () => {
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
label?: { text?: string };
|
||||
};
|
||||
|
||||
expect(afterReload.x).toBe(initial.x);
|
||||
expect(afterReload.y).toBe(initial.y);
|
||||
expect(afterReload.width).toBe(initial.width);
|
||||
expect(afterReload.height).toBe(initial.height);
|
||||
expect(afterReload.label?.text).toBe('Stable Label');
|
||||
// label is materialized into a native bound text element on create;
|
||||
// verify the bound text element persists with correct text after reload
|
||||
const btRes = await request.get(`${API}/api/elements/pos-stable-1-label`);
|
||||
expect(btRes.ok()).toBe(true);
|
||||
const bt = (await btRes.json()).element as { text: string };
|
||||
expect(bt.text).toBe('Stable Label');
|
||||
});
|
||||
|
||||
test('new container arrival auto-injects title and subtitle text', async ({ page, request }) => {
|
||||
|
||||
@@ -510,7 +510,9 @@ test.describe('Search E2E', () => {
|
||||
const res = await request.get(`${API}/api/elements/search?q=Authentication`);
|
||||
const body = await res.json();
|
||||
expect(body.elements.length).toBeGreaterThanOrEqual(1);
|
||||
expect(body.elements.some((e: any) => e.id === 'fts-el')).toBe(true);
|
||||
// label is materialized into a native bound text element (id: 'fts-el-label')
|
||||
// so FTS matches the bound text element; the container id or bound text id are both valid
|
||||
expect(body.elements.some((e: any) => e.id === 'fts-el' || e.id === 'fts-el-label')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
const wsUrl = process.env.CUSTOM_VITE_WS_URL || 'wss://excalidraw.forkless.com';
|
||||
|
||||
export default defineConfig({
|
||||
root: 'frontend',
|
||||
plugins: [react()],
|
||||
define: {
|
||||
'import.meta.env.CUSTOM_VITE_WS_URL': JSON.stringify(wsUrl),
|
||||
},
|
||||
build: {
|
||||
outDir: '../dist/frontend',
|
||||
emptyOutDir: true,
|
||||
|
||||
Reference in New Issue
Block a user