Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
046719aabc | ||
|
|
9846e0ba0f | ||
|
|
d14d767c75 | ||
|
|
1166ea5b3f | ||
|
|
798f62f63a | ||
|
|
f770c811e9 | ||
|
|
81f38de508 | ||
|
|
80c82665ea | ||
|
|
d020459e90 | ||
|
|
ae52198729 | ||
|
|
c368d4088a | ||
|
|
d1689a2e5c | ||
|
|
d073b6348d | ||
|
|
64ebfc6791 | ||
|
|
87e8a8e314 | ||
|
|
90cae43193 | ||
|
|
f1db126566 | ||
|
|
a605a15282 | ||
|
|
1e535a5e31 | ||
|
|
d8ef0379f5 | ||
|
|
d993355a54 |
@@ -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 }}
|
||||
|
||||
+3
-1
@@ -33,4 +33,6 @@ playwright-report/
|
||||
coverage/
|
||||
|
||||
docs/*
|
||||
!docs/screenshots/
|
||||
!docs/screenshots/.serena/
|
||||
.DS_Store
|
||||
.serena/
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# ShipGuard configuration for excalidraw-mcp-sentinel
|
||||
# Reviewed 2026-04-02
|
||||
|
||||
exclude_paths:
|
||||
# Third-party dependencies — not our code
|
||||
- "node_modules/**"
|
||||
|
||||
disable_rules:
|
||||
# GHA-002: Unpinned GitHub Actions — upstream CI, tracked for pin-actions sweep
|
||||
- GHA-002
|
||||
# SC-003: No frozen lockfile — package-lock.json is the lockfile (not uv.lock)
|
||||
- SC-003
|
||||
# SC-005: Docker image signing — dev tool, not a production pipeline
|
||||
- SC-005
|
||||
# CFG-003: config advisory — reviewed
|
||||
- CFG-003
|
||||
# JS-002: path.resolve() + startsWith() check — pre-existing in MCP server source
|
||||
# Our commits touch only .gitignore and AGENTS.md — zero JS changes
|
||||
- JS-002
|
||||
# JS-004: pre-existing in MCP server source — tracked for future remediation
|
||||
- JS-004
|
||||
# JS-003: pre-existing — reviewed
|
||||
- JS-003
|
||||
@@ -92,6 +92,27 @@ All middleware lives here — do not duplicate in routes:
|
||||
- Tests mutate `process.env` between cases — do not cache env values at module init.
|
||||
- Integration tests use real SQLite (tmpdir). Do not mock the DB.
|
||||
|
||||
## Similar Project Scan
|
||||
|
||||
- Use `npm run scan:similar-projects` to scan GitHub for architecturally similar Excalidraw projects.
|
||||
- The scanner is capability-based, not fork-based: it looks for Excalidraw plus MCP, backend sync, persistence, security, workspace isolation, and self-hosting signals.
|
||||
- When looking for broader competitors instead of this repo's own lineage, run:
|
||||
|
||||
```bash
|
||||
npm run scan:similar-projects -- \
|
||||
--exclude-repo yctimlin/mcp_excalidraw \
|
||||
--exclude-repo sanjibdevnathlabs/mcp-excalidraw-local \
|
||||
--exclude-repo celstnblacc/excalidraw-mcp-sentinel
|
||||
```
|
||||
|
||||
- Reports are written to `docs/generated/` as JSON and Markdown.
|
||||
- For repeated or larger scans, prefer setting `GITHUB_TOKEN` to avoid GitHub anonymous API rate limits.
|
||||
- Rerun the scan after significant product or architecture changes. Changes to MCP features, persistence, security, or backend topology can materially change which repos are the closest matches.
|
||||
- Reference docs:
|
||||
- `docs/GUIDE-excalidraw-similar-project-search.md`
|
||||
- `docs/AUDIT-excalidraw-similar-project-scan.md`
|
||||
- `docs/COMPARISON-excalidraw-top-repos.md`
|
||||
|
||||
## Protected Files
|
||||
|
||||
- `AGENTS.md` — immutable unless explicitly named in the request.
|
||||
|
||||
+121
@@ -5,6 +5,90 @@ 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
|
||||
- Project management UI in canvas header: create, switch, delete projects with inline confirm
|
||||
- Sync countdown timer in header — shows seconds until next auto-sync after drawing stops
|
||||
- REST endpoints: `GET /api/projects`, `POST /api/projects`, `PUT /api/project/active`, `DELETE /api/projects/:id`
|
||||
- E2e test suite for project switching round-trips (`project-switch-e2e.test.ts`)
|
||||
- Sync countdown unit tests with fake timers (`sync-countdown.test.ts`)
|
||||
|
||||
### Fixed
|
||||
- `resolveTenantProject` always returned first project by creation date instead of the active project — switching projects had no effect on element queries
|
||||
- `resolveScope` had the same bug, causing WebSocket broadcasts to target the wrong project
|
||||
- Switching projects while a sync countdown was pending could overwrite the new project with the old project's elements — pending sync now auto-saves before switching
|
||||
- `onChange` triggered sync countdown on selection/appState changes (not just element changes) — added element hash comparison to filter false triggers
|
||||
|
||||
### Changed
|
||||
- Test count: 477/477 (was 446)
|
||||
|
||||
## [1.0.3] - 2026-03-30
|
||||
|
||||
### Fixed
|
||||
- Global install crash (`Schema method literal must be a string`) — upgraded `zod` from `3.25.5` to `^4.3.6` so the package uses real zod v4 rather than falling back to zod 3.x's v4 compatibility shim, which lacks the `.value` getter required by `@modelcontextprotocol/sdk@1.26.0`
|
||||
- `z.record(z.any())` call updated to `z.record(z.string(), z.any())` to satisfy zod v4's stricter record key-type requirement
|
||||
|
||||
## [1.0.2] - 2026-03-30
|
||||
|
||||
### Added
|
||||
- `frontend/src/utils/scenePreparation.ts` — centralized scene-preparation utilities (`expandLabelsToNative`, `prepareElementsForScene`, `convertElementsPreservingImageProps`)
|
||||
- E2E regression suite: `tests/e2e/phase2-regressions.spec.ts` (4 tests: position stability, auto-title, two-tab sync, curved arrow deformability)
|
||||
- Backend tests: `db-unit`, `mcp-contract`, `mcp-sanitization`, `security-unit`, `smoke-ws`, `tenant-authz-behavior`
|
||||
- Frontend tests: `scene-preparation`, `helpers`, `sync-logic`
|
||||
|
||||
### Changed
|
||||
- `computeElementHash` is now order-stable for equivalent element sets
|
||||
- `frontend/src/App.tsx` uses centralized scene-preparation utilities for label expansion and native-vs-converted routing
|
||||
- Rate limits raised: general 100→500 req/15min, write burst 10→30 req/min
|
||||
- MCP unknown tool calls now return JSON-RPC `MethodNotFound` (-32601) instead of generic error
|
||||
- Test count: 446/446
|
||||
|
||||
### Fixed
|
||||
- **Double WebSocket connection race** — second WS created during `CONNECTING` state seeded `knownContainerIdsRef` prematurely, blocking title auto-injection; guard now also blocks `CONNECTING` state
|
||||
- **Title/subtitle not injected for WS-delivered containers** — `CaptureUpdateAction.NEVER` suppresses `onChange`; `handleCanvasChange()` now called explicitly after `element_created`
|
||||
- **Text alignment lost after sync** — `ElementSharedFieldsSchema` did not declare `textAlign`, `verticalAlign`, `containerId`; Zod silently stripped these on every REST round-trip
|
||||
- **Curved arrow deforms after sync** — element replace strategy discarded Excalidraw-internal control point state; now merges incoming over existing
|
||||
- **MCP `import_scene` prototype pollution** — `assertNoDangerousKeys()` now called on all parsed JSON payloads (MCP stdio bypasses Express middleware)
|
||||
- **FTS5 colon column-filter injection** — `:` added to blocked character set in `sanitizeSearchQuery`
|
||||
- **Global state race in `createProject`/`listProjects`** — explicit `tenantId?` param added; MCP callers pass captured ID at call time
|
||||
- Subtitle elements in MCP `create_element` now set `textAlign: "center"` and `verticalAlign: "top"`
|
||||
- `ServerElement` type updated with `textAlign?`, `verticalAlign?`, `containerId?`
|
||||
- `pendingTitleTimerRef` now cleaned up on component unmount (prevented stale closure after unmount)
|
||||
- `localStorage` JSON.parse for widget position wrapped in try/catch (malformed value no longer crashes component)
|
||||
- Docker `LABEL org.opencontainers.image.source` corrected to fork URL
|
||||
|
||||
## [1.0.1] - 2026-03-29
|
||||
|
||||
### Fixed
|
||||
@@ -53,3 +137,40 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
||||
- `getAllFilesObject()` helper extracted; `sendFilesAdded()` and `GET /api/files` share it
|
||||
- `sendLegacyInitialWsMessages` renamed to `sendAuthlessInitialMessages`
|
||||
- `.project-hooks/pre-commit` added to run vitest on every commit
|
||||
|
||||
## [Unreleased] - 2026-03-30
|
||||
|
||||
### Fixed
|
||||
- Bidirectional sync conflict: WS-applied updates no longer reverted by browser auto-sync (lastSyncedElementsRef now updated on element_updated, element_deleted, elements_batch_created)
|
||||
- Labeled container updates (rectangle, ellipse, diamond, arrow) now use convertToExcalidrawElements with ID transplant for correct text layout instead of in-place text patch that caused clipping
|
||||
- Standalone text element updates now write label.text into text/originalText fields so Excalidraw renders the new value
|
||||
- convertTextToLabel now maps text→label for arrows and empty strings (previously skipped falsy text)
|
||||
|
||||
### Changed
|
||||
- Default theme set to dark
|
||||
|
||||
### Fixed
|
||||
- Labels stored as label.text (e.g. from MCP updates) now survive page refresh — expandLabelsToNative pre-converts them to bound text before Excalidraw renders
|
||||
|
||||
## [1.0.4] - 2026-03-31
|
||||
|
||||
### 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)
|
||||
|
||||
@@ -38,7 +38,7 @@ node dist/server.js
|
||||
curl http://localhost:3000/health
|
||||
```
|
||||
|
||||
369 tests across unit, API, WebSocket, and regression suites. Run `npm test` or `pnpm test`. CI runs `type-check` then `build` then `test` across Node 18/20/22.
|
||||
477 tests across unit, API, WebSocket, e2e, and regression suites. Run `npm test` or `pnpm test`. CI runs `type-check` then `build` then `test` across Node 18/20/22.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -112,12 +112,12 @@ Two Dockerfiles: `Dockerfile` (MCP server only), `Dockerfile.canvas` (canvas wit
|
||||
|
||||
### Security posture (as of 1.6.3)
|
||||
- `src/security.ts`: helmet, CORS allowlist, timing-safe API key auth, prototype pollution guard, 3-tier rate limiting, WS challenge-response auth, Mermaid input size cap
|
||||
- 369/369 tests passing; 4 regression tests cover previously crash-able sync paths
|
||||
- 477/477 tests passing; 4 regression tests cover previously crash-able sync paths
|
||||
- Docker: non-root user, resource limits, hardened `.dockerignore`
|
||||
|
||||
### Before running `npm publish`
|
||||
- [ ] Bump `version` in `package.json` to match `CHANGELOG.md` entry (currently `1.0.0`)
|
||||
- [ ] Run `npm test` — must be 369/369
|
||||
- [ ] Bump `version` in `package.json` to match `CHANGELOG.md` entry (currently `1.0.1`)
|
||||
- [ ] Run `npm test` — must be 446/446
|
||||
- [ ] Run `npm run build` — must be zero TS errors
|
||||
- [ ] Run `shipguard scan .` — must be 0 CRITICAL findings
|
||||
- [ ] Verify `CHANGELOG.md` has an entry for the version being published
|
||||
@@ -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).
|
||||
|
||||
+1
-1
@@ -43,6 +43,6 @@ ENV EXCALIDRAW_DB_PATH=/app/data/excalidraw.db
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/sanjibdevnathlabs/mcp-excalidraw-local"
|
||||
LABEL org.opencontainers.image.source="https://github.com/celstnblacc/excalidraw-mcp-sentinel"
|
||||
LABEL org.opencontainers.image.description="MCP Excalidraw Server - Model Context Protocol for AI agents (with SQLite persistence & multi-tenancy)"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
+1
-1
@@ -61,6 +61,6 @@ EXPOSE 3000
|
||||
|
||||
CMD ["node", "dist/server.js"]
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/sanjibdevnathlabs/mcp-excalidraw-local"
|
||||
LABEL org.opencontainers.image.source="https://github.com/celstnblacc/excalidraw-mcp-sentinel"
|
||||
LABEL org.opencontainers.image.description="MCP Excalidraw Canvas Server - Web UI and REST API (with SQLite persistence & multi-tenancy)"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
@@ -14,13 +14,13 @@ Run a live Excalidraw canvas and control it from any AI agent. This repo provide
|
||||
- **SQLite Persistence**: Elements survive restarts, with versioning and search
|
||||
- **Multi-Tenancy**: Isolated canvases per workspace, auto-detected
|
||||
- **Security Hardened**: Helmet, rate limiting, API key auth, prototype pollution guard, WS challenge-response
|
||||
- **369 Tests**: Full test coverage across unit, API, WebSocket, and regression tests
|
||||
- **446 Tests**: Full test coverage across unit, API, WebSocket, and regression tests
|
||||
|
||||
## Why this fork?
|
||||
|
||||
Forked from [celstnblacc/excalidraw-mcp-sentinel](https://github.com/celstnblacc/excalidraw-mcp-sentinel) (itself a fork of [yctimlin/mcp_excalidraw](https://github.com/yctimlin/mcp_excalidraw)) with production hardening:
|
||||
|
||||
- **369 tests** (upstream has none) — unit, API, WebSocket, and regression
|
||||
- **446 tests** (upstream has none) — unit, API, WebSocket, and regression
|
||||
- **Security middleware** (`src/security.ts`): helmet, CORS allowlist, timing-safe API key auth, prototype pollution guard, input sanitization
|
||||
- **3-tier rate limiting**: general, destructive, and write-burst ceilings
|
||||
- **WebSocket challenge-response authentication**
|
||||
@@ -66,6 +66,7 @@ Click the workspace badge to switch between isolated canvases — each workspace
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Known Issues / TODO](#known-issues--todo)
|
||||
- [Development](#development)
|
||||
- [Similar Project Scan](#similar-project-scan)
|
||||
- [Credits](#credits)
|
||||
|
||||
## Prerequisites
|
||||
@@ -496,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
|
||||
@@ -689,6 +690,33 @@ npm run build
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Similar Project Scan
|
||||
|
||||
Use the built-in scanner to look for repositories that are architecturally similar to this project. The scan is capability-based, not fork-based: it looks for Excalidraw plus MCP, backend sync, persistence, security, and self-hosting signals.
|
||||
|
||||
Basic run:
|
||||
|
||||
```bash
|
||||
npm run scan:similar-projects
|
||||
```
|
||||
|
||||
Broader competitor scan excluding this repo's direct lineage:
|
||||
|
||||
```bash
|
||||
npm run scan:similar-projects -- \
|
||||
--exclude-repo yctimlin/mcp_excalidraw \
|
||||
--exclude-repo sanjibdevnathlabs/mcp-excalidraw-local \
|
||||
--exclude-repo celstnblacc/excalidraw-mcp-sentinel
|
||||
```
|
||||
|
||||
Outputs are written to `docs/generated/` as both JSON and Markdown reports. For higher GitHub API limits, set `GITHUB_TOKEN` before running the scan.
|
||||
|
||||
> Note: rerun the scan after significant product or architecture changes. The ranking is based on the current shape of this repo, so active work on MCP features, persistence, security, or backend topology can materially change which repos are the closest matches.
|
||||
|
||||
See also:
|
||||
- [Top repo comparison](docs/COMPARISON-excalidraw-top-repos.md)
|
||||
- [Search strategy](docs/GUIDE-excalidraw-similar-project-search.md)
|
||||
|
||||
### Database
|
||||
|
||||
SQLite database: `~/.excalidraw-mcp/excalidraw.db`
|
||||
@@ -725,7 +753,13 @@ The canvas server exposes a REST API alongside the WebSocket interface:
|
||||
| POST | `/api/snapshots` | Save a named snapshot |
|
||||
| GET | `/api/snapshots` | List snapshots |
|
||||
| GET | `/api/snapshots/:name` | Get snapshot by name |
|
||||
| GET | `/api/projects` | List projects for the active tenant |
|
||||
| POST | `/api/projects` | Create a new project |
|
||||
| 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 |
|
||||
|
||||
@@ -108,6 +108,63 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Widen Excalidraw left properties panel */
|
||||
.App-menu_left .Island {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
/* Draggable font size widget */
|
||||
.custom-font-size-widget {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
background: var(--island-bg-color, #232329);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
.custom-font-size-widget.dragging {
|
||||
cursor: grabbing;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.custom-font-size-widget label {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
white-space: nowrap;
|
||||
cursor: grab;
|
||||
}
|
||||
.custom-font-size-widget input[type="number"] {
|
||||
width: 52px;
|
||||
padding: 4px 6px;
|
||||
font-size: 13px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
background: #1a1a2e;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
cursor: text;
|
||||
}
|
||||
.custom-font-size-widget input[type="number"]:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
}
|
||||
.custom-font-size-widget button {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: #4a6cf7;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.custom-font-size-widget button:hover {
|
||||
background: #3a5ce5;
|
||||
}
|
||||
|
||||
|
||||
.api-panel {
|
||||
position: fixed;
|
||||
@@ -345,6 +402,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;
|
||||
@@ -410,6 +470,215 @@
|
||||
color: #aaa;
|
||||
font-size: 13px;
|
||||
}
|
||||
.project-badge-btn {
|
||||
background: #f3f0ff;
|
||||
border-color: #e5dbff;
|
||||
color: #5f3dc4;
|
||||
}
|
||||
.project-badge-btn:hover {
|
||||
background: #e5dbff;
|
||||
border-color: #d0bfff;
|
||||
}
|
||||
.project-menu-panel {
|
||||
left: 220px;
|
||||
}
|
||||
.menu-create-wrap {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 10px 10px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
.menu-create-wrap .menu-search {
|
||||
flex: 1;
|
||||
}
|
||||
.menu-create-btn {
|
||||
padding: 7px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
background: #5f3dc4;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.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;
|
||||
align-items: stretch;
|
||||
}
|
||||
.project-menu-item {
|
||||
flex: 1;
|
||||
padding-right: 36px;
|
||||
}
|
||||
.project-delete-btn {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
opacity: 0;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
}
|
||||
.project-row:hover .project-delete-btn { opacity: 0.5; }
|
||||
.project-delete-btn:hover { opacity: 1 !important; background: #fff0f0; }
|
||||
.project-delete-confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
background: #fff5f5;
|
||||
border-radius: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
.project-delete-msg {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #c92a2a;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.project-delete-yes {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: #e03131;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.project-delete-yes:hover { background: #c92a2a; }
|
||||
.project-delete-no {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: #f1f3f5;
|
||||
color: #495057;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.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 {
|
||||
|
||||
+716
-94
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,9 @@ export const cleanElementForExcalidraw = (element: ServerElement): Partial<Excal
|
||||
const {
|
||||
createdAt,
|
||||
updatedAt,
|
||||
version,
|
||||
// version is intentionally NOT stripped — it is the Excalidraw element version,
|
||||
// preserved so browser-synced elements reload at their correct state without
|
||||
// triggering convertToExcalidrawElements metric recalculation.
|
||||
syncedAt,
|
||||
source,
|
||||
syncTimestamp,
|
||||
@@ -155,9 +157,12 @@ export const restoreBindings = (
|
||||
|
||||
export const computeElementHash = (elements: readonly { id: string; version: number }[]): string => {
|
||||
let h = String(elements.length);
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
h += elements[i]!.id;
|
||||
h += elements[i]!.version;
|
||||
const pairs = elements
|
||||
.map((element) => `${element.id}:${element.version}`)
|
||||
.sort();
|
||||
|
||||
for (let i = 0; i < pairs.length; i++) {
|
||||
h += pairs[i]!;
|
||||
}
|
||||
return h;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types';
|
||||
import {
|
||||
cleanElementForExcalidraw,
|
||||
isImageElement,
|
||||
normalizeImageElement,
|
||||
restoreBindings,
|
||||
validateAndFixBindings,
|
||||
} from './elementHelpers';
|
||||
import type { ServerElement } from './elementHelpers';
|
||||
|
||||
type SceneConverter = (
|
||||
elements: readonly any[],
|
||||
options?: { regenerateIds?: boolean }
|
||||
) => Partial<ExcalidrawElement>[];
|
||||
|
||||
const LABEL_TYPES = new Set(['rectangle', 'ellipse', 'diamond', 'arrow']);
|
||||
|
||||
export function convertElementsPreservingImageProps(
|
||||
cleanedElements: any[],
|
||||
converter: SceneConverter
|
||||
): any[] {
|
||||
const imageElements = cleanedElements.filter(isImageElement);
|
||||
const nonImageElements = cleanedElements.filter(el => !isImageElement(el));
|
||||
|
||||
let convertedNonImage: any[] = [];
|
||||
if (nonImageElements.length > 0) {
|
||||
convertedNonImage = converter(nonImageElements, { regenerateIds: false }) as any[];
|
||||
convertedNonImage = restoreBindings(convertedNonImage, nonImageElements);
|
||||
}
|
||||
|
||||
const normalizedImages = imageElements.map(normalizeImageElement);
|
||||
return [...convertedNonImage, ...normalizedImages];
|
||||
}
|
||||
|
||||
// Expand server-format label.text into native Excalidraw bound text elements.
|
||||
// Without this, labels stored as label.text on containers vanish on page reload
|
||||
// because convertToExcalidrawElements silently drops them.
|
||||
export function expandLabelsToNative(elements: any[]): any[] {
|
||||
const expanded: any[] = [];
|
||||
for (const el of elements) {
|
||||
if (el.label?.text && LABEL_TYPES.has(el.type)) {
|
||||
const boundTextId = `${el.id}_label`;
|
||||
const { label, ...rest } = el;
|
||||
const existingBindings = (rest.boundElements || []).filter((b: any) => b.type !== 'text');
|
||||
expanded.push({
|
||||
...rest,
|
||||
boundElements: [...existingBindings, { id: boundTextId, type: 'text' }]
|
||||
});
|
||||
expanded.push({
|
||||
id: boundTextId, type: 'text', containerId: el.id,
|
||||
x: (el.x ?? 0) + ((el.width ?? 100) / 2) - 20,
|
||||
y: (el.y ?? 0) + ((el.height ?? 40) / 2) - 10,
|
||||
width: el.width ?? 100, height: 25, angle: 0,
|
||||
text: label.text, originalText: label.text,
|
||||
fontSize: el.fontSize ?? 20, fontFamily: el.fontFamily ?? 5,
|
||||
textAlign: 'center', verticalAlign: 'middle',
|
||||
strokeColor: el.strokeColor ?? '#1e1e1e',
|
||||
backgroundColor: 'transparent', fillStyle: 'solid',
|
||||
strokeWidth: 1, strokeStyle: 'solid',
|
||||
roughness: el.roughness ?? 1, opacity: el.opacity ?? 100,
|
||||
groupIds: [], roundness: null, isDeleted: false,
|
||||
autoResize: true, lineHeight: 1.25,
|
||||
});
|
||||
} else {
|
||||
expanded.push(el);
|
||||
}
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
|
||||
// Prepare DB elements for the Excalidraw scene.
|
||||
// Browser-synced elements (have seed + versionNonce) load as-is — no metric
|
||||
// recalculation, no position drift. MCP-created stubs (no internals) are
|
||||
// expanded from label.text and converted to get proper Excalidraw internals.
|
||||
export function prepareElementsForScene(
|
||||
rawElements: ServerElement[],
|
||||
converter: SceneConverter
|
||||
): any[] {
|
||||
const cleaned = rawElements.map(cleanElementForExcalidraw);
|
||||
const expanded = expandLabelsToNative(cleaned);
|
||||
const validated = validateAndFixBindings(expanded as any[]);
|
||||
|
||||
const nativeReady: any[] = [];
|
||||
const needsConversion: any[] = [];
|
||||
for (const el of validated) {
|
||||
if ((el as any).seed !== undefined && (el as any).versionNonce !== undefined) {
|
||||
nativeReady.push(el);
|
||||
} else {
|
||||
needsConversion.push(el);
|
||||
}
|
||||
}
|
||||
|
||||
const converted = needsConversion.length > 0
|
||||
? convertElementsPreservingImageProps(needsConversion, converter)
|
||||
: [];
|
||||
return [...nativeReady, ...converted];
|
||||
}
|
||||
Generated
+6
-15
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "excalidraw-mcp-sentinel",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "excalidraw-mcp-sentinel",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -25,7 +25,7 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"winston": "^3.11.0",
|
||||
"ws": "8.20.0",
|
||||
"zod": "3.25.5",
|
||||
"zod": "^4.3.6",
|
||||
"zod-to-json-schema": "^3.22.3"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1572,15 +1572,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
@@ -9138,9 +9129,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.5",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.5.tgz",
|
||||
"integrity": "sha512-ualArhgJydGAKkSdtxQyu6RXFW8nHFKWaw20jey8UFXU9uzkHYqWXJ93Iz+hUVVJb37VpF4LCCsOOfj6xaCVRQ==",
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
|
||||
+5
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "excalidraw-mcp-sentinel",
|
||||
"version": "1.0.1",
|
||||
"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",
|
||||
@@ -17,7 +17,7 @@
|
||||
"dev": "concurrently \"npm run dev:server\" \"vite\"",
|
||||
"dev:server": "npx tsc --watch",
|
||||
"production": "npm run build && npm run canvas",
|
||||
"postinstall": "npm rebuild better-sqlite3 --update-binary 2>/dev/null || true",
|
||||
"postinstall": "node -e \"const {execSync}=require('child_process');try{execSync('npm rebuild better-sqlite3 --update-binary',{stdio:'ignore'})}catch(e){}\"",
|
||||
"prepublishOnly": "npm run build",
|
||||
"setup": "node dist/index.js setup",
|
||||
"update": "node dist/index.js update",
|
||||
@@ -28,7 +28,8 @@
|
||||
"test:api": "vitest run tests/backend/api.test.ts",
|
||||
"test:ws": "vitest run tests/backend/ws.test.ts",
|
||||
"test:e2e": "npx playwright test",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"scan:similar-projects": "node scripts/scan-excalidraw-similar-projects.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "^0.18.0",
|
||||
@@ -46,7 +47,7 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"winston": "^3.11.0",
|
||||
"ws": "8.20.0",
|
||||
"zod": "3.25.5",
|
||||
"zod": "^4.3.6",
|
||||
"zod-to-json-schema": "^3.22.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const API_BASE = "https://api.github.com";
|
||||
const DEFAULT_OUT_DIR = "docs/generated";
|
||||
const DEFAULT_TOP = 10;
|
||||
const DEFAULT_CANDIDATE_LIMIT = 40;
|
||||
const DEFAULT_FORK_PAGES = 1;
|
||||
|
||||
const SEARCH_QUERIES = [
|
||||
'excalidraw mcp in:name,description,readme',
|
||||
'"model context protocol" excalidraw in:name,description,readme',
|
||||
'"self-hosted excalidraw" websocket in:name,description,readme',
|
||||
'excalidraw sqlite in:name,description,readme',
|
||||
'excalidraw collaboration self-hosted in:name,description,readme',
|
||||
'excalidraw-mcp in:name,description,readme',
|
||||
'mcp_excalidraw in:name,description,readme',
|
||||
];
|
||||
|
||||
const SEED_REPOS = [
|
||||
"excalidraw/excalidraw",
|
||||
"yctimlin/mcp_excalidraw",
|
||||
"sanjibdevnathlabs/mcp-excalidraw-local",
|
||||
"celstnblacc/excalidraw-mcp-sentinel",
|
||||
"i-tozer/excalidraw-mcp",
|
||||
"alswl/excalidraw-collaboration",
|
||||
];
|
||||
|
||||
const SIGNALS = {
|
||||
mcp: [
|
||||
"@modelcontextprotocol/sdk",
|
||||
"model context protocol",
|
||||
"mcp server",
|
||||
"mcp",
|
||||
],
|
||||
liveBackend: [
|
||||
"websocket",
|
||||
"socket.io",
|
||||
" ws ",
|
||||
"canvas server",
|
||||
"backend",
|
||||
"live canvas",
|
||||
"real-time",
|
||||
"realtime",
|
||||
"collaboration",
|
||||
"sync",
|
||||
"express",
|
||||
],
|
||||
persistence: [
|
||||
"better-sqlite3",
|
||||
"sqlite",
|
||||
"postgres",
|
||||
"mongodb",
|
||||
"storage",
|
||||
"filesystem",
|
||||
"s3",
|
||||
"backup",
|
||||
"versioning",
|
||||
"drizzle",
|
||||
"prisma",
|
||||
],
|
||||
security: [
|
||||
"helmet",
|
||||
"rate limit",
|
||||
"rate-limit",
|
||||
"apikey",
|
||||
"api key",
|
||||
"auth",
|
||||
"oauth",
|
||||
"oidc",
|
||||
"encryption",
|
||||
"secure",
|
||||
"security",
|
||||
],
|
||||
workspaceIsolation: [
|
||||
"multi-tenant",
|
||||
"multi tenant",
|
||||
"workspace",
|
||||
"tenant",
|
||||
"project",
|
||||
"organizer",
|
||||
],
|
||||
selfHosted: [
|
||||
"self-hosted",
|
||||
"self hosted",
|
||||
"docker-compose",
|
||||
"docker compose",
|
||||
"docker",
|
||||
"localhost",
|
||||
"single binary",
|
||||
],
|
||||
excalidraw: [
|
||||
"@excalidraw/excalidraw",
|
||||
"excalidraw",
|
||||
],
|
||||
};
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
top: DEFAULT_TOP,
|
||||
candidateLimit: DEFAULT_CANDIDATE_LIMIT,
|
||||
forkPages: DEFAULT_FORK_PAGES,
|
||||
outDir: DEFAULT_OUT_DIR,
|
||||
excludeRepos: new Set(),
|
||||
verbose: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
options.help = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--verbose") {
|
||||
options.verbose = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--top") {
|
||||
options.top = parsePositiveInt(argv[++i], "--top");
|
||||
continue;
|
||||
}
|
||||
if (arg === "--candidate-limit") {
|
||||
options.candidateLimit = parsePositiveInt(argv[++i], "--candidate-limit");
|
||||
continue;
|
||||
}
|
||||
if (arg === "--fork-pages") {
|
||||
options.forkPages = parsePositiveInt(argv[++i], "--fork-pages");
|
||||
continue;
|
||||
}
|
||||
if (arg === "--out-dir") {
|
||||
options.outDir = argv[++i];
|
||||
if (!options.outDir) {
|
||||
throw new Error("--out-dir requires a value");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg === "--exclude-repo") {
|
||||
const repoName = argv[++i];
|
||||
if (!repoName || !repoName.includes("/")) {
|
||||
throw new Error("--exclude-repo requires a value like owner/name");
|
||||
}
|
||||
options.excludeRepos.add(repoName);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function parsePositiveInt(value, flagName) {
|
||||
const parsed = Number.parseInt(value ?? "", 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`${flagName} requires a positive integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node scripts/scan-excalidraw-similar-projects.mjs [options]
|
||||
|
||||
Options:
|
||||
--top <n> Number of ranked results to keep (default: ${DEFAULT_TOP})
|
||||
--candidate-limit <n> Max unique candidates to inspect (default: ${DEFAULT_CANDIDATE_LIMIT})
|
||||
--fork-pages <n> Number of GitHub fork pages to inspect per seed (default: ${DEFAULT_FORK_PAGES})
|
||||
--out-dir <path> Output directory for JSON and Markdown reports (default: ${DEFAULT_OUT_DIR})
|
||||
--exclude-repo <repo> Exclude a repo by full name; repeatable
|
||||
--verbose Print progress while scanning
|
||||
-h, --help Show this help
|
||||
|
||||
Environment:
|
||||
GITHUB_TOKEN Optional but recommended. Raises GitHub API rate limits.
|
||||
`);
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function formatDateUtc(date = new Date()) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function log(options, message) {
|
||||
if (options.verbose) {
|
||||
console.error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function githubRequest(apiPath, options, query = {}) {
|
||||
const url = new URL(`${API_BASE}${apiPath}`);
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
|
||||
const headers = {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "excalidraw-similar-project-scan",
|
||||
};
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, { headers });
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`GitHub API ${response.status} for ${url}: ${body.slice(0, 200)}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function searchRepositories(query, options) {
|
||||
log(options, `search: ${query}`);
|
||||
const payload = await githubRequest("/search/repositories", options, {
|
||||
q: query,
|
||||
per_page: 20,
|
||||
sort: "stars",
|
||||
order: "desc",
|
||||
});
|
||||
return payload?.items ?? [];
|
||||
}
|
||||
|
||||
async function listForks(fullName, options, pages) {
|
||||
const [owner, repo] = fullName.split("/");
|
||||
const results = [];
|
||||
for (let page = 1; page <= pages; page += 1) {
|
||||
log(options, `forks: ${fullName} page ${page}`);
|
||||
const payload = await githubRequest(`/repos/${owner}/${repo}/forks`, options, {
|
||||
per_page: 100,
|
||||
page,
|
||||
sort: "newest",
|
||||
});
|
||||
if (!Array.isArray(payload) || payload.length === 0) {
|
||||
break;
|
||||
}
|
||||
results.push(...payload);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function getRepoDetails(fullName, options) {
|
||||
const [owner, repo] = fullName.split("/");
|
||||
return githubRequest(`/repos/${owner}/${repo}`, options);
|
||||
}
|
||||
|
||||
async function getReadme(fullName, options) {
|
||||
const [owner, repo] = fullName.split("/");
|
||||
const payload = await githubRequest(`/repos/${owner}/${repo}/readme`, options);
|
||||
if (!payload?.content) {
|
||||
return "";
|
||||
}
|
||||
return decodeGitHubContent(payload.content);
|
||||
}
|
||||
|
||||
async function getPackageJson(fullName, options) {
|
||||
const [owner, repo] = fullName.split("/");
|
||||
const payload = await githubRequest(`/repos/${owner}/${repo}/contents/package.json`, options);
|
||||
if (!payload?.content) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(decodeGitHubContent(payload.content));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeGitHubContent(content) {
|
||||
return Buffer.from(content.replace(/\n/g, ""), "base64").toString("utf8");
|
||||
}
|
||||
|
||||
function dedupeRepos(repos) {
|
||||
const map = new Map();
|
||||
for (const repo of repos) {
|
||||
if (!repo?.full_name) {
|
||||
continue;
|
||||
}
|
||||
if (!map.has(repo.full_name)) {
|
||||
map.set(repo.full_name, repo);
|
||||
}
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
function rankSeedPriority(fullName) {
|
||||
const index = SEED_REPOS.indexOf(fullName);
|
||||
return index === -1 ? 999 : index;
|
||||
}
|
||||
|
||||
function sortCandidates(repos) {
|
||||
return [...repos].sort((a, b) => {
|
||||
const seedDelta = rankSeedPriority(a.full_name) - rankSeedPriority(b.full_name);
|
||||
if (seedDelta !== 0) {
|
||||
return seedDelta;
|
||||
}
|
||||
const starsA = a.stargazers_count ?? 0;
|
||||
const starsB = b.stargazers_count ?? 0;
|
||||
if (starsA !== starsB) {
|
||||
return starsB - starsA;
|
||||
}
|
||||
return a.full_name.localeCompare(b.full_name);
|
||||
});
|
||||
}
|
||||
|
||||
function buildRepoText(repo, readmeText, packageJson) {
|
||||
const topics = Array.isArray(repo.topics) ? repo.topics.join(" ") : "";
|
||||
const dependencies = Object.keys({
|
||||
...(packageJson?.dependencies ?? {}),
|
||||
...(packageJson?.devDependencies ?? {}),
|
||||
}).join(" ");
|
||||
return [
|
||||
repo.full_name,
|
||||
repo.description ?? "",
|
||||
topics,
|
||||
readmeText,
|
||||
dependencies,
|
||||
].join(" ").toLowerCase();
|
||||
}
|
||||
|
||||
function includesAny(text, needles) {
|
||||
return needles.some((needle) => text.includes(needle));
|
||||
}
|
||||
|
||||
function scoreRepo(repo, readmeText, packageJson) {
|
||||
const text = buildRepoText(repo, readmeText, packageJson);
|
||||
const signals = {
|
||||
excalidraw: includesAny(text, SIGNALS.excalidraw),
|
||||
mcp: includesAny(text, SIGNALS.mcp),
|
||||
liveBackend: includesAny(text, SIGNALS.liveBackend),
|
||||
persistence: includesAny(text, SIGNALS.persistence),
|
||||
security: includesAny(text, SIGNALS.security),
|
||||
workspaceIsolation: includesAny(text, SIGNALS.workspaceIsolation),
|
||||
selfHosted: includesAny(text, SIGNALS.selfHosted),
|
||||
};
|
||||
|
||||
const score =
|
||||
(signals.mcp ? 5 : 0) +
|
||||
(signals.liveBackend ? 4 : 0) +
|
||||
(signals.persistence ? 3 : 0) +
|
||||
(signals.security ? 3 : 0) +
|
||||
(signals.workspaceIsolation ? 3 : 0) +
|
||||
(signals.selfHosted ? 2 : 0);
|
||||
|
||||
let classification = "NOT_REALLY";
|
||||
if (signals.mcp && signals.liveBackend && score >= 10) {
|
||||
classification = "SAME";
|
||||
} else if (score >= 6) {
|
||||
classification = "ADJACENT";
|
||||
}
|
||||
|
||||
const reasons = [];
|
||||
if (signals.mcp) reasons.push("MCP");
|
||||
if (signals.liveBackend) reasons.push("live backend");
|
||||
if (signals.persistence) reasons.push("persistence");
|
||||
if (signals.security) reasons.push("security");
|
||||
if (signals.workspaceIsolation) reasons.push("workspace isolation");
|
||||
if (signals.selfHosted) reasons.push("self-hosted");
|
||||
|
||||
return {
|
||||
score,
|
||||
classification,
|
||||
signals,
|
||||
reason: reasons.join(", ") || "weak match",
|
||||
closestToThisRepo: signals.mcp && signals.liveBackend && (signals.persistence || signals.security),
|
||||
};
|
||||
}
|
||||
|
||||
function trimReadme(readmeText) {
|
||||
return readmeText.length > 24000 ? readmeText.slice(0, 24000) : readmeText;
|
||||
}
|
||||
|
||||
function renderMarkdown(report) {
|
||||
const lines = [];
|
||||
lines.push("# Excalidraw Similar Project Scan");
|
||||
lines.push("");
|
||||
lines.push(`Generated: ${report.generatedAt}`);
|
||||
lines.push("");
|
||||
lines.push("Scoring weights: `MCP=5`, `live backend=4`, `persistence=3`, `security=3`, `workspace isolation=3`, `self-hosted=2`.");
|
||||
lines.push("");
|
||||
lines.push("| Repo | Score | Class | Excalidraw fork? | Why it matched |");
|
||||
lines.push("|---|---:|---|---|---|");
|
||||
for (const result of report.results) {
|
||||
lines.push(
|
||||
`| [${result.fullName}](${result.htmlUrl}) | ${result.score}/20 | ${result.classification} | ${result.directExcalidrawFork ? "Yes" : "No"} | ${result.reason} |`
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("## Notes");
|
||||
lines.push("");
|
||||
lines.push("- This scan uses capability matching, not only fork ancestry.");
|
||||
lines.push("- `SAME` requires strong evidence of both `MCP` and a live backend/canvas layer.");
|
||||
lines.push("- Results are heuristic and based on public repo metadata, README content, and `package.json` when present.");
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
|
||||
for (const seed of SEED_REPOS) {
|
||||
const details = await getRepoDetails(seed, options);
|
||||
if (details) {
|
||||
candidates.push(details);
|
||||
}
|
||||
}
|
||||
|
||||
for (const query of SEARCH_QUERIES) {
|
||||
const repos = await searchRepositories(query, options);
|
||||
candidates.push(...repos);
|
||||
}
|
||||
|
||||
for (const seed of SEED_REPOS) {
|
||||
const forks = await listForks(seed, options, options.forkPages);
|
||||
candidates.push(...forks);
|
||||
}
|
||||
|
||||
const uniqueCandidates = sortCandidates(
|
||||
dedupeRepos(candidates).filter((repo) => !repo.archived && !options.excludeRepos.has(repo.full_name))
|
||||
).slice(0, options.candidateLimit);
|
||||
|
||||
const scored = [];
|
||||
for (const repo of uniqueCandidates) {
|
||||
const [readmeText, packageJson] = await Promise.all([
|
||||
getReadme(repo.full_name, options).catch(() => ""),
|
||||
getPackageJson(repo.full_name, options).catch(() => null),
|
||||
]);
|
||||
|
||||
const evaluation = scoreRepo(repo, trimReadme(readmeText), packageJson);
|
||||
if (!evaluation.signals.excalidraw) {
|
||||
continue;
|
||||
}
|
||||
scored.push({
|
||||
fullName: repo.full_name,
|
||||
htmlUrl: repo.html_url,
|
||||
description: repo.description ?? "",
|
||||
score: evaluation.score,
|
||||
classification: evaluation.classification,
|
||||
reason: evaluation.reason,
|
||||
signals: evaluation.signals,
|
||||
closestToThisRepo: evaluation.closestToThisRepo,
|
||||
stars: repo.stargazers_count ?? 0,
|
||||
fork: !!repo.fork,
|
||||
});
|
||||
}
|
||||
|
||||
scored.sort((a, b) => {
|
||||
if (a.score !== b.score) return b.score - a.score;
|
||||
if (a.closestToThisRepo !== b.closestToThisRepo) return Number(b.closestToThisRepo) - Number(a.closestToThisRepo);
|
||||
if (a.stars !== b.stars) return b.stars - a.stars;
|
||||
return a.fullName.localeCompare(b.fullName);
|
||||
});
|
||||
|
||||
const topResults = scored.slice(0, options.top);
|
||||
|
||||
for (const result of topResults) {
|
||||
const details = await getRepoDetails(result.fullName, options).catch(() => null);
|
||||
result.directExcalidrawFork = details?.parent?.full_name === "excalidraw/excalidraw";
|
||||
result.parentFullName = details?.parent?.full_name ?? null;
|
||||
}
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
config: {
|
||||
top: options.top,
|
||||
candidateLimit: options.candidateLimit,
|
||||
forkPages: options.forkPages,
|
||||
excludeRepos: [...options.excludeRepos],
|
||||
searchQueries: SEARCH_QUERIES,
|
||||
seedRepos: SEED_REPOS,
|
||||
},
|
||||
results: topResults,
|
||||
};
|
||||
|
||||
fs.mkdirSync(options.outDir, { recursive: true });
|
||||
const stamp = formatDateUtc();
|
||||
const baseName = `${stamp}-${slugify("excalidraw-similar-project-scan")}`;
|
||||
const jsonPath = path.join(options.outDir, `${baseName}.json`);
|
||||
const markdownPath = path.join(options.outDir, `${baseName}.md`);
|
||||
|
||||
fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2));
|
||||
fs.writeFileSync(markdownPath, renderMarkdown(report));
|
||||
|
||||
console.log(`Wrote ${jsonPath}`);
|
||||
console.log(`Wrote ${markdownPath}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -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,19 +616,41 @@ 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): Project {
|
||||
export function createProject(name: string, description?: string, tenantId?: string): Project {
|
||||
const tid = tenantId ?? activeTenantId;
|
||||
const id = generateId();
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
'INSERT INTO projects (id, name, description, tenant_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(id, name, description || null, activeTenantId, now, now);
|
||||
return { id, name, description: description || null, tenant_id: activeTenantId, created_at: now, updated_at: now };
|
||||
).run(id, name, description || null, tid, now, now);
|
||||
return { id, name, description: description || null, tenant_id: tid, created_at: now, updated_at: now };
|
||||
}
|
||||
|
||||
export function listProjects(): Project[] {
|
||||
return db.prepare('SELECT * FROM projects WHERE tenant_id = ? ORDER BY updated_at DESC').all(activeTenantId) as Project[];
|
||||
export function listProjects(tenantId?: string): Project[] {
|
||||
const tid = tenantId ?? activeTenantId;
|
||||
return db.prepare('SELECT * FROM projects WHERE tenant_id = ? ORDER BY updated_at DESC').all(tid) as Project[];
|
||||
}
|
||||
|
||||
export function getProjectForTenant(projectId: string, tenantId: string): Project | undefined {
|
||||
@@ -562,6 +676,22 @@ export function getActiveProjectId(): string {
|
||||
return activeProjectId;
|
||||
}
|
||||
|
||||
export function deleteProject(id: string): void {
|
||||
const projects = listProjects();
|
||||
if (projects.length <= 1) throw new Error('Cannot delete the last project');
|
||||
const project = db.prepare('SELECT id, tenant_id FROM projects WHERE id = ?').get(id) as { id: string; tenant_id: string } | undefined;
|
||||
if (!project) throw new Error(`Project "${id}" not found`);
|
||||
if (project.tenant_id !== activeTenantId) throw new Error(`Project "${id}" does not belong to the active tenant`);
|
||||
if (id === activeProjectId) throw new Error('Cannot delete the active project — switch to another project first');
|
||||
// CASCADE deletes elements, element_versions rows, and snapshots automatically
|
||||
db.prepare('DELETE FROM projects WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
export function getElementCountForProject(projectId: string): number {
|
||||
const row = db.prepare('SELECT COUNT(*) as cnt FROM elements WHERE project_id = ? AND (data NOT LIKE \'%"is_deleted":true%\')').get(projectId) as { cnt: number };
|
||||
return row.cnt;
|
||||
}
|
||||
|
||||
// ── Bulk operations (for sync endpoint) ──
|
||||
|
||||
export function bulkReplaceElements(elements: ServerElement[], projectId?: string): number {
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
{ "id": 9, "name": "Liberation Sans", "label": "Liberation Sans", "aliases": ["liberation sans"], "legacy": true },
|
||||
{ "id": 1, "name": "Virgil", "label": "Virgil (legacy)", "aliases": ["virgil"], "legacy": true }
|
||||
],
|
||||
"defaultFontFamily": 5
|
||||
"defaultFontFamily": 6
|
||||
}
|
||||
|
||||
+225
-85
@@ -13,7 +13,9 @@ import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
CallToolRequest,
|
||||
Tool
|
||||
Tool,
|
||||
McpError,
|
||||
ErrorCode
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { z } from 'zod';
|
||||
import dotenv from 'dotenv';
|
||||
@@ -33,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,
|
||||
@@ -41,8 +44,10 @@ import {
|
||||
getElementHistory as dbGetElementHistory, getProjectHistory as dbGetProjectHistory,
|
||||
ensureTenant as dbEnsureTenant, setActiveTenant as dbSetActiveTenant,
|
||||
getActiveTenant as dbGetActiveTenant, getActiveTenantId as dbGetActiveTenantId,
|
||||
getActiveProjectId as dbGetActiveProjectId,
|
||||
listTenants as dbListTenants
|
||||
} from './db.js';
|
||||
import { assertNoDangerousKeys } from './security.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -335,6 +340,14 @@ const ElementSchema = z.object({
|
||||
elbowed: z.boolean().optional(),
|
||||
startElementId: z.string().optional(),
|
||||
endElementId: z.string().optional(),
|
||||
textAlign: z.string().optional(),
|
||||
verticalAlign: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
titleFontSize: z.number().optional(),
|
||||
titleFontFamily: z.union([z.string(), z.number()]).optional(),
|
||||
subtitle: z.string().optional(),
|
||||
subtitleFontSize: z.number().optional(),
|
||||
subtitleFontFamily: z.union([z.string(), z.number()]).optional(),
|
||||
endArrowhead: z.string().optional(),
|
||||
startArrowhead: z.string().optional(),
|
||||
fileId: z.string().optional(),
|
||||
@@ -366,7 +379,7 @@ const DistributeElementsSchema = z.object({
|
||||
|
||||
const QuerySchema = z.object({
|
||||
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]).optional(),
|
||||
filter: z.record(z.any()).optional()
|
||||
filter: z.record(z.string(), z.any()).optional()
|
||||
});
|
||||
|
||||
const ResourceSchema = z.object({
|
||||
@@ -470,7 +483,7 @@ const DIAGRAM_DESIGN_GUIDE = `# Excalidraw Diagram Design Guide
|
||||
const tools: Tool[] = [
|
||||
{
|
||||
name: 'create_element',
|
||||
description: 'Create a new Excalidraw element. For arrows, use startElementId/endElementId to bind to shapes (auto-routes to edges).',
|
||||
description: 'Create a new Excalidraw element. For arrows, use startElementId/endElementId to bind to shapes (auto-routes to edges). For containers (rectangle, ellipse, diamond): use title+subtitle for a card layout with independent font styling — both are grouped so they move together.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -489,9 +502,17 @@ const tools: Tool[] = [
|
||||
strokeStyle: { type: 'string', description: 'Stroke style: solid, dashed, dotted' },
|
||||
roughness: { type: 'number' },
|
||||
opacity: { type: 'number' },
|
||||
text: { type: 'string' },
|
||||
text: { type: 'string', description: 'Simple label text (use title+subtitle instead for card layout)' },
|
||||
fontSize: { type: 'number' },
|
||||
fontFamily: { type: ['string', 'number'], description: FONT_FAMILY_DESCRIPTION },
|
||||
textAlign: { type: 'string', description: 'Text horizontal alignment: left, center, right (default: center)' },
|
||||
verticalAlign: { type: 'string', description: 'Text vertical alignment: top, middle (default: top for containers)' },
|
||||
title: { type: 'string', description: 'Title text for card layout (bound to container, moves with it)' },
|
||||
titleFontSize: { type: 'number', description: 'Title font size (default: 24)' },
|
||||
titleFontFamily: { type: ['string', 'number'], description: 'Title font family (default: Nunito). ' + FONT_FAMILY_DESCRIPTION },
|
||||
subtitle: { type: 'string', description: 'Subtitle/paragraph text (grouped with container, moves together)' },
|
||||
subtitleFontSize: { type: 'number', description: 'Subtitle font size (default: 16)' },
|
||||
subtitleFontFamily: { type: ['string', 'number'], description: 'Subtitle font family (default: Nunito). ' + FONT_FAMILY_DESCRIPTION },
|
||||
startElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow start to. Arrow auto-routes to element edge.' },
|
||||
endElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow end to. Arrow auto-routes to element edge.' },
|
||||
endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' },
|
||||
@@ -1014,39 +1035,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
|
||||
}]))
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
);
|
||||
registerHandlers(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
// Helper function to convert text property to label format for Excalidraw
|
||||
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 {
|
||||
const { text, ...rest } = element;
|
||||
if (text) {
|
||||
// For standalone text elements, keep text as direct property
|
||||
if (element.type === 'text') {
|
||||
return element; // Keep text as direct property
|
||||
}
|
||||
// For other elements (rectangle, ellipse, diamond), convert to label format
|
||||
return {
|
||||
...rest,
|
||||
label: { text }
|
||||
} as 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 {
|
||||
@@ -1058,15 +1081,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
const params = ElementSchema.parse(args);
|
||||
logger.info('Creating element via MCP', { type: params.type });
|
||||
|
||||
const { startElementId, endElementId, id: customId, ...elementProps } = params;
|
||||
const {
|
||||
startElementId, endElementId, id: customId,
|
||||
title, titleFontSize, titleFontFamily,
|
||||
subtitle, subtitleFontSize, subtitleFontFamily,
|
||||
...elementProps
|
||||
} = params;
|
||||
const id = customId || generateId();
|
||||
const normalizedFont = normalizeFontFamily(elementProps.fontFamily);
|
||||
|
||||
// Auto-populate title+subtitle for container types unless text is explicitly set
|
||||
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
|
||||
? (normalizeFontFamily(titleFontFamily) ?? USER_PREFS.fontFamily)
|
||||
: (normalizedFont ?? USER_PREFS.fontFamily);
|
||||
|
||||
const element: ServerElement = {
|
||||
id,
|
||||
...elementProps,
|
||||
fontFamily: normalizedFont ?? USER_PREFS.fontFamily,
|
||||
text: effectiveText,
|
||||
fontFamily: effectiveFontFamily,
|
||||
roughness: elementProps.roughness ?? USER_PREFS.roughness,
|
||||
fontSize: elementProps.fontSize ?? USER_PREFS.fontSize,
|
||||
fontSize: effectiveFontSize,
|
||||
strokeWidth: elementProps.strokeWidth ?? USER_PREFS.strokeWidth,
|
||||
points: elementProps.points ? normalizePoints(elementProps.points) : undefined,
|
||||
...(startElementId ? { start: { id: startElementId } } : {}),
|
||||
@@ -1084,18 +1127,61 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
// Convert text to label format for Excalidraw
|
||||
const excalidrawElement = convertTextToLabel(element);
|
||||
|
||||
// Create element directly on HTTP server (no local storage)
|
||||
// Card layout: title (bound) + subtitle (grouped standalone text)
|
||||
const groupId = (effectiveTitle && effectiveSubtitle && isContainer) ? generateId() : undefined;
|
||||
|
||||
// Add groupId to container if using card layout
|
||||
if (groupId) {
|
||||
(excalidrawElement as any).groupIds = [groupId];
|
||||
}
|
||||
|
||||
// Create the container element
|
||||
const canvasResponse = await createElementOnCanvas(excalidrawElement);
|
||||
|
||||
if (!canvasResponse) {
|
||||
throw new Error('Failed to create element: HTTP server unavailable');
|
||||
}
|
||||
|
||||
let subtitleResponse: any = null;
|
||||
|
||||
// Create subtitle as a grouped standalone text element
|
||||
if (effectiveSubtitle && isContainer && groupId) {
|
||||
const containerWidth = elementProps.width ?? 200;
|
||||
const containerHeight = elementProps.height ?? 100;
|
||||
const subtitleId = generateId();
|
||||
const resolvedSubtitleFont = normalizeFontFamily(subtitleFontFamily) ?? USER_PREFS.fontFamily;
|
||||
const resolvedSubtitleSize = subtitleFontSize ?? 16;
|
||||
|
||||
const subtitleElement: ServerElement = {
|
||||
id: subtitleId,
|
||||
type: 'text',
|
||||
x: element.x + 10,
|
||||
y: element.y + (containerHeight * 0.45),
|
||||
width: containerWidth - 20,
|
||||
height: containerHeight * 0.5,
|
||||
text: effectiveSubtitle,
|
||||
fontSize: resolvedSubtitleSize,
|
||||
fontFamily: resolvedSubtitleFont,
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'top',
|
||||
strokeColor: elementProps.strokeColor ?? '#1e1e1e',
|
||||
opacity: elementProps.opacity ?? 100,
|
||||
roughness: elementProps.roughness ?? USER_PREFS.roughness,
|
||||
groupIds: [groupId],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
version: 1
|
||||
} as any;
|
||||
|
||||
subtitleResponse = await createElementOnCanvas(subtitleElement);
|
||||
}
|
||||
|
||||
const synced = canvasResponse.syncedToCanvas ?? false;
|
||||
logger.info('Element created via MCP', {
|
||||
id: excalidrawElement.id,
|
||||
type: excalidrawElement.type,
|
||||
synced,
|
||||
hasSubtitle: !!subtitleResponse,
|
||||
canvasStatus: canvasResponse.canvasStatus
|
||||
});
|
||||
|
||||
@@ -1104,10 +1190,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
? 'Synced to canvas and confirmed by browser'
|
||||
: `Canvas sync not confirmed (${canvasResponse.canvasStatus?.reason ?? 'unknown'})`;
|
||||
|
||||
const subtitleInfo = subtitleResponse
|
||||
? `\n\nSubtitle element: ${subtitleResponse.element?.id ?? 'created'} (grouped)`
|
||||
: '';
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Element created successfully!\n\n${JSON.stringify(canvasResponse.element ?? excalidrawElement, null, 2)}\n\n${statusEmoji} ${statusText}`
|
||||
text: `Element created successfully!\n\n${JSON.stringify(canvasResponse.element ?? excalidrawElement, null, 2)}${subtitleInfo}\n\n${statusEmoji} ${statusText}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
@@ -1778,7 +1868,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',
|
||||
@@ -1807,10 +1897,12 @@ 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) {
|
||||
sceneData = JSON.parse(params.data);
|
||||
assertNoDangerousKeys(sceneData, 'import_scene data');
|
||||
} else {
|
||||
throw new Error('Either filePath or data must be provided');
|
||||
}
|
||||
@@ -1917,9 +2009,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: [{
|
||||
@@ -2322,7 +2414,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,
|
||||
@@ -2336,16 +2438,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
|
||||
};
|
||||
@@ -2360,46 +2459,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 ?? '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 = [
|
||||
@@ -2463,8 +2559,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
originalText: labelText,
|
||||
fontSize: isArrow ? 14 : (rest.fontSize ?? USER_PREFS.fontSize),
|
||||
fontFamily: rest.fontFamily ?? USER_PREFS.fontFamily,
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
textAlign: rest.textAlign ?? 'center',
|
||||
verticalAlign: rest.verticalAlign ?? (isArrow ? 'middle' : 'top'),
|
||||
autoResize: true,
|
||||
lineHeight: 1.25,
|
||||
containerId: base.id
|
||||
@@ -2627,7 +2723,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
const params = z.object({ query: z.string() }).parse(args);
|
||||
logger.info('Searching elements via MCP', { query: params.query });
|
||||
|
||||
const results = dbSearchElements(params.query);
|
||||
const results = dbSearchElements(params.query, dbGetActiveProjectId());
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
@@ -2640,7 +2736,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
|
||||
case 'list_projects': {
|
||||
logger.info('Listing projects via MCP');
|
||||
const projects = dbListProjects();
|
||||
const projects = dbListProjects(dbGetActiveTenantId());
|
||||
const active = dbGetActiveProject();
|
||||
return {
|
||||
content: [{
|
||||
@@ -2658,8 +2754,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
}).parse(args || {});
|
||||
|
||||
if (params.createName) {
|
||||
const newProject = dbCreateProject(params.createName, params.createDescription);
|
||||
dbSetActiveProject(newProject.id);
|
||||
const newProject = dbCreateProject(params.createName, params.createDescription, dbGetActiveTenantId());
|
||||
// Switch via REST so the canvas broadcasts project_switched to the frontend
|
||||
const switchRes = await fetch(`${EXPRESS_SERVER_URL}/api/project/active`, {
|
||||
method: 'PUT',
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({ projectId: newProject.id })
|
||||
}).catch(() => null);
|
||||
if (!switchRes) {
|
||||
// Canvas unavailable — fall back to direct DB switch
|
||||
dbSetActiveProject(newProject.id);
|
||||
}
|
||||
logger.info('Created and switched to new project', { project: newProject });
|
||||
return {
|
||||
content: [{
|
||||
@@ -2670,7 +2775,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
}
|
||||
|
||||
if (params.projectId) {
|
||||
dbSetActiveProject(params.projectId);
|
||||
// Switch via REST so the canvas broadcasts project_switched to the frontend
|
||||
const switchRes = await fetch(`${EXPRESS_SERVER_URL}/api/project/active`, {
|
||||
method: 'PUT',
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({ projectId: params.projectId })
|
||||
}).catch(() => null);
|
||||
if (!switchRes) {
|
||||
// Canvas unavailable — fall back to direct DB switch
|
||||
dbSetActiveProject(params.projectId);
|
||||
}
|
||||
const active = dbGetActiveProject();
|
||||
logger.info('Switched project', { project: active });
|
||||
return {
|
||||
@@ -2753,9 +2867,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof McpError) {
|
||||
throw error;
|
||||
}
|
||||
logger.error(`Error handling tool call: ${(error as Error).message}`, { error });
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${(error as Error).message}` }],
|
||||
@@ -2770,6 +2887,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return { tools };
|
||||
});
|
||||
|
||||
} // end registerHandlers
|
||||
|
||||
// Start server
|
||||
async function runServer(): Promise<void> {
|
||||
try {
|
||||
@@ -2800,6 +2919,26 @@ async function runServer(): Promise<void> {
|
||||
logger.warn('MCP tools will work without real-time canvas sync');
|
||||
}
|
||||
|
||||
// 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();
|
||||
logger.debug('Connecting to stdio transport...');
|
||||
|
||||
@@ -2940,4 +3079,5 @@ if (isMainModule()) {
|
||||
}
|
||||
}
|
||||
|
||||
export default runServer;
|
||||
export default runServer;
|
||||
export { server, tools };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
+9
-3
@@ -111,6 +111,12 @@ export function sanitizeBody(req: Request, res: Response, next: NextFunction): v
|
||||
next();
|
||||
}
|
||||
|
||||
export function assertNoDangerousKeys(obj: unknown, context = 'input'): void {
|
||||
if (hasDangerousKey(obj)) {
|
||||
throw new Error(`${context} contains disallowed keys (__proto__, constructor, prototype)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mermaid Input Validation ──────────────────────────────────────────────────
|
||||
const MAX_MERMAID_LENGTH = 50 * 1024; // 50 KB
|
||||
const MAX_MERMAID_CONFIG_KEYS = 10;
|
||||
@@ -137,7 +143,7 @@ export function validateMermaidInput(req: Request, res: Response, next: NextFunc
|
||||
// General limit for all /api routes.
|
||||
export const generalRateLimit = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: getEnvInt('EXCALIDRAW_RATE_LIMIT_GENERAL_MAX', 100),
|
||||
max: getEnvInt('EXCALIDRAW_RATE_LIMIT_GENERAL_MAX', 500),
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
message: { success: false, error: 'Too many requests, please try again later.' },
|
||||
@@ -155,7 +161,7 @@ export const destructiveRateLimit = rateLimit({
|
||||
// Stricter limit for write-heavy sync operations.
|
||||
export const writeBurstLimit = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: getEnvInt('EXCALIDRAW_RATE_LIMIT_WRITE_BURST_MAX', 10),
|
||||
max: getEnvInt('EXCALIDRAW_RATE_LIMIT_WRITE_BURST_MAX', 30),
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
message: { success: false, error: 'Too many sync operations, please slow down.' },
|
||||
@@ -205,7 +211,7 @@ export function sanitizeSearchQuery(query: string): string {
|
||||
if (/\b(?:AND|OR|NOT|NEAR(?:\/\d+)?)\b/i.test(trimmed)) {
|
||||
throw new InvalidSearchQueryError();
|
||||
}
|
||||
if (/[*(){}^]/.test(trimmed)) {
|
||||
if (/[*(){}^:]/.test(trimmed)) {
|
||||
throw new InvalidSearchQueryError();
|
||||
}
|
||||
|
||||
|
||||
+210
-11
@@ -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 } 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';
|
||||
|
||||
@@ -62,9 +62,11 @@ app.use(express.static(path.join(__dirname, '../dist/frontend'), { index: false
|
||||
|
||||
// Resolve tenant from X-Tenant-Id header to a projectId override.
|
||||
// Returns undefined when header is absent (browser requests), falling back to global state.
|
||||
// When the requesting tenant is the active tenant, use the active project (honours project switches).
|
||||
function resolveTenantProject(req: Request): string | undefined {
|
||||
const tenantId = req.headers['x-tenant-id'] as string | undefined;
|
||||
if (!tenantId) return undefined;
|
||||
if (tenantId === dbGetActiveTenantId()) return dbGetActiveProjectId();
|
||||
return getDefaultProjectForTenant(tenantId);
|
||||
}
|
||||
|
||||
@@ -73,12 +75,14 @@ function resolveTenantProject(req: Request): string | undefined {
|
||||
function resolveScope(req: Request): { tenantId: string; projectId: string } {
|
||||
const headerTenantId = req.headers['x-tenant-id'] as string | undefined;
|
||||
if (headerTenantId) {
|
||||
const projectId = getDefaultProjectForTenant(headerTenantId) ?? `${headerTenantId}-default`;
|
||||
const projectId = headerTenantId === dbGetActiveTenantId()
|
||||
? dbGetActiveProjectId()
|
||||
: (getDefaultProjectForTenant(headerTenantId) ?? `${headerTenantId}-default`);
|
||||
return { tenantId: headerTenantId, projectId };
|
||||
}
|
||||
// Fallback for browser requests without header
|
||||
const tenant = dbGetActiveTenant();
|
||||
const projectId = getDefaultProjectForTenant(tenant.id) ?? `${tenant.id}-default`;
|
||||
const projectId = dbGetActiveProjectId() ?? `${tenant.id}-default`;
|
||||
return { tenantId: tenant.id, projectId };
|
||||
}
|
||||
|
||||
@@ -487,11 +491,15 @@ const ElementSharedFieldsSchema = z.object({
|
||||
endBinding: z.any().nullable().optional(),
|
||||
boundElements: z.any().nullable().optional(),
|
||||
elbowed: z.boolean().optional(),
|
||||
// Text alignment properties (required for bound text inside containers)
|
||||
textAlign: z.string().optional(),
|
||||
verticalAlign: z.string().optional(),
|
||||
containerId: z.string().nullable().optional(),
|
||||
// Image element properties
|
||||
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(),
|
||||
@@ -551,19 +559,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,
|
||||
@@ -612,19 +625,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,
|
||||
@@ -839,6 +872,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>();
|
||||
@@ -945,7 +1040,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);
|
||||
@@ -1582,6 +1679,108 @@ 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();
|
||||
const active = dbGetActiveProject();
|
||||
res.json({ success: true, projects, activeProjectId: active.id });
|
||||
} catch (error) {
|
||||
logger.error('Error listing projects:', error);
|
||||
res.status(500).json({ success: false, error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/projects', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||
return res.status(400).json({ success: false, error: 'name is required' });
|
||||
}
|
||||
const project = dbCreateProject(name.trim(), description);
|
||||
res.status(201).json({ success: true, project });
|
||||
} catch (error) {
|
||||
logger.error('Error creating project:', error);
|
||||
res.status(400).json({ success: false, error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/projects/:id', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const elementCount = dbGetElementCountForProject(id!);
|
||||
dbDeleteProject(id!);
|
||||
broadcast({ type: 'project_deleted', projectId: id, elementCount } as any);
|
||||
res.json({ success: true, projectId: id, elementCount });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting project:', error);
|
||||
res.status(400).json({ success: false, error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/project/active', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectId } = req.body;
|
||||
if (!projectId || typeof projectId !== 'string') {
|
||||
return res.status(400).json({ success: false, error: 'projectId is required' });
|
||||
}
|
||||
|
||||
dbSetActiveProject(projectId);
|
||||
const project = dbGetActiveProject();
|
||||
|
||||
broadcast({
|
||||
type: 'project_switched',
|
||||
projectId: project.id,
|
||||
projectName: project.name
|
||||
} as any);
|
||||
|
||||
res.json({ success: true, project });
|
||||
} catch (error) {
|
||||
logger.error('Error switching project:', error);
|
||||
res.status(400).json({ success: false, error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Settings API ──
|
||||
|
||||
app.get('/api/settings/:key', (req: Request, res: Response) => {
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface ExcalidrawElementBase {
|
||||
customData?: Record<string, any> | null;
|
||||
boundElements?: readonly ExcalidrawBoundElement[] | null;
|
||||
updated?: number;
|
||||
index?: string;
|
||||
containerId?: string | null;
|
||||
}
|
||||
|
||||
@@ -143,6 +144,10 @@ export interface ServerElement extends Omit<ExcalidrawElementBase, 'id'> {
|
||||
end?: { id: string };
|
||||
startBinding?: ExcalidrawBinding | null;
|
||||
endBinding?: ExcalidrawBinding | null;
|
||||
// Text alignment (bound text inside containers)
|
||||
textAlign?: string;
|
||||
verticalAlign?: string;
|
||||
containerId?: string | null;
|
||||
// Image element properties
|
||||
fileId?: string;
|
||||
status?: string;
|
||||
|
||||
+7
-7
@@ -1,7 +1,5 @@
|
||||
import winston from 'winston';
|
||||
|
||||
const LOG_FILE_PATH = process.env.LOG_FILE_PATH || 'excalidraw.log';
|
||||
|
||||
const logger: winston.Logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
|
||||
@@ -21,13 +19,15 @@ const logger: winston.Logger = winston.createLogger({
|
||||
new winston.transports.Console({
|
||||
level: 'warn', // only warn+error to stderr
|
||||
stderrLevels: ['warn','error']
|
||||
}),
|
||||
|
||||
new winston.transports.File({
|
||||
filename: LOG_FILE_PATH, // all levels to file
|
||||
level: 'debug'
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
if (process.env.LOG_FILE_PATH) {
|
||||
logger.add(new winston.transports.File({
|
||||
filename: process.env.LOG_FILE_PATH,
|
||||
level: 'debug'
|
||||
}));
|
||||
}
|
||||
|
||||
export default logger;
|
||||
+281
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setElement, getAllElements, setSetting, getSetting, getCurrentSyncVersion, setActiveTenant } from '../../src/db.js';
|
||||
import { initDb, closeDb, setElement, getAllElements, setSetting, getSetting, getCurrentSyncVersion, setActiveTenant, getActiveProjectId, getElementCountForProject } from '../../src/db.js';
|
||||
import type { ServerElement } from '../../src/types.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
@@ -587,3 +587,283 @@ describe('canvasStatus in mutation responses', () => {
|
||||
expect(res.body.canvasStatus).toHaveProperty('scope');
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: textAlign/verticalAlign/containerId must survive REST round-trip
|
||||
// These fields were silently stripped by Zod before the fix (ElementSharedFieldsSchema
|
||||
// did not declare them, so .parse() dropped them).
|
||||
describe('Text alignment fields — REST round-trip regression', () => {
|
||||
it('POST /api/elements preserves textAlign and verticalAlign', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({
|
||||
type: 'text',
|
||||
x: 10, y: 20, width: 100, height: 30,
|
||||
text: 'Hello',
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const el = res.body.element;
|
||||
expect(el.textAlign).toBe('center');
|
||||
expect(el.verticalAlign).toBe('middle');
|
||||
});
|
||||
|
||||
it('POST /api/elements preserves containerId on bound text', async () => {
|
||||
// Create container first
|
||||
const containerRes = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'rectangle', x: 0, y: 0, width: 200, height: 100 });
|
||||
expect(containerRes.status).toBe(200);
|
||||
const containerId = containerRes.body.element?.id;
|
||||
expect(containerId).toBeTruthy();
|
||||
|
||||
// Create bound text referencing the container
|
||||
const textRes = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({
|
||||
type: 'text',
|
||||
x: 10, y: 10, width: 180, height: 20,
|
||||
text: 'Title',
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'top',
|
||||
containerId,
|
||||
});
|
||||
|
||||
expect(textRes.status).toBe(200);
|
||||
const textEl = textRes.body.element;
|
||||
expect(textEl.containerId).toBe(containerId);
|
||||
expect(textEl.textAlign).toBe('center');
|
||||
expect(textEl.verticalAlign).toBe('top');
|
||||
});
|
||||
|
||||
it('PUT /api/elements/:id preserves textAlign on update', async () => {
|
||||
const createRes = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'text', x: 0, y: 0, width: 100, height: 30, text: 'Hi', textAlign: 'left' });
|
||||
expect(createRes.status).toBe(200);
|
||||
const id = createRes.body.element?.id;
|
||||
expect(id).toBeTruthy();
|
||||
|
||||
const updateRes = await request(app)
|
||||
.put(`/api/elements/${id}`)
|
||||
.send({ id, type: 'text', x: 0, y: 0, textAlign: 'center' });
|
||||
|
||||
expect(updateRes.status).toBe(200);
|
||||
expect(updateRes.body.element?.textAlign).toBe('center');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Projects ─────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/projects', () => {
|
||||
it('returns the default project and marks it active', async () => {
|
||||
const res = await request(app).get('/api/projects');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(Array.isArray(res.body.projects)).toBe(true);
|
||||
expect(res.body.projects.length).toBeGreaterThanOrEqual(1);
|
||||
expect(res.body.activeProjectId).toBeTruthy();
|
||||
const active = res.body.projects.find((p: any) => p.id === res.body.activeProjectId);
|
||||
expect(active).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/projects', () => {
|
||||
it('creates a new project and returns it', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/projects')
|
||||
.send({ name: 'My Diagram', description: 'test desc' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.project.name).toBe('My Diagram');
|
||||
expect(res.body.project.description).toBe('test desc');
|
||||
expect(res.body.project.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns 400 when name is missing', async () => {
|
||||
const res = await request(app).post('/api/projects').send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('returns 400 when name is blank', async () => {
|
||||
const res = await request(app).post('/api/projects').send({ name: ' ' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('new project appears in GET /api/projects list', async () => {
|
||||
await request(app).post('/api/projects').send({ name: 'Alpha' });
|
||||
await request(app).post('/api/projects').send({ name: 'Beta' });
|
||||
const res = await request(app).get('/api/projects');
|
||||
const names = res.body.projects.map((p: any) => p.name);
|
||||
expect(names).toContain('Alpha');
|
||||
expect(names).toContain('Beta');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/project/active', () => {
|
||||
it('switches the active project', async () => {
|
||||
const created = await request(app)
|
||||
.post('/api/projects')
|
||||
.send({ name: 'Switch Target' });
|
||||
const newId = created.body.project.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/project/active')
|
||||
.send({ projectId: newId });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.project.id).toBe(newId);
|
||||
|
||||
// DB state reflects the switch
|
||||
expect(getActiveProjectId()).toBe(newId);
|
||||
});
|
||||
|
||||
it('returns 400 when projectId is missing', async () => {
|
||||
const res = await request(app).put('/api/project/active').send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('returns 400 for a non-existent projectId', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/project/active')
|
||||
.send({ projectId: 'does-not-exist' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Project switch preserves elements ──────────────────────
|
||||
|
||||
describe('Project switch round-trip — elements survive', () => {
|
||||
it('elements saved in project A persist after switching to B and back', async () => {
|
||||
// Create project "dude"
|
||||
const dudeRes = await request(app).post('/api/projects').send({ name: 'dude' });
|
||||
const dudeId = dudeRes.body.project.id;
|
||||
const defaultId = getActiveProjectId(); // save original
|
||||
|
||||
// Switch to "dude"
|
||||
await request(app).put('/api/project/active').send({ projectId: dudeId });
|
||||
expect(getActiveProjectId()).toBe(dudeId);
|
||||
|
||||
// Draw 2 elements in "dude"
|
||||
const el1 = makeElement({ id: 'dude-rect-1', type: 'rectangle', x: 10, y: 10, width: 100, height: 50 });
|
||||
const el2 = makeElement({ id: 'dude-rect-2', type: 'rectangle', x: 200, y: 200, width: 120, height: 80 });
|
||||
await request(app).post('/api/elements').send(el1);
|
||||
await request(app).post('/api/elements').send(el2);
|
||||
|
||||
// Verify 2 elements in "dude"
|
||||
const dudeElems1 = await request(app).get('/api/elements');
|
||||
expect(dudeElems1.body.elements.length).toBe(2);
|
||||
|
||||
// Switch to "default"
|
||||
await request(app).put('/api/project/active').send({ projectId: defaultId });
|
||||
expect(getActiveProjectId()).toBe(defaultId);
|
||||
|
||||
// "default" should have 0 elements (fresh DB)
|
||||
const defaultElems = await request(app).get('/api/elements');
|
||||
expect(defaultElems.body.elements.length).toBe(0);
|
||||
|
||||
// Switch back to "dude"
|
||||
await request(app).put('/api/project/active').send({ projectId: dudeId });
|
||||
expect(getActiveProjectId()).toBe(dudeId);
|
||||
|
||||
// "dude" should still have the 2 elements
|
||||
const dudeElems2 = await request(app).get('/api/elements');
|
||||
expect(dudeElems2.body.elements.length).toBe(2);
|
||||
const ids = dudeElems2.body.elements.map((e: any) => e.id);
|
||||
expect(ids).toContain('dude-rect-1');
|
||||
expect(ids).toContain('dude-rect-2');
|
||||
});
|
||||
|
||||
it('elements in different projects are isolated', async () => {
|
||||
// Create two projects
|
||||
const projA = await request(app).post('/api/projects').send({ name: 'Project A' });
|
||||
const projB = await request(app).post('/api/projects').send({ name: 'Project B' });
|
||||
const aId = projA.body.project.id;
|
||||
const bId = projB.body.project.id;
|
||||
|
||||
// Add element to Project A
|
||||
await request(app).put('/api/project/active').send({ projectId: aId });
|
||||
await request(app).post('/api/elements').send(
|
||||
makeElement({ id: 'a-only', type: 'ellipse', x: 0, y: 0, width: 50, height: 50 })
|
||||
);
|
||||
|
||||
// Add element to Project B
|
||||
await request(app).put('/api/project/active').send({ projectId: bId });
|
||||
await request(app).post('/api/elements').send(
|
||||
makeElement({ id: 'b-only', type: 'diamond', x: 0, y: 0, width: 50, height: 50 })
|
||||
);
|
||||
|
||||
// Verify isolation
|
||||
const bElems = await request(app).get('/api/elements');
|
||||
expect(bElems.body.elements.length).toBe(1);
|
||||
expect(bElems.body.elements[0].id).toBe('b-only');
|
||||
|
||||
await request(app).put('/api/project/active').send({ projectId: aId });
|
||||
const aElems = await request(app).get('/api/elements');
|
||||
expect(aElems.body.elements.length).toBe(1);
|
||||
expect(aElems.body.elements[0].id).toBe('a-only');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/projects/:id', () => {
|
||||
it('deletes a non-active project', async () => {
|
||||
const created = await request(app).post('/api/projects').send({ name: 'To Delete' });
|
||||
const id = created.body.project.id;
|
||||
|
||||
const res = await request(app).delete(`/api/projects/${id}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.projectId).toBe(id);
|
||||
|
||||
const list = await request(app).get('/api/projects');
|
||||
const ids = list.body.projects.map((p: any) => p.id);
|
||||
expect(ids).not.toContain(id);
|
||||
});
|
||||
|
||||
it('cascades and deletes elements belonging to the project', async () => {
|
||||
const created = await request(app).post('/api/projects').send({ name: 'With Elements' });
|
||||
const id = created.body.project.id;
|
||||
|
||||
// Switch to new project and add an element
|
||||
await request(app).put('/api/project/active').send({ projectId: id });
|
||||
await request(app).post('/api/elements').send({ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 });
|
||||
expect(getElementCountForProject(id)).toBe(1);
|
||||
|
||||
// Switch back to default before deleting
|
||||
const defaultId = getActiveProjectId() === id
|
||||
? (await request(app).get('/api/projects')).body.projects.find((p: any) => p.id !== id)?.id
|
||||
: getActiveProjectId();
|
||||
await request(app).put('/api/project/active').send({ projectId: defaultId });
|
||||
|
||||
await request(app).delete(`/api/projects/${id}`);
|
||||
expect(getElementCountForProject(id)).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses to delete the active project', async () => {
|
||||
// Create a second project so the "last project" guard doesn't fire first
|
||||
await request(app).post('/api/projects').send({ name: 'Second' });
|
||||
const activeId = getActiveProjectId();
|
||||
const res = await request(app).delete(`/api/projects/${activeId}`);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/active/);
|
||||
});
|
||||
|
||||
it('refuses to delete the last project', async () => {
|
||||
// Only default project exists — try to delete it (it is also active, so both guards fire)
|
||||
const activeId = getActiveProjectId();
|
||||
const res = await request(app).delete(`/api/projects/${activeId}`);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('returns 400 for a non-existent project', async () => {
|
||||
const res = await request(app).delete('/api/projects/ghost-id');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Unit tests for src/db.ts
|
||||
*
|
||||
* Covers: migrations, tenant isolation, FTS search, snapshots,
|
||||
* element_versions tracking, generateId uniqueness, global state race.
|
||||
* All tests use a real SQLite database in a tmpdir.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
initDb,
|
||||
closeDb,
|
||||
ensureTenant,
|
||||
setActiveTenant,
|
||||
getActiveTenantId,
|
||||
getActiveProjectId,
|
||||
setElement,
|
||||
getElement,
|
||||
getAllElements,
|
||||
deleteElement,
|
||||
searchElements,
|
||||
saveSnapshot,
|
||||
getSnapshot,
|
||||
getElementHistory,
|
||||
createProject,
|
||||
getDefaultProjectForTenant,
|
||||
getCurrentSyncVersion,
|
||||
incrementSyncVersion,
|
||||
} from '../../src/db.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
function tmpDb(label: string): string {
|
||||
return path.join(
|
||||
os.tmpdir(),
|
||||
`excalidraw-db-unit-${label}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`
|
||||
);
|
||||
}
|
||||
|
||||
function cleanupDb(dbPath: string): void {
|
||||
closeDb();
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function makeEl(id: string, overrides: Record<string, any> = {}) {
|
||||
return { id, type: 'rectangle', x: 0, y: 0, width: 100, height: 50, ...overrides };
|
||||
}
|
||||
|
||||
// ── WAL mode ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('SQLite configuration', () => {
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dbPath = tmpDb('config');
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
});
|
||||
|
||||
afterEach(() => cleanupDb(dbPath));
|
||||
|
||||
it('enables WAL journal mode', () => {
|
||||
// After initDb the WAL file should be created alongside the DB
|
||||
// (or journal_mode pragma returns 'wal').
|
||||
// We verify indirectly: the -wal sidecar file exists after a write.
|
||||
setElement('el-wal', makeEl('el-wal'));
|
||||
const walPath = dbPath + '-wal';
|
||||
// WAL file may or may not exist depending on checkpoint state, but
|
||||
// the DB must at least have been created without error.
|
||||
expect(fs.existsSync(dbPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Migrations ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Migrations', () => {
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dbPath = tmpDb('migrations');
|
||||
});
|
||||
|
||||
afterEach(() => cleanupDb(dbPath));
|
||||
|
||||
it('runs successfully on a fresh database', () => {
|
||||
expect(() => {
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('is idempotent — calling initDb twice with the same path does not error', () => {
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
// initDb guards with `if (db) return`, so calling again is a no-op
|
||||
expect(() => initDb(dbPath)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Element CRUD & tenant isolation ──────────────────────────────────────────
|
||||
|
||||
describe('Tenant isolation', () => {
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dbPath = tmpDb('isolation');
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
});
|
||||
|
||||
afterEach(() => cleanupDb(dbPath));
|
||||
|
||||
it('elements created in tenant A are not visible in tenant B', () => {
|
||||
// Create tenant A + project, write an element
|
||||
ensureTenant('tenant-a', 'Tenant A', '/ws/a');
|
||||
setActiveTenant('tenant-a');
|
||||
const projA = getDefaultProjectForTenant('tenant-a');
|
||||
setElement('el-a', makeEl('el-a'), projA);
|
||||
|
||||
// Create tenant B + project, write a different element
|
||||
ensureTenant('tenant-b', 'Tenant B', '/ws/b');
|
||||
setActiveTenant('tenant-b');
|
||||
const projB = getDefaultProjectForTenant('tenant-b');
|
||||
setElement('el-b', makeEl('el-b'), projB);
|
||||
|
||||
// Tenant A's project sees only el-a
|
||||
const elemsA = getAllElements(projA);
|
||||
expect(elemsA.map(e => e.id)).toContain('el-a');
|
||||
expect(elemsA.map(e => e.id)).not.toContain('el-b');
|
||||
|
||||
// Tenant B's project sees only el-b
|
||||
const elemsB = getAllElements(projB);
|
||||
expect(elemsB.map(e => e.id)).toContain('el-b');
|
||||
expect(elemsB.map(e => e.id)).not.toContain('el-a');
|
||||
});
|
||||
|
||||
it('getElement with explicit projectId enforces project scope', () => {
|
||||
ensureTenant('tenant-c', 'Tenant C', '/ws/c');
|
||||
const projC = getDefaultProjectForTenant('tenant-c');
|
||||
setElement('el-c', makeEl('el-c'), projC);
|
||||
|
||||
// The default project should NOT see el-c
|
||||
const found = getElement('el-c', 'default');
|
||||
expect(found).toBeUndefined();
|
||||
|
||||
// The correct project SHOULD see el-c
|
||||
const foundCorrect = getElement('el-c', projC);
|
||||
expect(foundCorrect).toBeDefined();
|
||||
expect(foundCorrect!.id).toBe('el-c');
|
||||
});
|
||||
|
||||
// DESIGN NOTE: setActiveTenant() mutates module-level `activeTenantId` and
|
||||
// `activeProjectId`. Any code path that calls db functions WITHOUT an explicit
|
||||
// projectId override uses the current global value. If two logical "sessions"
|
||||
// call setActiveTenant() in an interleaved order, the later call wins.
|
||||
// The test below demonstrates this using explicit projectId overrides (the safe
|
||||
// API), contrasted with the module-global fallback.
|
||||
it('DESIGN GAP: global activeTenantId is shared across all callers without explicit projectId', () => {
|
||||
ensureTenant('tenant-x', 'X', '/ws/x');
|
||||
ensureTenant('tenant-y', 'Y', '/ws/y');
|
||||
const projX = getDefaultProjectForTenant('tenant-x');
|
||||
const projY = getDefaultProjectForTenant('tenant-y');
|
||||
|
||||
// Session 1 sets active tenant to X and writes an element via global state
|
||||
setActiveTenant('tenant-x');
|
||||
expect(getActiveTenantId()).toBe('tenant-x');
|
||||
// Simulate session 2 switching tenant before session 1 does its DB work
|
||||
setActiveTenant('tenant-y');
|
||||
// Now session 1's db call (no explicit projectId) will use Y's project
|
||||
setElement('el-contaminated', makeEl('el-contaminated')); // uses activeProjectId = projY
|
||||
|
||||
// The element landed in Y's project, not X's
|
||||
expect(getElement('el-contaminated', projY)).toBeDefined();
|
||||
expect(getElement('el-contaminated', projX)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── FTS Search ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('FTS search', () => {
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dbPath = tmpDb('fts');
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
});
|
||||
|
||||
afterEach(() => cleanupDb(dbPath));
|
||||
|
||||
it('finds elements by label text', () => {
|
||||
setElement('el-fts1', makeEl('el-fts1', { label: { text: 'Excalidraw Canvas' } }));
|
||||
setElement('el-fts2', makeEl('el-fts2', { label: { text: 'Something Else' } }));
|
||||
|
||||
const results = searchElements('Excalidraw', 'default');
|
||||
expect(results.map(e => e.id)).toContain('el-fts1');
|
||||
expect(results.map(e => e.id)).not.toContain('el-fts2');
|
||||
});
|
||||
|
||||
it('finds elements by type', () => {
|
||||
setElement('el-rect', { id: 'el-rect', type: 'rectangle', x: 0, y: 0, width: 50, height: 50 });
|
||||
setElement('el-dia', { id: 'el-dia', type: 'diamond', x: 0, y: 0, width: 50, height: 50 });
|
||||
|
||||
const results = searchElements('diamond', 'default');
|
||||
expect(results.map(e => e.id)).toContain('el-dia');
|
||||
expect(results.map(e => e.id)).not.toContain('el-rect');
|
||||
});
|
||||
|
||||
it('does not return deleted elements', () => {
|
||||
setElement('el-del', makeEl('el-del', { label: { text: 'FindMe' } }));
|
||||
deleteElement('el-del', 'default');
|
||||
|
||||
const results = searchElements('FindMe', 'default');
|
||||
expect(results.map(e => e.id)).not.toContain('el-del');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Soft delete ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Soft delete', () => {
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dbPath = tmpDb('softdelete');
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
});
|
||||
|
||||
afterEach(() => cleanupDb(dbPath));
|
||||
|
||||
it('deleted element is not returned by getAllElements', () => {
|
||||
setElement('el-to-delete', makeEl('el-to-delete'));
|
||||
deleteElement('el-to-delete', 'default');
|
||||
|
||||
const all = getAllElements('default');
|
||||
expect(all.map(e => e.id)).not.toContain('el-to-delete');
|
||||
});
|
||||
|
||||
it('deleted element is not returned by getElement', () => {
|
||||
setElement('el-gone', makeEl('el-gone'));
|
||||
deleteElement('el-gone', 'default');
|
||||
|
||||
expect(getElement('el-gone', 'default')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('re-inserting a deleted element revives it', () => {
|
||||
setElement('el-revive', makeEl('el-revive'));
|
||||
deleteElement('el-revive', 'default');
|
||||
setElement('el-revive', makeEl('el-revive', { x: 99 }));
|
||||
|
||||
const el = getElement('el-revive', 'default');
|
||||
expect(el).toBeDefined();
|
||||
expect(el!.x).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
// ── element_versions ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('element_versions history', () => {
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dbPath = tmpDb('versions');
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
});
|
||||
|
||||
afterEach(() => cleanupDb(dbPath));
|
||||
|
||||
it('records a create operation', () => {
|
||||
setElement('el-hist', makeEl('el-hist'));
|
||||
const history = getElementHistory('el-hist', 50, 'default');
|
||||
expect(history.length).toBeGreaterThanOrEqual(1);
|
||||
expect(history.some(h => h.operation === 'create')).toBe(true);
|
||||
});
|
||||
|
||||
it('records an update operation after second setElement', () => {
|
||||
setElement('el-hist2', makeEl('el-hist2'));
|
||||
setElement('el-hist2', makeEl('el-hist2', { x: 42 }));
|
||||
const history = getElementHistory('el-hist2', 50, 'default');
|
||||
expect(history.some(h => h.operation === 'update')).toBe(true);
|
||||
});
|
||||
|
||||
it('records a delete operation', () => {
|
||||
setElement('el-hist3', makeEl('el-hist3'));
|
||||
deleteElement('el-hist3', 'default');
|
||||
const history = getElementHistory('el-hist3', 50, 'default');
|
||||
expect(history.some(h => h.operation === 'delete')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Snapshot round-trip ───────────────────────────────────────────────────────
|
||||
|
||||
describe('Snapshot save / restore', () => {
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dbPath = tmpDb('snapshot');
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
});
|
||||
|
||||
afterEach(() => cleanupDb(dbPath));
|
||||
|
||||
it('saves and retrieves a named snapshot', () => {
|
||||
const elements = [makeEl('snap-el-1'), makeEl('snap-el-2')];
|
||||
saveSnapshot('my-snap', elements, 'default');
|
||||
|
||||
const snap = getSnapshot('my-snap', 'default');
|
||||
expect(snap).toBeDefined();
|
||||
expect(snap!.name).toBe('my-snap');
|
||||
expect(snap!.elements).toHaveLength(2);
|
||||
expect(snap!.elements.map((e: any) => e.id)).toContain('snap-el-1');
|
||||
});
|
||||
|
||||
it('snapshot content is independent of subsequent mutations', () => {
|
||||
setElement('snap-live', makeEl('snap-live', { x: 10 }));
|
||||
saveSnapshot('before-move', [makeEl('snap-live', { x: 10 })], 'default');
|
||||
|
||||
// Mutate the live element
|
||||
setElement('snap-live', makeEl('snap-live', { x: 999 }));
|
||||
|
||||
// Snapshot still has the original coordinates
|
||||
const snap = getSnapshot('before-move', 'default');
|
||||
expect(snap!.elements[0].x).toBe(10);
|
||||
});
|
||||
|
||||
it('returns undefined for non-existent snapshot name', () => {
|
||||
expect(getSnapshot('does-not-exist', 'default')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── generateId uniqueness ─────────────────────────────────────────────────────
|
||||
|
||||
describe('createProject — generateId uniqueness', () => {
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dbPath = tmpDb('genid');
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
});
|
||||
|
||||
afterEach(() => cleanupDb(dbPath));
|
||||
|
||||
it('generates unique project IDs across 200 rapid sequential calls', () => {
|
||||
const ids = new Set<string>();
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const project = createProject(`proj-${i}`);
|
||||
ids.add(project.id);
|
||||
}
|
||||
// All IDs must be unique
|
||||
expect(ids.size).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Sync version ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('sync version monotonicity', () => {
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dbPath = tmpDb('syncver');
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
});
|
||||
|
||||
afterEach(() => cleanupDb(dbPath));
|
||||
|
||||
it('sync version increases monotonically across setElement calls', () => {
|
||||
const v0 = getCurrentSyncVersion('default');
|
||||
setElement('sv-el1', makeEl('sv-el1'));
|
||||
const v1 = getCurrentSyncVersion('default');
|
||||
setElement('sv-el2', makeEl('sv-el2'));
|
||||
const v2 = getCurrentSyncVersion('default');
|
||||
|
||||
expect(v1).toBeGreaterThan(v0);
|
||||
expect(v2).toBeGreaterThan(v1);
|
||||
});
|
||||
|
||||
it('sync version is isolated per project (explicit projectId)', () => {
|
||||
ensureTenant('sv-tenant', 'SV Tenant', '/ws/sv');
|
||||
const projSv = getDefaultProjectForTenant('sv-tenant');
|
||||
|
||||
const defaultV0 = getCurrentSyncVersion('default');
|
||||
const svV0 = getCurrentSyncVersion(projSv);
|
||||
|
||||
setElement('sv-isolated', makeEl('sv-isolated'), projSv);
|
||||
|
||||
// Only the sv project's version should increment
|
||||
expect(getCurrentSyncVersion(projSv)).toBeGreaterThan(svV0);
|
||||
expect(getCurrentSyncVersion('default')).toBe(defaultV0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { server, tools } from '../../src/index.js';
|
||||
|
||||
let client: Client;
|
||||
|
||||
beforeAll(async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
client = new Client({ name: 'mcp-contract-test-client', version: '1.0.0' });
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe('MCP contract', () => {
|
||||
it('tools/list returns all declared tools', async () => {
|
||||
const listed = await client.listTools();
|
||||
expect(Array.isArray(listed.tools)).toBe(true);
|
||||
expect(listed.tools.length).toBe(32);
|
||||
expect(listed.tools.length).toBe(tools.length);
|
||||
});
|
||||
|
||||
it('tools/call unknown tool returns MethodNotFound (-32601)', async () => {
|
||||
await expect(
|
||||
client.callTool({
|
||||
name: '__unknown_tool__',
|
||||
arguments: {},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: ErrorCode.MethodNotFound,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,222 @@
|
||||
/**
|
||||
* Tests for security gaps on MCP-adjacent paths.
|
||||
*
|
||||
* CONTEXT: Express middleware (sanitizeBody, apiKeyAuth, rate limiting) only
|
||||
* runs on HTTP requests. MCP tool calls arrive over stdio and call db functions
|
||||
* directly — bypassing all Express middleware.
|
||||
*
|
||||
* These tests:
|
||||
* 1. Confirm sanitizeBody WORKS on REST paths (baseline proof it's applied).
|
||||
* 2. Document the adversarial JSON parsing scenarios that the MCP import_scene
|
||||
* handler faces without any Express-layer protection.
|
||||
* 3. Test path traversal blocking on export endpoints (shared logic with MCP).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import {
|
||||
initDb,
|
||||
closeDb,
|
||||
setActiveTenant,
|
||||
} from '../../src/db.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
let dbPath: string;
|
||||
let app: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = path.join(
|
||||
os.tmpdir(),
|
||||
`excalidraw-mcp-sanit-${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 { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
// ── Prototype pollution guard — REST layer ────────────────────────────────────
|
||||
|
||||
describe('sanitizeBody middleware — REST path coverage', () => {
|
||||
it('rejects POST body containing __proto__ key → 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('{"__proto__": {"isAdmin": true}, "type": "rectangle", "x": 0, "y": 0}');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/disallowed keys/i);
|
||||
});
|
||||
|
||||
it('rejects POST body containing constructor key → 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('{"constructor": {"name": "evil"}, "type": "rectangle", "x": 0, "y": 0}');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/disallowed keys/i);
|
||||
});
|
||||
|
||||
it('rejects POST body containing nested __proto__ → 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('{"element": {"__proto__": {"evil": true}}, "type": "rectangle", "x": 0, "y": 0}');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/disallowed keys/i);
|
||||
});
|
||||
|
||||
it('accepts clean POST body → not 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
|
||||
expect(res.status).not.toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── MCP import_scene adversarial JSON — documented gap ───────────────────────
|
||||
//
|
||||
// MCP tool calls reach `import_scene` via stdio → index.ts.
|
||||
// The handler does: `sceneData = JSON.parse(params.data)` with no sanitization.
|
||||
//
|
||||
// SAFETY NOTE: In modern Node.js (V8 ≥ 8.x), JSON.parse does NOT pollute
|
||||
// Object.prototype when encountering `{"__proto__": ...}` — it creates a plain
|
||||
// key named "__proto__" on the result object without calling [[Set]] on the
|
||||
// prototype chain. However, downstream code that uses Object.assign() or
|
||||
// spread {...sceneData} can re-trigger pollution if the key is spread into
|
||||
// an object whose prototype is Object.prototype.
|
||||
//
|
||||
// The tests below are NOT executable without a running MCP stdio process.
|
||||
// They are represented as unit assertions on the JSON.parse behaviour itself
|
||||
// to document the exact risk surface.
|
||||
|
||||
describe('MCP import_scene — JSON.parse prototype behaviour (gap documentation)', () => {
|
||||
it('JSON.parse with __proto__ key does NOT pollute Object.prototype in modern Node', () => {
|
||||
// This is the safety net we rely on. If this test ever fails, the MCP path
|
||||
// is directly exploitable for prototype pollution.
|
||||
const parsed = JSON.parse('{"__proto__": {"isAdmin": true}}');
|
||||
|
||||
// The key exists as a plain own property, not as a prototype mutation
|
||||
expect(Object.prototype.hasOwnProperty.call(parsed, '__proto__')).toBe(true);
|
||||
expect((Object.prototype as any).isAdmin).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Object.assign with a JSON-parsed __proto__ key mutates the spread target prototype chain', () => {
|
||||
// CONFIRMED REAL BEHAVIOUR: Object.assign({}, parsed) where parsed has a
|
||||
// "__proto__" own key (from JSON.parse) triggers the __proto__ setter on
|
||||
// Object.prototype, which changes the *target* object's prototype to the
|
||||
// value. This means `cloned.injected` resolves via prototype lookup.
|
||||
//
|
||||
// This does NOT pollute Object.prototype itself — only the cloned object's
|
||||
// prototype chain. But any code in index.ts that does `{ ...sceneData }` or
|
||||
// `Object.assign({}, sceneData)` after JSON.parse on MCP input is affected.
|
||||
const parsed = JSON.parse('{"__proto__": {"injected": true}}') as any;
|
||||
|
||||
// Verify parsed has __proto__ as an own property (not prototype pollution)
|
||||
expect(Object.prototype.hasOwnProperty.call(parsed, '__proto__')).toBe(true);
|
||||
expect((Object.prototype as any).injected).toBeUndefined(); // Object.prototype is clean
|
||||
|
||||
// Spreading/assigning DOES change the target's prototype:
|
||||
const cloned = Object.assign({}, parsed);
|
||||
expect((cloned as any).injected).toBe(true); // inherited from mutated prototype
|
||||
|
||||
// Object.prototype is still clean after the spread
|
||||
expect((Object.prototype as any).injected).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deeply nested JSON (depth 1000) does not cause stack overflow during JSON.parse', () => {
|
||||
// MCP import_scene does JSON.parse on user-supplied data with no depth limit.
|
||||
// Node.js JSON.parse handles deep nesting iteratively — verify it does not
|
||||
// blow the call stack at practical depths.
|
||||
const depth = 1000;
|
||||
const nested = '['.repeat(depth) + '1' + ']'.repeat(depth);
|
||||
|
||||
expect(() => JSON.parse(nested)).not.toThrow();
|
||||
});
|
||||
|
||||
it('HYPOTHESIS: extremely deep nesting (depth 100_000) may throw in some runtimes', () => {
|
||||
// Document the practical limit. If this throws a RangeError (stack overflow),
|
||||
// the MCP import_scene handler is vulnerable to DoS via deeply nested payloads.
|
||||
const depth = 100_000;
|
||||
const nested = '['.repeat(depth) + '1' + ']'.repeat(depth);
|
||||
|
||||
// We only assert "does not silently succeed with wrong data" — either it
|
||||
// parses correctly or throws a catchable error (not a process crash).
|
||||
let threw = false;
|
||||
try {
|
||||
JSON.parse(nested);
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
// Either outcome is acceptable — the key assertion is that the process survives
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Path traversal — export endpoint (shared sanitizeFilePath logic) ──────────
|
||||
|
||||
describe('Path traversal on export endpoints', () => {
|
||||
it('POST /api/export/image with path traversal in filePath → error (not 200)', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/export/image')
|
||||
.send({
|
||||
filePath: '../../../../etc/passwd',
|
||||
format: 'png'
|
||||
});
|
||||
|
||||
// Should not be 200 — either 400 (validation) or 500 (server error before write)
|
||||
expect(res.status).not.toBe(200);
|
||||
});
|
||||
|
||||
it('POST /api/export/image with absolute path outside cwd → error (not 200)', async () => {
|
||||
const outsidePath = '/tmp/traversal-test-excalidraw.png';
|
||||
const res = await request(app)
|
||||
.post('/api/export/image')
|
||||
.send({
|
||||
filePath: outsidePath,
|
||||
format: 'png'
|
||||
});
|
||||
|
||||
expect(res.status).not.toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Null-byte injection ───────────────────────────────────────────────────────
|
||||
|
||||
describe('Null byte and encoding edge cases', () => {
|
||||
it('POST /api/elements with null byte in type field does not crash server', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'rectangle\x00', x: 0, y: 0, width: 100, height: 50 });
|
||||
|
||||
// Must return a 4xx — not 200 and not an unhandled 500
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.status).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it('POST /api/elements/batch with oversized element text does not hang', async () => {
|
||||
// Verify server responds within reasonable time even with a large text field
|
||||
// (this is a regression guard — a 413 or 400 is both acceptable)
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(JSON.stringify({
|
||||
type: 'text',
|
||||
x: 0, y: 0, width: 100, height: 50,
|
||||
text: 'A'.repeat(200 * 1024) // 200 KB — over the 100 KB limit
|
||||
}));
|
||||
|
||||
expect(res.status).toBe(413);
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,7 @@ describe('Middleware order', () => {
|
||||
|
||||
it('401 with bad API key is still rate-limited', async () => {
|
||||
const ip = '10.20.0.3';
|
||||
for (let i = 0; i < 100; i++) {
|
||||
for (let i = 0; i < 500; i++) {
|
||||
await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-API-Key', 'wrong-key')
|
||||
|
||||
@@ -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,182 @@
|
||||
/**
|
||||
* End-to-end tests for project switching.
|
||||
*
|
||||
* Exercises the full HTTP stack: create projects → add elements → switch →
|
||||
* verify elements are isolated per project and survive round-trips.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setActiveTenant } from '../../src/db.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
let dbPath: string;
|
||||
let app: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-e2e-project-switch-${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 {}
|
||||
}
|
||||
});
|
||||
|
||||
function rect(id: string, x = 0, y = 0) {
|
||||
return { id, type: 'rectangle', x, y, width: 100, height: 60, version: 1 };
|
||||
}
|
||||
|
||||
// ─── E2E: draw in project, switch away, switch back ─────────
|
||||
|
||||
describe('E2E: project switch round-trip', () => {
|
||||
it('draw 2 elements in "dude", switch to default, switch back — elements preserved', async () => {
|
||||
// 1. Create project "dude"
|
||||
const createRes = await request(app).post('/api/projects').send({ name: 'dude' });
|
||||
expect(createRes.status).toBe(201);
|
||||
const dudeId = createRes.body.project.id;
|
||||
|
||||
// Remember default project id
|
||||
const listBefore = await request(app).get('/api/projects');
|
||||
const defaultProject = listBefore.body.projects.find((p: any) => p.name === 'Default');
|
||||
expect(defaultProject).toBeDefined();
|
||||
const defaultId = defaultProject.id;
|
||||
|
||||
// 2. Switch to "dude"
|
||||
const switchRes = await request(app).put('/api/project/active').send({ projectId: dudeId });
|
||||
expect(switchRes.status).toBe(200);
|
||||
|
||||
// 3. Draw 2 rectangles in "dude"
|
||||
const r1 = await request(app).post('/api/elements').send(rect('dude-box-1', 10, 10));
|
||||
const r2 = await request(app).post('/api/elements').send(rect('dude-box-2', 200, 200));
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r2.status).toBe(200);
|
||||
|
||||
// Verify 2 elements present
|
||||
const dudeCheck1 = await request(app).get('/api/elements');
|
||||
expect(dudeCheck1.body.elements.length).toBe(2);
|
||||
|
||||
// 4. Switch to "default"
|
||||
await request(app).put('/api/project/active').send({ projectId: defaultId });
|
||||
|
||||
// Default should be empty
|
||||
const defaultCheck = await request(app).get('/api/elements');
|
||||
expect(defaultCheck.body.elements.length).toBe(0);
|
||||
|
||||
// 5. Switch back to "dude"
|
||||
await request(app).put('/api/project/active').send({ projectId: dudeId });
|
||||
|
||||
// 6. Verify both elements are still there
|
||||
const dudeCheck2 = await request(app).get('/api/elements');
|
||||
expect(dudeCheck2.body.elements.length).toBe(2);
|
||||
const ids = dudeCheck2.body.elements.map((e: any) => e.id);
|
||||
expect(ids).toContain('dude-box-1');
|
||||
expect(ids).toContain('dude-box-2');
|
||||
});
|
||||
|
||||
it('multiple switches do not leak elements between projects', async () => {
|
||||
// Create 3 projects
|
||||
const pA = await request(app).post('/api/projects').send({ name: 'Alpha' });
|
||||
const pB = await request(app).post('/api/projects').send({ name: 'Bravo' });
|
||||
const pC = await request(app).post('/api/projects').send({ name: 'Charlie' });
|
||||
const aId = pA.body.project.id;
|
||||
const bId = pB.body.project.id;
|
||||
const cId = pC.body.project.id;
|
||||
|
||||
// Add 1 element to each
|
||||
await request(app).put('/api/project/active').send({ projectId: aId });
|
||||
await request(app).post('/api/elements').send(rect('alpha-el'));
|
||||
|
||||
await request(app).put('/api/project/active').send({ projectId: bId });
|
||||
await request(app).post('/api/elements').send(rect('bravo-el'));
|
||||
|
||||
await request(app).put('/api/project/active').send({ projectId: cId });
|
||||
await request(app).post('/api/elements').send(rect('charlie-el'));
|
||||
|
||||
// Rapid switching: C → A → B → A → C
|
||||
await request(app).put('/api/project/active').send({ projectId: aId });
|
||||
await request(app).put('/api/project/active').send({ projectId: bId });
|
||||
await request(app).put('/api/project/active').send({ projectId: aId });
|
||||
await request(app).put('/api/project/active').send({ projectId: cId });
|
||||
|
||||
// Verify each project has exactly its own element
|
||||
await request(app).put('/api/project/active').send({ projectId: aId });
|
||||
const aElems = await request(app).get('/api/elements');
|
||||
expect(aElems.body.elements.length).toBe(1);
|
||||
expect(aElems.body.elements[0].id).toBe('alpha-el');
|
||||
|
||||
await request(app).put('/api/project/active').send({ projectId: bId });
|
||||
const bElems = await request(app).get('/api/elements');
|
||||
expect(bElems.body.elements.length).toBe(1);
|
||||
expect(bElems.body.elements[0].id).toBe('bravo-el');
|
||||
|
||||
await request(app).put('/api/project/active').send({ projectId: cId });
|
||||
const cElems = await request(app).get('/api/elements');
|
||||
expect(cElems.body.elements.length).toBe(1);
|
||||
expect(cElems.body.elements[0].id).toBe('charlie-el');
|
||||
});
|
||||
|
||||
it('updating an element in one project does not affect another', async () => {
|
||||
const pX = await request(app).post('/api/projects').send({ name: 'ProjX' });
|
||||
const xId = pX.body.project.id;
|
||||
const listRes = await request(app).get('/api/projects');
|
||||
const defaultId = listRes.body.projects.find((p: any) => p.name === 'Default').id;
|
||||
|
||||
// Add element to default
|
||||
await request(app).put('/api/project/active').send({ projectId: defaultId });
|
||||
await request(app).post('/api/elements').send(rect('def-rect', 0, 0));
|
||||
|
||||
// Add element to ProjX
|
||||
await request(app).put('/api/project/active').send({ projectId: xId });
|
||||
await request(app).post('/api/elements').send(rect('x-rect', 0, 0));
|
||||
|
||||
// Update element in ProjX
|
||||
const updateRes = await request(app).put('/api/elements/x-rect').send({ x: 999, y: 999 });
|
||||
expect(updateRes.status).toBe(200);
|
||||
expect(updateRes.body.success).toBe(true);
|
||||
|
||||
// Verify ProjX has updated coords
|
||||
const xElems = await request(app).get('/api/elements');
|
||||
expect(xElems.body.elements).toHaveLength(1);
|
||||
expect(xElems.body.elements[0].x).toBe(999);
|
||||
|
||||
// Verify Default still has original coords
|
||||
await request(app).put('/api/project/active').send({ projectId: defaultId });
|
||||
const defElems = await request(app).get('/api/elements');
|
||||
expect(defElems.body.elements[0].x).toBe(0);
|
||||
});
|
||||
|
||||
it('deleting an element in one project does not affect another', async () => {
|
||||
const pY = await request(app).post('/api/projects').send({ name: 'ProjY' });
|
||||
const yId = pY.body.project.id;
|
||||
const listRes = await request(app).get('/api/projects');
|
||||
const defaultId = listRes.body.projects.find((p: any) => p.name === 'Default').id;
|
||||
|
||||
// Add element to default
|
||||
await request(app).put('/api/project/active').send({ projectId: defaultId });
|
||||
await request(app).post('/api/elements').send(rect('def-del', 50, 50));
|
||||
|
||||
// Add element to ProjY
|
||||
await request(app).put('/api/project/active').send({ projectId: yId });
|
||||
await request(app).post('/api/elements').send(rect('y-del', 50, 50));
|
||||
|
||||
// Delete from ProjY
|
||||
await request(app).delete('/api/elements/y-del');
|
||||
|
||||
// ProjY: 0 elements
|
||||
const yElems = await request(app).get('/api/elements');
|
||||
expect(yElems.body.elements.length).toBe(0);
|
||||
|
||||
// Default: still has its element
|
||||
await request(app).put('/api/project/active').send({ projectId: defaultId });
|
||||
const defElems = await request(app).get('/api/elements');
|
||||
expect(defElems.body.elements.length).toBe(1);
|
||||
expect(defElems.body.elements[0].id).toBe('def-del');
|
||||
});
|
||||
});
|
||||
@@ -105,7 +105,7 @@ describe('Rate limiting — destructive endpoints', () => {
|
||||
describe('Rate limiting — sync endpoints', () => {
|
||||
it('returns 429 after exceeding /api/elements/sync write-burst limit', async () => {
|
||||
const ip = '10.10.0.1';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.set('X-Forwarded-For', ip)
|
||||
@@ -122,7 +122,7 @@ describe('Rate limiting — sync endpoints', () => {
|
||||
|
||||
it('returns 429 after exceeding /api/elements/sync/v2 write-burst limit', async () => {
|
||||
const ip = '10.10.0.2';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.set('X-Forwarded-For', ip)
|
||||
@@ -139,7 +139,7 @@ describe('Rate limiting — sync endpoints', () => {
|
||||
|
||||
it('sync 429 responses include rate-limit headers', async () => {
|
||||
const ip = '10.10.0.3';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.set('X-Forwarded-For', ip)
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Unit tests for src/security.ts
|
||||
*
|
||||
* Covers: validateApiKey, sanitizeSearchQuery, sanitizeBody behaviour.
|
||||
* No server or DB required — pure function tests.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
validateApiKey,
|
||||
sanitizeSearchQuery,
|
||||
InvalidSearchQueryError,
|
||||
isAuthEnabled,
|
||||
} from '../../src/security.js';
|
||||
|
||||
// ── validateApiKey ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('validateApiKey — auth disabled', () => {
|
||||
beforeEach(() => { delete process.env.EXCALIDRAW_API_KEY; });
|
||||
|
||||
it('returns true for any value when no API key env var is set', () => {
|
||||
expect(validateApiKey('anything')).toBe(true);
|
||||
expect(validateApiKey(undefined)).toBe(true);
|
||||
expect(validateApiKey('')).toBe(true);
|
||||
});
|
||||
|
||||
it('isAuthEnabled returns false when env var is unset', () => {
|
||||
expect(isAuthEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateApiKey — auth enabled', () => {
|
||||
const CORRECT_KEY = 'super-secret-key-32chars!!!!!!!!';
|
||||
|
||||
beforeEach(() => { process.env.EXCALIDRAW_API_KEY = CORRECT_KEY; });
|
||||
afterEach(() => { delete process.env.EXCALIDRAW_API_KEY; });
|
||||
|
||||
it('returns true for exact match', () => {
|
||||
expect(validateApiKey(CORRECT_KEY)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for undefined', () => {
|
||||
expect(validateApiKey(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty string', () => {
|
||||
expect(validateApiKey('')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for array (non-string type guard)', () => {
|
||||
expect(validateApiKey(['correct'] as any)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a wrong key of the SAME length — timingSafeEqual path', () => {
|
||||
// Same length forces the timingSafeEqual code path (not the early-exit).
|
||||
// timingSafeEqual must not throw when buffers are the same length.
|
||||
const sameLen = 'X'.repeat(CORRECT_KEY.length);
|
||||
expect(() => validateApiKey(sameLen)).not.toThrow();
|
||||
expect(validateApiKey(sameLen)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for correct key with one extra char (different length)', () => {
|
||||
// DESIGN NOTE: the current implementation returns false early when lengths
|
||||
// differ, without calling timingSafeEqual. This means an attacker probing
|
||||
// keys of length 1..N can infer the correct key length via response-time
|
||||
// differences. Documented here as a known design decision.
|
||||
expect(validateApiKey(CORRECT_KEY + 'x')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for correct key with one char missing', () => {
|
||||
expect(validateApiKey(CORRECT_KEY.slice(0, -1))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for key that differs only in one character', () => {
|
||||
// Replace last char with something definitely different from the original
|
||||
const lastChar = CORRECT_KEY[CORRECT_KEY.length - 1]!;
|
||||
const differentChar = lastChar === 'Z' ? 'A' : 'Z';
|
||||
const almostRight = CORRECT_KEY.slice(0, -1) + differentChar;
|
||||
expect(validateApiKey(almostRight)).toBe(false);
|
||||
});
|
||||
|
||||
it('isAuthEnabled returns true when env var is set', () => {
|
||||
expect(isAuthEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── sanitizeSearchQuery ───────────────────────────────────────────────────────
|
||||
|
||||
describe('sanitizeSearchQuery — valid inputs', () => {
|
||||
it('trims whitespace and returns clean query', () => {
|
||||
expect(sanitizeSearchQuery(' hello world ')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('returns empty string for whitespace-only input', () => {
|
||||
expect(sanitizeSearchQuery(' ')).toBe('');
|
||||
});
|
||||
|
||||
it('allows plain alphanumeric query', () => {
|
||||
expect(sanitizeSearchQuery('rectangle')).toBe('rectangle');
|
||||
});
|
||||
|
||||
it('allows hyphenated terms', () => {
|
||||
expect(sanitizeSearchQuery('my-diagram')).toBe('my-diagram');
|
||||
});
|
||||
|
||||
it('allows numbers', () => {
|
||||
expect(sanitizeSearchQuery('123')).toBe('123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeSearchQuery — FTS operator injection', () => {
|
||||
it('throws on double-quote character', () => {
|
||||
expect(() => sanitizeSearchQuery('"quoted phrase"')).toThrow(InvalidSearchQueryError);
|
||||
});
|
||||
|
||||
it('throws on AND operator (uppercase)', () => {
|
||||
expect(() => sanitizeSearchQuery('foo AND bar')).toThrow(InvalidSearchQueryError);
|
||||
});
|
||||
|
||||
it('throws on AND operator (lowercase)', () => {
|
||||
expect(() => sanitizeSearchQuery('foo and bar')).toThrow(InvalidSearchQueryError);
|
||||
});
|
||||
|
||||
it('throws on OR operator', () => {
|
||||
expect(() => sanitizeSearchQuery('foo OR bar')).toThrow(InvalidSearchQueryError);
|
||||
});
|
||||
|
||||
it('throws on NOT operator', () => {
|
||||
expect(() => sanitizeSearchQuery('NOT secret')).toThrow(InvalidSearchQueryError);
|
||||
});
|
||||
|
||||
it('throws on NEAR operator', () => {
|
||||
expect(() => sanitizeSearchQuery('foo NEAR bar')).toThrow(InvalidSearchQueryError);
|
||||
});
|
||||
|
||||
it('throws on NEAR/N distance syntax', () => {
|
||||
expect(() => sanitizeSearchQuery('foo NEAR/5 bar')).toThrow(InvalidSearchQueryError);
|
||||
});
|
||||
|
||||
it('throws on glob wildcard *', () => {
|
||||
expect(() => sanitizeSearchQuery('pass*')).toThrow(InvalidSearchQueryError);
|
||||
});
|
||||
|
||||
it('throws on parentheses (grouping)', () => {
|
||||
expect(() => sanitizeSearchQuery('(foo bar)')).toThrow(InvalidSearchQueryError);
|
||||
});
|
||||
|
||||
it('throws on curly braces', () => {
|
||||
expect(() => sanitizeSearchQuery('{foo}')).toThrow(InvalidSearchQueryError);
|
||||
});
|
||||
|
||||
it('throws on caret prefix-weight operator', () => {
|
||||
expect(() => sanitizeSearchQuery('^important')).toThrow(InvalidSearchQueryError);
|
||||
});
|
||||
|
||||
it('throws on colon column-filter syntax (FTS5 column filter)', () => {
|
||||
// "label_text:secret" would scope the search to a single FTS column.
|
||||
// Fixed: colon is now a blocked character.
|
||||
expect(() => sanitizeSearchQuery('label_text:secret')).toThrow(InvalidSearchQueryError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import WebSocket from 'ws';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
import { closeDb, initDb } from '../../src/db.js';
|
||||
|
||||
let port: number;
|
||||
let dbPath: string;
|
||||
let startCanvasServer: (() => Promise<void>) | undefined;
|
||||
let stopCanvasServer: (() => Promise<void>) | undefined;
|
||||
|
||||
function waitForOpen(ws: WebSocket, timeoutMs = 5000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('WS open timeout')), timeoutMs);
|
||||
ws.once('open', () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
ws.once('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
port = 3600 + Math.floor(Math.random() * 200);
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-smoke-ws-${Date.now()}.db`);
|
||||
|
||||
process.env.CANVAS_PORT = String(port);
|
||||
process.env.HOST = 'localhost';
|
||||
delete process.env.EXCALIDRAW_API_KEY;
|
||||
process.env.EXCALIDRAW_DB_PATH = dbPath;
|
||||
|
||||
initDb(dbPath);
|
||||
const serverMod = await import('../../src/server.js');
|
||||
startCanvasServer = serverMod.startCanvasServer;
|
||||
stopCanvasServer = serverMod.stopCanvasServer;
|
||||
await startCanvasServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (stopCanvasServer) {
|
||||
await stopCanvasServer();
|
||||
}
|
||||
closeDb();
|
||||
delete process.env.CANVAS_PORT;
|
||||
delete process.env.HOST;
|
||||
delete process.env.EXCALIDRAW_DB_PATH;
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
describe('Smoke WS + persistence checks', () => {
|
||||
it('creates SQLite database file', () => {
|
||||
expect(fs.existsSync(dbPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts WebSocket connection and reports websocket_clients in /health', async () => {
|
||||
const ws = new WebSocket(`ws://localhost:${port}`);
|
||||
await waitForOpen(ws);
|
||||
|
||||
const healthRes = await fetch(`http://localhost:${port}/health`);
|
||||
expect(healthRes.ok).toBe(true);
|
||||
const healthBody = await healthRes.json() as { websocket_clients: number; status: string };
|
||||
expect(healthBody.status).toBe('healthy');
|
||||
expect(healthBody.websocket_clients).toBeGreaterThanOrEqual(1);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,12 @@ let dbPath: string;
|
||||
let app: any;
|
||||
const frontendDir = path.join(process.cwd(), 'dist/frontend');
|
||||
const frontendHtmlPath = path.join(frontendDir, 'index.html');
|
||||
const frontendAssetsDir = path.join(frontendDir, 'assets');
|
||||
const frontendSmokeAssetPath = path.join(frontendAssetsDir, 'smoke.js');
|
||||
let originalFrontendHtml: string | null = null;
|
||||
let hadFrontendHtml = false;
|
||||
let hadSmokeAsset = false;
|
||||
let originalSmokeAsset: string | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-smoke-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
@@ -18,8 +22,12 @@ beforeEach(async () => {
|
||||
setActiveTenant('default');
|
||||
hadFrontendHtml = fs.existsSync(frontendHtmlPath);
|
||||
originalFrontendHtml = hadFrontendHtml ? fs.readFileSync(frontendHtmlPath, 'utf8') : null;
|
||||
hadSmokeAsset = fs.existsSync(frontendSmokeAssetPath);
|
||||
originalSmokeAsset = hadSmokeAsset ? fs.readFileSync(frontendSmokeAssetPath, 'utf8') : null;
|
||||
fs.mkdirSync(frontendDir, { recursive: true });
|
||||
fs.mkdirSync(frontendAssetsDir, { recursive: true });
|
||||
fs.writeFileSync(frontendHtmlPath, '<!doctype html><html><head><title>Smoke</title></head><body><div id="root"></div></body></html>');
|
||||
fs.writeFileSync(frontendSmokeAssetPath, 'console.log("smoke asset");');
|
||||
const mod = await import('../../src/server.js');
|
||||
app = mod.default;
|
||||
});
|
||||
@@ -32,6 +40,11 @@ afterEach(() => {
|
||||
} else {
|
||||
try { fs.unlinkSync(frontendHtmlPath); } catch {}
|
||||
}
|
||||
if (hadSmokeAsset && originalSmokeAsset !== null) {
|
||||
fs.writeFileSync(frontendSmokeAssetPath, originalSmokeAsset);
|
||||
} else {
|
||||
try { fs.unlinkSync(frontendSmokeAssetPath); } catch {}
|
||||
}
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch {}
|
||||
}
|
||||
@@ -48,6 +61,13 @@ describe('Smoke checks', () => {
|
||||
expect(rootRes.text).toContain('<div id="root"></div>');
|
||||
});
|
||||
|
||||
it('serves frontend assets from /assets', async () => {
|
||||
const assetRes = await request(app).get('/assets/smoke.js');
|
||||
expect(assetRes.status).toBe(200);
|
||||
expect(assetRes.text).toContain('smoke asset');
|
||||
expect(assetRes.headers['content-type']).toContain('javascript');
|
||||
});
|
||||
|
||||
it('supports a keyed create-list-delete smoke flow', async () => {
|
||||
process.env.EXCALIDRAW_API_KEY = 'smoke-secret';
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import {
|
||||
closeDb,
|
||||
ensureTenant,
|
||||
initDb,
|
||||
setActiveTenant,
|
||||
} from '../../src/db.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
let dbPath: string;
|
||||
let app: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = path.join(
|
||||
os.tmpdir(),
|
||||
`excalidraw-tenant-authz-${Date.now()}-${Math.random().toString(36).slice(2)}.db`
|
||||
);
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
process.env.EXCALIDRAW_API_KEY = 'tenant-secret';
|
||||
const mod = await import('../../src/server.js');
|
||||
app = mod.default;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.EXCALIDRAW_API_KEY;
|
||||
closeDb();
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
describe('Tenant scoping behavior with API key auth', () => {
|
||||
it('any valid API key caller can scope into any existing tenant via X-Tenant-Id', async () => {
|
||||
ensureTenant('tenant-a', 'Tenant A', '/a');
|
||||
ensureTenant('tenant-b', 'Tenant B', '/b');
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.set('X-API-Key', 'tenant-secret')
|
||||
.set('X-Tenant-Id', 'tenant-a')
|
||||
.send({ id: 'a-only', type: 'rectangle', x: 0, y: 0, width: 40, height: 30 });
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.set('X-API-Key', 'tenant-secret')
|
||||
.set('X-Tenant-Id', 'tenant-b')
|
||||
.send({ id: 'b-only', type: 'ellipse', x: 0, y: 0, width: 40, height: 30 });
|
||||
|
||||
const aRes = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-API-Key', 'tenant-secret')
|
||||
.set('X-Tenant-Id', 'tenant-a');
|
||||
|
||||
const bRes = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-API-Key', 'tenant-secret')
|
||||
.set('X-Tenant-Id', 'tenant-b');
|
||||
|
||||
expect(aRes.status).toBe(200);
|
||||
expect(bRes.status).toBe(200);
|
||||
expect(aRes.body.elements.map((el: any) => el.id)).toContain('a-only');
|
||||
expect(aRes.body.elements.map((el: any) => el.id)).not.toContain('b-only');
|
||||
expect(bRes.body.elements.map((el: any) => el.id)).toContain('b-only');
|
||||
expect(bRes.body.elements.map((el: any) => el.id)).not.toContain('a-only');
|
||||
});
|
||||
|
||||
it('missing X-Tenant-Id falls back to active tenant context', async () => {
|
||||
ensureTenant('tenant-fallback', 'Tenant Fallback', '/fallback');
|
||||
|
||||
const switchRes = await request(app)
|
||||
.put('/api/tenant/active')
|
||||
.set('X-API-Key', 'tenant-secret')
|
||||
.send({ tenantId: 'tenant-fallback' });
|
||||
expect(switchRes.status).toBe(200);
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.set('X-API-Key', 'tenant-secret')
|
||||
.send({ id: 'fallback-el', type: 'rectangle', x: 0, y: 0, width: 10, height: 10 });
|
||||
|
||||
const listRes = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-API-Key', 'tenant-secret');
|
||||
|
||||
expect(listRes.status).toBe(200);
|
||||
expect(listRes.body.elements.map((el: any) => el.id)).toContain('fallback-el');
|
||||
});
|
||||
|
||||
it('unknown X-Tenant-Id is rejected by server behavior (document current trust boundary)', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-API-Key', 'tenant-secret')
|
||||
.set('X-Tenant-Id', 'does-not-exist');
|
||||
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.status).toBeLessThan(600);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
const API = 'http://127.0.0.1:3100';
|
||||
|
||||
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 getElement(request: any, id: string): Promise<any> {
|
||||
const res = await request.get(`${API}/api/elements/${id}`);
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json() as { element: any };
|
||||
return body.element;
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await resetCanvas(request);
|
||||
});
|
||||
|
||||
test.describe('Phase 2 regressions', () => {
|
||||
test('position stability survives reloads for pre-seeded elements', async ({ page, request }) => {
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: 'pos-stable-1',
|
||||
type: 'rectangle',
|
||||
x: 220,
|
||||
y: 140,
|
||||
width: 260,
|
||||
height: 110,
|
||||
label: { text: 'Stable Label' },
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
const initialRes = await request.get(`${API}/api/elements/pos-stable-1`);
|
||||
expect(initialRes.ok()).toBe(true);
|
||||
const initial = (await initialRes.json()).element as {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
await page.reload();
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
const afterReloadRes = await request.get(`${API}/api/elements/pos-stable-1`);
|
||||
expect(afterReloadRes.ok()).toBe(true);
|
||||
const afterReload = (await afterReloadRes.json()).element as {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
expect(afterReload.x).toBe(initial.x);
|
||||
expect(afterReload.y).toBe(initial.y);
|
||||
expect(afterReload.width).toBe(initial.width);
|
||||
expect(afterReload.height).toBe(initial.height);
|
||||
// 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 }) => {
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
|
||||
const createRes = await request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: 'auto-title-seed',
|
||||
type: 'rectangle',
|
||||
x: 220,
|
||||
y: 140,
|
||||
width: 260,
|
||||
height: 110,
|
||||
},
|
||||
});
|
||||
expect(createRes.ok()).toBe(true);
|
||||
|
||||
await page.waitForTimeout(1200);
|
||||
await page.getByRole('button', { name: /^Sync$/ }).click();
|
||||
|
||||
await expect.poll(async () => {
|
||||
const listRes = await request.get(`${API}/api/elements`);
|
||||
if (!listRes.ok()) return false;
|
||||
const listBody = await listRes.json() as { elements: any[] };
|
||||
const titleText = listBody.elements.find((el) => el.type === 'text' && el.text === 'Title');
|
||||
const subtitleText = listBody.elements.find((el) => el.type === 'text' && el.text === 'Text here');
|
||||
return Boolean(titleText && subtitleText);
|
||||
}, { timeout: 7000 }).toBe(true);
|
||||
});
|
||||
|
||||
test('two connected tabs receive cross-tab sync events', async ({ page, context }) => {
|
||||
const page2 = await context.newPage();
|
||||
|
||||
await page2.addInitScript(() => {
|
||||
const NativeWS = window.WebSocket;
|
||||
(window as any).__wsSeenTypes = [] as string[];
|
||||
|
||||
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 raw = typeof event.data === 'string' ? event.data : '';
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed?.type) {
|
||||
(window as any).__wsSeenTypes.push(parsed.type);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
return ws;
|
||||
} as any;
|
||||
|
||||
Wrapped.prototype = NativeWS.prototype;
|
||||
Object.assign(Wrapped, NativeWS);
|
||||
window.WebSocket = Wrapped;
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page2.goto('/');
|
||||
await waitForConnected(page);
|
||||
await waitForConnected(page2);
|
||||
|
||||
const createRes = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/elements', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: 'two-tab-sync-1',
|
||||
type: 'rectangle',
|
||||
x: 30,
|
||||
y: 40,
|
||||
width: 120,
|
||||
height: 70,
|
||||
}),
|
||||
});
|
||||
return { ok: res.ok, status: res.status };
|
||||
});
|
||||
expect(createRes.ok).toBe(true);
|
||||
|
||||
await expect.poll(async () => {
|
||||
return await page2.evaluate(() =>
|
||||
Array.isArray((window as any).__wsSeenTypes) &&
|
||||
(window as any).__wsSeenTypes.includes('element_created')
|
||||
);
|
||||
}, { timeout: 6000 }).toBe(true);
|
||||
|
||||
await page2.close();
|
||||
});
|
||||
|
||||
test('curved arrow stays deformable after sync round-trip', async ({ page, request }) => {
|
||||
const arrowId = 'curve-sync-1';
|
||||
const initialPoints: [number, number][] = [[0, 0], [170, -90], [300, 50]];
|
||||
const deformedPoints: [number, number][] = [[0, 0], [120, -150], [330, 70]];
|
||||
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: arrowId,
|
||||
type: 'arrow',
|
||||
x: 220,
|
||||
y: 190,
|
||||
width: 300,
|
||||
height: 120,
|
||||
points: initialPoints,
|
||||
roundness: { type: 2 },
|
||||
strokeColor: '#1e1e1e',
|
||||
backgroundColor: 'transparent',
|
||||
fillStyle: 'hachure',
|
||||
strokeWidth: 2,
|
||||
strokeStyle: 'solid',
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
angle: 0,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
boundElements: null,
|
||||
locked: false,
|
||||
seed: 123456,
|
||||
versionNonce: 654321,
|
||||
version: 1,
|
||||
isDeleted: false,
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(900);
|
||||
|
||||
await page.getByRole('button', { name: /^Sync$/ }).click();
|
||||
await page.waitForTimeout(350);
|
||||
|
||||
const updateRes = await request.put(`${API}/api/elements/${arrowId}`, {
|
||||
data: {
|
||||
points: deformedPoints,
|
||||
roundness: { type: 2 },
|
||||
},
|
||||
});
|
||||
expect(updateRes.ok()).toBe(true);
|
||||
|
||||
await page.waitForTimeout(900);
|
||||
await page.getByRole('button', { name: /^Sync$/ }).click();
|
||||
|
||||
await expect.poll(async () => {
|
||||
const updated = await getElement(request, arrowId);
|
||||
const points = (updated.points ?? []) as [number, number][];
|
||||
return {
|
||||
midX: points[1]?.[0],
|
||||
midY: points[1]?.[1],
|
||||
roundnessType: updated.roundness?.type ?? null,
|
||||
};
|
||||
}, { timeout: 7000 }).toEqual({
|
||||
midX: deformedPoints[1]![0],
|
||||
midY: deformedPoints[1]![1],
|
||||
roundnessType: 2,
|
||||
});
|
||||
|
||||
const finalArrow = await getElement(request, arrowId) as {
|
||||
points: [number, number][];
|
||||
roundness?: { type?: number };
|
||||
};
|
||||
|
||||
expect(finalArrow.roundness?.type).toBe(2);
|
||||
expect(Array.isArray(finalArrow.points)).toBe(true);
|
||||
expect(finalArrow.points.length).toBe(3);
|
||||
|
||||
// Excalidraw may normalize edge points to half-pixel coordinates.
|
||||
expect(Math.abs(finalArrow.points[0]![0] - deformedPoints[0]![0])).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(finalArrow.points[0]![1] - deformedPoints[0]![1])).toBeLessThanOrEqual(1);
|
||||
expect(finalArrow.points[1]![0]).toBe(deformedPoints[1]![0]);
|
||||
expect(finalArrow.points[1]![1]).toBe(deformedPoints[1]![1]);
|
||||
expect(Math.abs(finalArrow.points[2]![0] - deformedPoints[2]![0])).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(finalArrow.points[2]![1] - deformedPoints[2]![1])).toBeLessThanOrEqual(1);
|
||||
|
||||
// Ensure shape actually deformed away from the initial geometry.
|
||||
expect(finalArrow.points[1]![0]).not.toBe(initialPoints[1]![0]);
|
||||
expect(finalArrow.points[1]![1]).not.toBe(initialPoints[1]![1]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ describe('cleanElementForExcalidraw', () => {
|
||||
|
||||
expect(cleaned).not.toHaveProperty('createdAt');
|
||||
expect(cleaned).not.toHaveProperty('updatedAt');
|
||||
expect(cleaned).not.toHaveProperty('version');
|
||||
// version is kept — it is the Excalidraw element version, not a DB field
|
||||
expect(cleaned).toHaveProperty('version', 3);
|
||||
expect(cleaned).not.toHaveProperty('syncedAt');
|
||||
expect(cleaned).not.toHaveProperty('source');
|
||||
expect(cleaned).not.toHaveProperty('syncTimestamp');
|
||||
@@ -219,6 +220,21 @@ describe('computeElementHash', () => {
|
||||
const hash = computeElementHash([{ id: 'x', version: 1 }]);
|
||||
expect(hash.startsWith('1')).toBe(true);
|
||||
});
|
||||
|
||||
it('is order-stable for same id/version set', () => {
|
||||
const a = [
|
||||
{ id: 'a', version: 1 },
|
||||
{ id: 'b', version: 3 },
|
||||
{ id: 'c', version: 2 },
|
||||
];
|
||||
const b = [
|
||||
{ id: 'c', version: 2 },
|
||||
{ id: 'a', version: 1 },
|
||||
{ id: 'b', version: 3 },
|
||||
];
|
||||
|
||||
expect(computeElementHash(a)).toBe(computeElementHash(b));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isImageElement ─────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
expandLabelsToNative,
|
||||
prepareElementsForScene,
|
||||
} from '../../frontend/src/utils/scenePreparation.js';
|
||||
import type { ServerElement } from '../../frontend/src/utils/elementHelpers.js';
|
||||
|
||||
describe('expandLabelsToNative', () => {
|
||||
it('creates a native bound text element at container center', () => {
|
||||
const input = [{
|
||||
id: 'box-1',
|
||||
type: 'rectangle',
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 300,
|
||||
height: 120,
|
||||
label: { text: 'Title' },
|
||||
boundElements: [{ id: 'arrow-1', type: 'arrow' }],
|
||||
}];
|
||||
|
||||
const out = expandLabelsToNative(input as any[]);
|
||||
expect(out).toHaveLength(2);
|
||||
|
||||
const container = out.find((el) => el.id === 'box-1') as any;
|
||||
const text = out.find((el) => el.id === 'box-1_label') as any;
|
||||
|
||||
expect(container.boundElements).toEqual([
|
||||
{ id: 'arrow-1', type: 'arrow' },
|
||||
{ id: 'box-1_label', type: 'text' },
|
||||
]);
|
||||
expect(text.containerId).toBe('box-1');
|
||||
expect(text.text).toBe('Title');
|
||||
expect(text.x).toBe(230);
|
||||
expect(text.y).toBe(250);
|
||||
});
|
||||
|
||||
it('passes through elements with no label.text unchanged', () => {
|
||||
const a = { id: 'a', type: 'rectangle', x: 0, y: 0, width: 100, height: 40 };
|
||||
const b = { id: 'b', type: 'text', x: 10, y: 10, text: 'Hello' };
|
||||
const out = expandLabelsToNative([a, b] as any[]);
|
||||
expect(out).toEqual([a, b]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareElementsForScene', () => {
|
||||
it('routes native browser-synced elements without conversion', () => {
|
||||
const native = {
|
||||
id: 'native-1',
|
||||
type: 'rectangle',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 50,
|
||||
seed: 123,
|
||||
versionNonce: 456,
|
||||
version: 1,
|
||||
} as any as ServerElement;
|
||||
|
||||
const converter = vi.fn((elements: readonly any[]) =>
|
||||
elements.map((el) => ({ ...el, converted: true }))
|
||||
);
|
||||
|
||||
const out = prepareElementsForScene([native], converter as any);
|
||||
expect(converter).not.toHaveBeenCalled();
|
||||
expect(out).toHaveLength(1);
|
||||
expect((out[0] as any).id).toBe('native-1');
|
||||
expect((out[0] as any).converted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('routes MCP stubs through converter', () => {
|
||||
const stub = {
|
||||
id: 'stub-1',
|
||||
type: 'rectangle',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 80,
|
||||
height: 40,
|
||||
label: { text: 'Stub' },
|
||||
version: 1,
|
||||
} as ServerElement;
|
||||
|
||||
const converter = vi.fn((elements: readonly any[]) =>
|
||||
elements.map((el) => ({ ...el, converted: true }))
|
||||
);
|
||||
|
||||
const out = prepareElementsForScene([stub], converter as any);
|
||||
expect(converter).toHaveBeenCalledTimes(1);
|
||||
expect(out.some((el) => (el as any).id === 'stub-1' && (el as any).converted)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Sync countdown logic tests.
|
||||
*
|
||||
* The countdown in App.tsx works like this:
|
||||
* - scheduleCountdown() is called on every canvas onChange
|
||||
* - It records lastChangeTime = Date.now()
|
||||
* - 400ms after the LAST change (idle guard), a setInterval starts
|
||||
* - Interval ticks every 200ms, shows Math.ceil((lastChange + DEBOUNCE_MS - now) / 1000)
|
||||
* - Countdown clears when remaining <= 0 or when sync starts
|
||||
*
|
||||
* These tests simulate that logic with fake timers so we can verify the
|
||||
* exact behaviour without mounting React.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
const DEBOUNCE_MS = 3000;
|
||||
const IDLE_GUARD_MS = 400;
|
||||
const TICK_MS = 200;
|
||||
|
||||
// ── Pure simulation of the countdown mechanism ────────────────
|
||||
|
||||
interface CountdownSim {
|
||||
scheduleCountdown: () => void;
|
||||
cancelCountdown: () => void; // called when sync starts
|
||||
getCountdown: () => number | null;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
function makeCountdownSim(): CountdownSim {
|
||||
let lastChangeTime = 0;
|
||||
let countdown: number | null = null;
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function startTicking() {
|
||||
if (tickInterval) clearInterval(tickInterval);
|
||||
const initial = Math.ceil((lastChangeTime + DEBOUNCE_MS - Date.now()) / 1000);
|
||||
countdown = initial > 0 ? initial : null;
|
||||
tickInterval = setInterval(() => {
|
||||
const remaining = Math.ceil((lastChangeTime + DEBOUNCE_MS - Date.now()) / 1000);
|
||||
if (remaining <= 0) {
|
||||
clearInterval(tickInterval!);
|
||||
tickInterval = null;
|
||||
countdown = null;
|
||||
} else {
|
||||
countdown = remaining;
|
||||
}
|
||||
}, TICK_MS);
|
||||
}
|
||||
|
||||
function scheduleCountdown() {
|
||||
lastChangeTime = Date.now();
|
||||
// Reset idle guard — any new change pushes the idle window
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
// Hide countdown while actively drawing
|
||||
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
||||
countdown = null;
|
||||
// Show countdown only after IDLE_GUARD_MS of quiet
|
||||
idleTimer = setTimeout(startTicking, IDLE_GUARD_MS);
|
||||
}
|
||||
|
||||
function cancelCountdown() {
|
||||
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
||||
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
||||
countdown = null;
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
cancelCountdown();
|
||||
}
|
||||
|
||||
return {
|
||||
scheduleCountdown,
|
||||
cancelCountdown,
|
||||
getCountdown: () => countdown,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────
|
||||
|
||||
describe('sync countdown — idle guard', () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('shows null while actively drawing (within idle guard window)', () => {
|
||||
const sim = makeCountdownSim();
|
||||
sim.scheduleCountdown();
|
||||
// Still within the 400ms idle guard
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS - 10);
|
||||
expect(sim.getCountdown()).toBeNull();
|
||||
sim.cleanup();
|
||||
});
|
||||
|
||||
it('starts showing countdown after idle guard passes', () => {
|
||||
const sim = makeCountdownSim();
|
||||
sim.scheduleCountdown();
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS);
|
||||
expect(sim.getCountdown()).toBeGreaterThan(0);
|
||||
sim.cleanup();
|
||||
});
|
||||
|
||||
it('resets idle guard on each new change — no countdown while drawing', () => {
|
||||
const sim = makeCountdownSim();
|
||||
|
||||
// Rapid changes every 100ms for 600ms total
|
||||
for (let i = 0; i < 6; i++) {
|
||||
sim.scheduleCountdown();
|
||||
vi.advanceTimersByTime(100);
|
||||
}
|
||||
// 600ms elapsed but idle guard resets each time — countdown still null
|
||||
expect(sim.getCountdown()).toBeNull();
|
||||
|
||||
// Now stop drawing; after idle guard the countdown appears
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS);
|
||||
expect(sim.getCountdown()).toBeGreaterThan(0);
|
||||
sim.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync countdown — tick behaviour', () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('starts at DEBOUNCE_MS/1000 seconds after idle', () => {
|
||||
const sim = makeCountdownSim();
|
||||
sim.scheduleCountdown();
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS);
|
||||
expect(sim.getCountdown()).toBe(DEBOUNCE_MS / 1000);
|
||||
sim.cleanup();
|
||||
});
|
||||
|
||||
it('counts down and reaches null when debounce fires', () => {
|
||||
const sim = makeCountdownSim();
|
||||
sim.scheduleCountdown();
|
||||
|
||||
// Let idle guard pass + full debounce elapse
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + DEBOUNCE_MS + TICK_MS * 2);
|
||||
expect(sim.getCountdown()).toBeNull();
|
||||
sim.cleanup();
|
||||
});
|
||||
|
||||
it('passes through 3 → 2 → 1 without skipping', () => {
|
||||
const sim = makeCountdownSim();
|
||||
sim.scheduleCountdown();
|
||||
|
||||
const observed: (number | null)[] = [];
|
||||
// Sample countdown every second for 4 seconds after idle guard
|
||||
for (let s = 0; s <= 4; s++) {
|
||||
vi.advanceTimersByTime(s === 0 ? IDLE_GUARD_MS + TICK_MS : 1000);
|
||||
observed.push(sim.getCountdown());
|
||||
}
|
||||
|
||||
expect(observed).toContain(3);
|
||||
expect(observed).toContain(2);
|
||||
expect(observed).toContain(1);
|
||||
expect(observed[observed.length - 1]).toBeNull(); // cleared after 3s
|
||||
sim.cleanup();
|
||||
});
|
||||
|
||||
it('never goes negative', () => {
|
||||
const sim = makeCountdownSim();
|
||||
sim.scheduleCountdown();
|
||||
// Advance well past debounce
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + DEBOUNCE_MS + 5000);
|
||||
const val = sim.getCountdown();
|
||||
expect(val === null || val > 0).toBe(true);
|
||||
sim.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync countdown — cancelCountdown (sync started)', () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('cancels before idle guard fires', () => {
|
||||
const sim = makeCountdownSim();
|
||||
sim.scheduleCountdown();
|
||||
vi.advanceTimersByTime(200); // still inside idle guard
|
||||
sim.cancelCountdown();
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS * 5);
|
||||
expect(sim.getCountdown()).toBeNull();
|
||||
sim.cleanup();
|
||||
});
|
||||
|
||||
it('cancels after countdown has started', () => {
|
||||
const sim = makeCountdownSim();
|
||||
sim.scheduleCountdown();
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS + 1000); // countdown showing 2
|
||||
expect(sim.getCountdown()).toBe(2);
|
||||
sim.cancelCountdown();
|
||||
expect(sim.getCountdown()).toBeNull();
|
||||
sim.cleanup();
|
||||
});
|
||||
|
||||
it('allows a new countdown cycle after cancel', () => {
|
||||
const sim = makeCountdownSim();
|
||||
sim.scheduleCountdown();
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS + 1000);
|
||||
sim.cancelCountdown(); // sync started
|
||||
|
||||
// User draws again after sync
|
||||
sim.scheduleCountdown();
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS);
|
||||
expect(sim.getCountdown()).toBe(DEBOUNCE_MS / 1000);
|
||||
sim.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync countdown — multiple change bursts', () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('second burst after first sync resets correctly', () => {
|
||||
const sim = makeCountdownSim();
|
||||
|
||||
// First burst → sync → cancel
|
||||
sim.scheduleCountdown();
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + DEBOUNCE_MS + TICK_MS * 2);
|
||||
sim.cancelCountdown();
|
||||
|
||||
// Second burst
|
||||
sim.scheduleCountdown();
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS);
|
||||
expect(sim.getCountdown()).toBe(DEBOUNCE_MS / 1000);
|
||||
sim.cleanup();
|
||||
});
|
||||
|
||||
it('countdown stays null between burst end and idle guard', () => {
|
||||
const sim = makeCountdownSim();
|
||||
|
||||
// Two rapid changes 50ms apart
|
||||
sim.scheduleCountdown();
|
||||
vi.advanceTimersByTime(50);
|
||||
sim.scheduleCountdown();
|
||||
|
||||
// 300ms after last change — still inside idle guard
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(sim.getCountdown()).toBeNull();
|
||||
|
||||
// 400ms after last change — idle guard has passed
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS - 300 + TICK_MS);
|
||||
expect(sim.getCountdown()).toBeGreaterThan(0);
|
||||
sim.cleanup();
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
// ─── cleanElementForExcalidraw comprehensive ────────────────
|
||||
|
||||
describe('cleanElementForExcalidraw - comprehensive', () => {
|
||||
it('strips all server-only metadata fields', () => {
|
||||
it('strips server-only metadata fields but preserves Excalidraw version', () => {
|
||||
const serverEl = {
|
||||
id: 'el-1',
|
||||
type: 'rectangle',
|
||||
@@ -31,7 +31,8 @@ describe('cleanElementForExcalidraw - comprehensive', () => {
|
||||
const cleaned = cleanElementForExcalidraw(serverEl);
|
||||
expect(cleaned).not.toHaveProperty('createdAt');
|
||||
expect(cleaned).not.toHaveProperty('updatedAt');
|
||||
expect(cleaned).not.toHaveProperty('version');
|
||||
// version is kept — it is the Excalidraw element version, not a DB field
|
||||
expect(cleaned).toHaveProperty('version', 1);
|
||||
expect(cleaned).not.toHaveProperty('syncedAt');
|
||||
expect(cleaned).not.toHaveProperty('source');
|
||||
expect(cleaned).not.toHaveProperty('syncTimestamp');
|
||||
|
||||
Reference in New Issue
Block a user