agentic-canvas 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -10
- package/dist/{chunk-V477KSZQ.js → chunk-HPUYT5TC.js} +183 -8
- package/dist/{chunk-W7NRP3EX.js → chunk-J52RPFRP.js} +39 -8
- package/dist/{chunk-GGQPE65T.js → chunk-JZA525X5.js} +61 -2
- package/dist/{chunk-KEMOXKBY.js → chunk-TSWXD4YG.js} +15 -2
- package/dist/cli.js +13 -3
- package/dist/client.d.ts +119 -0
- package/dist/client.js +80 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -3
- package/dist/live-worker.d.ts +2 -1
- package/dist/live-worker.js +3 -3
- package/dist/live.d.ts +7 -1
- package/dist/live.js +12 -11
- package/dist/{service-CJTe-uBI.d.ts → read-Bdg1uiFC.d.ts} +11 -108
- package/dist/service-Cim7TWnJ.d.ts +128 -0
- package/dist/worker.d.ts +5 -2
- package/dist/worker.js +5 -3
- package/llms.txt +3 -1
- package/package.json +21 -6
package/README.md
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
|
|
8
8
|
A shared whiteboard for diagrams, drawn by people and agents.
|
|
9
9
|
|
|
10
|
-
> Status: 0.
|
|
10
|
+
> Status: 0.4.0. The board model, the agent operations, the exports, the CLI, the
|
|
11
11
|
> MCP server, a small Worker API, and live co-editing work. The CLI, the MCP server,
|
|
12
|
-
> and the SDK can join a live room.
|
|
12
|
+
> and the SDK can join a live room. A remote client calls the Worker API. A small web UI, served by the Worker, is new in 0.4.0.
|
|
13
13
|
|
|
14
14
|
## Install
|
|
15
15
|
|
|
@@ -37,7 +37,7 @@ npx agentic-canvas export demo --out demo.svg
|
|
|
37
37
|
npx agentic-canvas export demo --format png --out demo.png
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
Other commands: `add`, `connect`, `move`, `rm`. Run `npx agentic-canvas --help`.
|
|
40
|
+
Other commands: `list`, `add`, `connect`, `update`, `move`, `rm`. Run `npx agentic-canvas --help`.
|
|
41
41
|
`repair` is for the owner only. It needs `CANVAS_ACTOR=human:<id>`.
|
|
42
42
|
Boards live in `./.agentic-canvas` (or `--dir`, or `CANVAS_DIR`). Every write
|
|
43
43
|
needs an actor in `CANVAS_ACTOR`, as `<kind>:<id>`. The operator sets it. A write
|
|
@@ -51,7 +51,7 @@ CANVAS_ACTOR=agent:helper npx agentic-canvas --dir ./boards mcp
|
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
The server speaks MCP on stdio. It has one tool for each CLI command: `create_board`,
|
|
54
|
-
`read_board`, `add_shape`, `connect`, `move`, `remove`, `draw_flow`, `export_svg`, and
|
|
54
|
+
`list_boards`, `read_board`, `add_shape`, `connect`, `update_element`, `move`, `remove`, `draw_flow`, `export_svg`, and
|
|
55
55
|
`export_png`. `repair` is not a tool. Only a human operator runs it, from the CLI. The server will not start unless `CANVAS_ACTOR` is set to a valid
|
|
56
56
|
`<kind>:<id>`. That actor stamps every write. No tool takes an actor, author, or kind, so a
|
|
57
57
|
caller cannot choose who it writes as. An error comes back as a tool error with a code.
|
|
@@ -84,6 +84,111 @@ The flow text takes one statement per line: `A -> B: label`. Use `[text]` for a
|
|
|
84
84
|
box, `(text)` for an ellipse, and `{text}` for a diamond. `direction LR` sets
|
|
85
85
|
the direction. Lines that start with `#` or `//` are comments.
|
|
86
86
|
|
|
87
|
+
### Remote client
|
|
88
|
+
|
|
89
|
+
Call a running Worker from a browser, a Worker, or Node 22. The client uses only `fetch`.
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { createClient, CanvasApiError } from "agentic-canvas/client";
|
|
93
|
+
|
|
94
|
+
const canvas = createClient({ baseUrl: "https://canvas.example.com/api/v1/canvas", token });
|
|
95
|
+
await canvas.createBoard({ id: "plan", title: "Plan" });
|
|
96
|
+
await canvas.addShape("plan", { type: "rect", x: 0, y: 0, text: "Start" });
|
|
97
|
+
await canvas.drawFlow("plan", "A -> B: go");
|
|
98
|
+
const board = await canvas.readBoard("plan");
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
await canvas.readBoard("missing");
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error instanceof CanvasApiError) console.log(error.code, error.status); // NOT_FOUND 404
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
There is one method per route: `me`, `listBoards`, `createBoard`, `readBoard`, `exportSvg`, `addShape`, `addConnector`,
|
|
108
|
+
`drawFlow`, `update`, `move`, `remove`, `uploadImage`, `getImage`, `state`, and `checkpoint`. Each takes
|
|
109
|
+
`{ signal }` last, so an `AbortSignal` can stop a call. The token sets the actor. A failed call throws
|
|
110
|
+
`CanvasApiError` with the API `code` and the HTTP `status`. A call that gets no reply has code `NETWORK`
|
|
111
|
+
and status 0. The client never retries. A 503 means "try again", and the caller decides, because a
|
|
112
|
+
repeated write can change a board twice. Pass `fetch` to use your own.
|
|
113
|
+
|
|
114
|
+
## Live demo
|
|
115
|
+
|
|
116
|
+
A demo Worker runs on workers.dev: <https://agentic-canvas-demo.hello-fd1.workers.dev>. `GET /health` answers 200
|
|
117
|
+
and the web UI is at the root. The API needs a token, so a request without one gets 401. The demo's D1
|
|
118
|
+
database and R2 bucket are in the EU, and its live rooms use the EU jurisdiction. The demo data can be
|
|
119
|
+
reset at any time.
|
|
120
|
+
|
|
121
|
+
To redeploy it, a maintainer with a Cloudflare token runs this from the repo root. The deploy is manual and
|
|
122
|
+
no CI secret is used:
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
npm ci && npm run build
|
|
126
|
+
WRANGLER_SEND_METRICS=false CLOUDFLARE_API_TOKEN=... npx wrangler deploy -c wrangler.demo.jsonc
|
|
127
|
+
npx wrangler secret put CANVAS_API_TOKENS -c wrangler.demo.jsonc # only to set or rotate tokens
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`wrangler.demo.jsonc` holds the config. The tokens are a Worker secret and are not in the repo. To run your
|
|
131
|
+
own copy, create a D1 database and an R2 bucket (both in the EU), put their names and id in a copy of that
|
|
132
|
+
file, and deploy it the same way.
|
|
133
|
+
|
|
134
|
+
## UI
|
|
135
|
+
|
|
136
|
+
The Worker serves a small web UI at its root. It uses the published `agentic-design` package for
|
|
137
|
+
tokens, controls, and overlays, and the typed `agentic-canvas/client` for every call. It is
|
|
138
|
+
bundled at build time, so a build needs `agentic-design` from npm and the browser loads nothing
|
|
139
|
+
from another site.
|
|
140
|
+
|
|
141
|
+
Run it on your machine:
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
npm ci
|
|
145
|
+
npm run build # writes the UI to ui-dist/
|
|
146
|
+
cat > .dev.vars <<'EOF2'
|
|
147
|
+
CANVAS_REGION=unknown
|
|
148
|
+
CANVAS_API_TOKENS={"ada-token-0123456789-0123456789-0123456789":{"actor":"ada","kind":"human"}}
|
|
149
|
+
EOF2
|
|
150
|
+
WRANGLER_SEND_METRICS=false npx wrangler dev
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Open the address that wrangler prints and paste the token. A token has 32 characters or more. Its
|
|
154
|
+
grant sets the name, the kind (a person or an agent), and the role. A `viewer` token reads and
|
|
155
|
+
cannot change a board. The token stays in the tab's session storage.
|
|
156
|
+
|
|
157
|
+
What it does:
|
|
158
|
+
|
|
159
|
+
- Lists boards, makes a board, and opens one. Empty, loading, and error states have a retry.
|
|
160
|
+
- Draws shapes, connectors, text, and sticky notes. Select, move, resize, connect, and delete an
|
|
161
|
+
item. Pan by dragging the background, zoom with the wheel or the toolbar.
|
|
162
|
+
- Marks an item an agent wrote with an "Agent" tag and in its accessible name. The side panel shows
|
|
163
|
+
who made the item and who last edited it.
|
|
164
|
+
- Shows who is in the board, from the live room (`agentic-realtime`). Edits go over the HTTP API.
|
|
165
|
+
After a save, the editor raises a counter in its room presence, and the other editors read the
|
|
166
|
+
board again. The page also reads the board again every 5 seconds, so a change made through the API, the CLI, or an agent shows without a reload, a few seconds late.
|
|
167
|
+
- Works with a keyboard. Tab moves between items. Arrow keys move the selected item, Shift with an
|
|
168
|
+
arrow resizes it, Delete removes it, Escape clears the selection. With nothing selected, arrow
|
|
169
|
+
keys pan, `+` and `-` zoom, and `0` resets the view. The side panel also sets text, position, and size,
|
|
170
|
+
and connects shapes, with fields, and the toolbar has pan buttons. A person who cannot drag
|
|
171
|
+
can do everything a drag does.
|
|
172
|
+
|
|
173
|
+
Develop and test the UI:
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
npm run build # UI and library
|
|
177
|
+
npx playwright-core install chromium
|
|
178
|
+
npm run test:e2e # Playwright against `wrangler dev`, with axe (WCAG 2.2 AA)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The E2E run creates a board, adds a shape and a note, moves them, reloads, and checks each view
|
|
182
|
+
with axe in the light and the dark theme. It saves screenshots to `test-results/screens/`. The committed set in `docs/ui/` shows the UI at this release. Run `UPDATE_SCREENSHOTS=1 npm run test:e2e` to refresh it.
|
|
183
|
+
|
|
184
|
+
A browser cannot set a header on a WebSocket, so the UI sends its token to the live room as a WebSocket sub-protocol (`Sec-WebSocket-Protocol`). A proxy that logs request headers can record it. The API itself takes the token only in an `Authorization` header, and the sub-protocol only on a WebSocket upgrade.
|
|
185
|
+
|
|
186
|
+
The UI saves each edit in order, and shows "Saving…" until the last one is stored. It asks the browser to confirm before you leave a page with unsaved edits. It reads the board again at most once a second.
|
|
187
|
+
|
|
188
|
+
UI limits: the UI draws with the theme colors and ignores the `fill` and `stroke` of an element.
|
|
189
|
+
It shows no image pixels. A text field of a shape is plain text. Two editors who move one item
|
|
190
|
+
at the same time keep the last write. The board list shows the first 100 boards.
|
|
191
|
+
|
|
87
192
|
## Features
|
|
88
193
|
|
|
89
194
|
- Shapes: rectangle, ellipse, diamond, text, sticky note, frame, image.
|
|
@@ -105,23 +210,28 @@ the direction. Lines that start with `#` or `//` are comments.
|
|
|
105
210
|
(EU jurisdiction), through the shared `Store` and `Blobs` ports. Set `CANVAS_API_TOKENS`
|
|
106
211
|
(a JSON map of token to `{ actor, kind }`, tokens of 32 characters or more) and `CANVAS_REGION`.
|
|
107
212
|
Routes: `POST /boards`, `GET /boards/:id`, `GET /boards/:id/svg`, `POST /boards/:id/shapes`,
|
|
108
|
-
`/connectors`, `/flow`, `/move`, `DELETE /boards/:id/elements/:element`, `POST /images`,
|
|
213
|
+
`/connectors`, `/flow`, `/move`, `GET /boards`, `PATCH /boards/:id/elements/:element`, `DELETE /boards/:id/elements/:element`, `POST /images`,
|
|
109
214
|
`GET /images/:id`. An image is stored under the SHA-256 of its bytes, and the reply gives
|
|
110
215
|
the `blob:` ref for a shape. A busy database or bucket answers 503: try again.
|
|
111
216
|
- Live rooms (`agentic-canvas/live-worker`, on `agentic-realtime`). One Durable Object holds one
|
|
112
217
|
board. A token may carry `"role": "viewer"`: a viewer reads and cannot write. `GET /boards/:id/ws`
|
|
113
|
-
opens a room, `GET /me` says who a token is
|
|
114
|
-
|
|
115
|
-
|
|
218
|
+
opens a room, `GET /me` says who a token is. The Worker seeds a room from the stored board when a client
|
|
219
|
+
joins, and saves the room to the stored board before it serves any board read or write. A raw WebSocket
|
|
220
|
+
client needs no seed and no save call. A room that no request reaches stays unsaved in the Store until the
|
|
221
|
+
next request, and it is safe in the room's own storage. `GET /boards/:id/state` and
|
|
222
|
+
`POST /boards/:id/checkpoint` still work for a client that saves by hand. Every save checks author stamps,
|
|
223
|
+
and the Worker sets them from the room's log of who sent each update (the token's actor and kind): a client
|
|
224
|
+
cannot claim another principal, and an agent cannot pass as a person, even by naming one who is in the room.
|
|
225
|
+
A new item that the log lacks (it keeps a capped number of ranges, and a hand save may hold content the room never had) has no sender: the save deletes a new element and undoes a change. A hand save puts the part of its update that the room lacks into the room as the poster, so the room refuses a client id that it holds for someone else and its log names the poster. It drops the part the room holds, saves the room, and fails if the room cannot be saved. It is one message of 1,000,000 bytes at most. A stored board over 1,000,000 bytes cannot seed a room: a live join answers 422 for it.
|
|
116
226
|
- The SDK joins a room: `import { LiveBoard, LiveService } from "agentic-canvas/live"`. The CLI and the MCP
|
|
117
227
|
server join when `CANVAS_SERVER` and `CANVAS_TOKEN` are set. The token sets the actor, so
|
|
118
228
|
`CANVAS_ACTOR` is not needed. `--dir`, `CANVAS_DIR`, and `repair` stay local.
|
|
119
229
|
- Yjs underneath: two copies of a board merge without conflicts.
|
|
120
|
-
-
|
|
230
|
+
- The board model has no UI code. It is plain TypeScript on Yjs, with no outbound calls.
|
|
121
231
|
|
|
122
232
|
## Limits
|
|
123
233
|
|
|
124
|
-
- Canvas takes principal kinds, regions, and the Store and Blobs ports from `agentic-identity` 0.
|
|
234
|
+
- Canvas takes principal kinds, regions, and the Store and Blobs ports from `agentic-identity` 0.5.
|
|
125
235
|
It does not use the sharing API yet. Its editor and viewer tokens are a separate table in
|
|
126
236
|
`CANVAS_API_TOKENS`. It has no board delete or erase. When it adopts sharing, grants are keyed
|
|
127
237
|
on kind and id, and a delete calls `deleteResource`.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CanvasService,
|
|
3
3
|
MAX_LIVE_BYTES
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-J52RPFRP.js";
|
|
5
5
|
import {
|
|
6
6
|
CanvasError,
|
|
7
7
|
principalSchema
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-JZA525X5.js";
|
|
9
9
|
|
|
10
10
|
// src/worker.ts
|
|
11
11
|
import { parseRegion } from "agentic-identity";
|
|
@@ -1788,6 +1788,145 @@ var Hono3 = class extends Hono {
|
|
|
1788
1788
|
}
|
|
1789
1789
|
};
|
|
1790
1790
|
|
|
1791
|
+
// src/room-sync.ts
|
|
1792
|
+
import * as Y from "yjs";
|
|
1793
|
+
var SEEDER = { kind: "agent", id: "canvas-room" };
|
|
1794
|
+
var MAX_SEED_BYTES = 1e6;
|
|
1795
|
+
var SYNC = 0;
|
|
1796
|
+
var AUTH = 2;
|
|
1797
|
+
var SYNC_STEP2 = 1;
|
|
1798
|
+
var SYNC_UPDATE = 2;
|
|
1799
|
+
var same = (a, b) => a.kind === b.kind && a.id === b.id;
|
|
1800
|
+
function varUint(value) {
|
|
1801
|
+
const out = [];
|
|
1802
|
+
for (; value > 127; value = Math.floor(value / 128)) out.push(128 | value % 128);
|
|
1803
|
+
return [...out, value];
|
|
1804
|
+
}
|
|
1805
|
+
async function roomState(live, id) {
|
|
1806
|
+
const meta = await live.meta(id);
|
|
1807
|
+
if (meta.updates === 0 && meta.compactions === 0) return null;
|
|
1808
|
+
const reader = (await live.exportHistory(id)).getReader();
|
|
1809
|
+
const decoder = new TextDecoder();
|
|
1810
|
+
let text2 = "";
|
|
1811
|
+
try {
|
|
1812
|
+
for (; ; ) {
|
|
1813
|
+
const { done, value } = await reader.read();
|
|
1814
|
+
if (value) text2 += decoder.decode(value, { stream: true });
|
|
1815
|
+
for (let end = text2.indexOf("\n"); end >= 0; end = text2.indexOf("\n")) {
|
|
1816
|
+
let record;
|
|
1817
|
+
try {
|
|
1818
|
+
record = JSON.parse(text2.slice(0, end));
|
|
1819
|
+
} catch {
|
|
1820
|
+
throw new CanvasError("UNAVAILABLE", "the room sent an export this server cannot read");
|
|
1821
|
+
}
|
|
1822
|
+
text2 = text2.slice(end + 1);
|
|
1823
|
+
if (record.type === "state" && typeof record.base64 === "string") return Uint8Array.from(atob(record.base64), (c) => c.charCodeAt(0));
|
|
1824
|
+
}
|
|
1825
|
+
if (done) throw new CanvasError("UNAVAILABLE", "the room sent no state");
|
|
1826
|
+
}
|
|
1827
|
+
} finally {
|
|
1828
|
+
await reader.cancel().catch(() => void 0);
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
async function send(live, id, update, roomVector, who) {
|
|
1832
|
+
if (update.length > MAX_SEED_BYTES) throw new CanvasError("LIMIT", "the update is too big to put in a room");
|
|
1833
|
+
const upgrade = new Request("https://room.internal/ws", { headers: { upgrade: "websocket" } });
|
|
1834
|
+
const response = await live.connect(id, upgrade, { principal: who.principal, role: "editor", name: who.principal.id });
|
|
1835
|
+
const socket = response.webSocket;
|
|
1836
|
+
if (!socket) throw new CanvasError("UNAVAILABLE", "the room did not open");
|
|
1837
|
+
socket.accept();
|
|
1838
|
+
await new Promise((resolve, reject) => {
|
|
1839
|
+
const timer = setTimeout(() => fail2(new CanvasError("UNAVAILABLE", "the room did not answer")), 1e4);
|
|
1840
|
+
const fail2 = (error) => (clearTimeout(timer), reject(error));
|
|
1841
|
+
socket.addEventListener("message", ({ data }) => {
|
|
1842
|
+
const bytes = new Uint8Array(data);
|
|
1843
|
+
if (bytes[0] === AUTH && bytes[1] === 0) fail2(new CanvasError(who.refusal, "the room refused the update"));
|
|
1844
|
+
else if (bytes[0] === SYNC && bytes[1] === SYNC_STEP2) clearTimeout(timer), resolve();
|
|
1845
|
+
});
|
|
1846
|
+
socket.addEventListener("close", () => fail2(new CanvasError("UNAVAILABLE", "the room closed before it took the update")));
|
|
1847
|
+
socket.send(new Uint8Array([SYNC, SYNC_UPDATE, ...varUint(update.length), ...update]));
|
|
1848
|
+
socket.send(new Uint8Array([SYNC, 0, ...varUint(roomVector.length), ...roomVector]));
|
|
1849
|
+
}).finally(() => socket.close(1e3, "done"));
|
|
1850
|
+
}
|
|
1851
|
+
async function writers(live, id) {
|
|
1852
|
+
const log = await live.attribution(id);
|
|
1853
|
+
if (!log) throw new CanvasError("UNAVAILABLE", "the room has no log of who sent its updates");
|
|
1854
|
+
const ranges = /* @__PURE__ */ new Map();
|
|
1855
|
+
for (const range of log.ranges) ranges.set(range.client, [...ranges.get(range.client) ?? [], range]);
|
|
1856
|
+
const reach = /* @__PURE__ */ new Map();
|
|
1857
|
+
for (const [client, list] of ranges) {
|
|
1858
|
+
list.sort((a, b) => a.clock - b.clock);
|
|
1859
|
+
reach.set(client, list.map((r) => r.clock + r.length));
|
|
1860
|
+
const ends = reach.get(client);
|
|
1861
|
+
for (let i = 1; i < ends.length; i++) ends[i] = Math.max(ends[i], ends[i - 1]);
|
|
1862
|
+
}
|
|
1863
|
+
return ({ client, clock }) => {
|
|
1864
|
+
const list = ranges.get(client) ?? [];
|
|
1865
|
+
let low = 0;
|
|
1866
|
+
let high = list.length;
|
|
1867
|
+
while (low < high) {
|
|
1868
|
+
const mid = low + high >> 1;
|
|
1869
|
+
if (list[mid].clock <= clock) low = mid + 1;
|
|
1870
|
+
else high = mid;
|
|
1871
|
+
}
|
|
1872
|
+
let first;
|
|
1873
|
+
for (let i = low - 1; i >= 0 && reach.get(client)[i] > clock && !first; i--) {
|
|
1874
|
+
const r = list[i];
|
|
1875
|
+
if (clock < r.clock + r.length) first = r;
|
|
1876
|
+
}
|
|
1877
|
+
const who = first ? log.authors[first.author] : void 0;
|
|
1878
|
+
return who && !same(who, SEEDER) ? { id: who.id, kind: who.kind } : void 0;
|
|
1879
|
+
};
|
|
1880
|
+
}
|
|
1881
|
+
async function handSave(live, service, id, update, poster) {
|
|
1882
|
+
const state = await roomState(live, id);
|
|
1883
|
+
const vector = Y.encodeStateVectorFromUpdate(state ?? Y.encodeStateAsUpdate(new Y.Doc()));
|
|
1884
|
+
let missing;
|
|
1885
|
+
try {
|
|
1886
|
+
missing = Y.diffUpdate(update, vector);
|
|
1887
|
+
} catch {
|
|
1888
|
+
throw new CanvasError("INVALID", "the update is not a Yjs update");
|
|
1889
|
+
}
|
|
1890
|
+
const { structs, ds } = Y.decodeUpdate(missing);
|
|
1891
|
+
if (structs.length > 0 || ds.clients.size > 0) await send(live, id, missing, vector, { principal: poster, refusal: "FORBIDDEN" });
|
|
1892
|
+
const saved2 = await pull(live, service, id, true);
|
|
1893
|
+
if (saved2) return saved2;
|
|
1894
|
+
const board = await service.read(id);
|
|
1895
|
+
return { elements: board.nodes.length + board.edges.length + board.groups.length, fixed: [] };
|
|
1896
|
+
}
|
|
1897
|
+
async function roomAuthors(live, id) {
|
|
1898
|
+
return (await live.meta(id)).contributors.filter((c) => !same(c, SEEDER)).map(({ id: who, kind }) => ({ id: who, kind }));
|
|
1899
|
+
}
|
|
1900
|
+
var saved = /* @__PURE__ */ new Map();
|
|
1901
|
+
function remember(id, mark) {
|
|
1902
|
+
if (saved.size > 500) saved.clear();
|
|
1903
|
+
saved.set(id, mark);
|
|
1904
|
+
}
|
|
1905
|
+
async function pull(live, service, id, force = false) {
|
|
1906
|
+
const { updates, compactions } = await live.meta(id);
|
|
1907
|
+
const mark = `${updates}:${compactions}`;
|
|
1908
|
+
if (!force && saved.get(id) === mark) return null;
|
|
1909
|
+
const state = await roomState(live, id);
|
|
1910
|
+
if (!state) return null;
|
|
1911
|
+
let result;
|
|
1912
|
+
try {
|
|
1913
|
+
result = await service.checkpoint(id, state, null, await roomAuthors(live, id), await writers(live, id));
|
|
1914
|
+
} catch (error) {
|
|
1915
|
+
if (error instanceof CanvasError && error.code !== "UNAVAILABLE") remember(id, mark);
|
|
1916
|
+
throw error;
|
|
1917
|
+
}
|
|
1918
|
+
remember(id, mark);
|
|
1919
|
+
return result;
|
|
1920
|
+
}
|
|
1921
|
+
async function seed(live, service, id) {
|
|
1922
|
+
const state = await roomState(live, id);
|
|
1923
|
+
const missing = await service.missingFrom(id, state ?? void 0);
|
|
1924
|
+
if (!missing) return;
|
|
1925
|
+
const known = new Y.Doc();
|
|
1926
|
+
if (state) Y.applyUpdate(known, state);
|
|
1927
|
+
await send(live, id, missing, Y.encodeStateVector(known), { principal: SEEDER, refusal: "UNAVAILABLE" });
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1791
1930
|
// src/worker.ts
|
|
1792
1931
|
var MIN_TOKEN_LENGTH = 32;
|
|
1793
1932
|
var DEFAULT_MAX_IMAGE_BYTES = 1e6;
|
|
@@ -1809,8 +1948,19 @@ function sameText(a, b) {
|
|
|
1809
1948
|
for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0);
|
|
1810
1949
|
return diff === 0;
|
|
1811
1950
|
}
|
|
1951
|
+
var WS_PROTOCOL = "agentic-canvas.bearer";
|
|
1952
|
+
function socketToken(request) {
|
|
1953
|
+
if (request.headers.get("upgrade") !== "websocket") return void 0;
|
|
1954
|
+
const [name, encoded, ...more] = (request.headers.get("sec-websocket-protocol") ?? "").split(",").map((p) => p.trim());
|
|
1955
|
+
if (name !== WS_PROTOCOL || !encoded || more.length > 0 || !/^[A-Za-z0-9_-]+$/.test(encoded)) return void 0;
|
|
1956
|
+
try {
|
|
1957
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(atob(encoded.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)));
|
|
1958
|
+
} catch {
|
|
1959
|
+
return void 0;
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1812
1962
|
function authenticate(request, env) {
|
|
1813
|
-
const token = /^Bearer (.+)$/.exec(request.headers.get("authorization") ?? "")?.[1];
|
|
1963
|
+
const token = /^Bearer (.+)$/.exec(request.headers.get("authorization") ?? "")?.[1] ?? socketToken(request);
|
|
1814
1964
|
if (!token || !env.CANVAS_API_TOKENS) return null;
|
|
1815
1965
|
let grants;
|
|
1816
1966
|
try {
|
|
@@ -1826,7 +1976,8 @@ function authenticate(request, env) {
|
|
|
1826
1976
|
if (typeof found !== "object" || found === null) return null;
|
|
1827
1977
|
const { actor, kind, role = "editor" } = found;
|
|
1828
1978
|
const parsed = principalSchema.safeParse({ kind, id: actor });
|
|
1829
|
-
|
|
1979
|
+
const taken = parsed.success && parsed.data.kind === SEEDER.kind && parsed.data.id === SEEDER.id;
|
|
1980
|
+
return parsed.success && !taken && (role === "editor" || role === "viewer") ? { actor: parsed.data, role } : null;
|
|
1830
1981
|
}
|
|
1831
1982
|
function sniffImage(bytes) {
|
|
1832
1983
|
const at = (offset, ...sig) => sig.every((b, i) => bytes[offset + i] === b);
|
|
@@ -1916,13 +2067,35 @@ function createApp(options = {}) {
|
|
|
1916
2067
|
await next();
|
|
1917
2068
|
});
|
|
1918
2069
|
api.get("/me", (c) => c.json({ actor: c.var.actor.id, kind: c.var.actor.kind, role: c.var.role }));
|
|
2070
|
+
const saveRoom = async (c, seeding) => {
|
|
2071
|
+
if (!options.live) return;
|
|
2072
|
+
const id = c.req.param("id");
|
|
2073
|
+
await c.var.service.assertExists(id);
|
|
2074
|
+
const live = options.live(c.env);
|
|
2075
|
+
await pull(live, c.var.service, id).catch((error) => console.error(JSON.stringify({ level: "error", op: "save-room", message: loggable(error) })));
|
|
2076
|
+
if (seeding) {
|
|
2077
|
+
await seed(live, c.var.service, id).catch((error) => {
|
|
2078
|
+
console.error(JSON.stringify({ level: "error", op: "seed-room", message: loggable(error) }));
|
|
2079
|
+
throw error instanceof CanvasError ? error : new CanvasError("UNAVAILABLE", "the room could not take the stored board. Try again");
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
};
|
|
2083
|
+
const sync = async (c, next) => {
|
|
2084
|
+
if (!/\/(ws|checkpoint)$/.test(new URL(c.req.url).pathname)) await saveRoom(c, false);
|
|
2085
|
+
await next();
|
|
2086
|
+
};
|
|
2087
|
+
api.use("/boards/:id/*", sync);
|
|
1919
2088
|
api.get("/boards/:id/ws", async (c) => {
|
|
1920
2089
|
if (!options.live) return fail("NOT_FOUND", "this server hosts no live rooms", 404);
|
|
1921
2090
|
if (c.req.header("upgrade") !== "websocket") return fail("UPGRADE_REQUIRED", "open this route as a WebSocket", 426);
|
|
1922
2091
|
const id = c.req.param("id");
|
|
1923
|
-
await c
|
|
2092
|
+
await saveRoom(c, true);
|
|
1924
2093
|
const { actor, role } = c.var;
|
|
1925
|
-
|
|
2094
|
+
const opened = await options.live(c.env).connect(id, c.req.raw, { principal: { id: actor.id, kind: actor.kind }, role, name: actor.id });
|
|
2095
|
+
if (opened.status !== 101 || c.req.header("authorization") || !socketToken(c.req.raw)) return opened;
|
|
2096
|
+
const headers = new Headers(opened.headers);
|
|
2097
|
+
headers.set("sec-websocket-protocol", WS_PROTOCOL);
|
|
2098
|
+
return new Response(null, { status: 101, headers, webSocket: opened.webSocket });
|
|
1926
2099
|
});
|
|
1927
2100
|
api.get("/boards/:id/state", async (c) => !options.live ? fail("NOT_FOUND", "this server hosts no live rooms", 404) : c.body(new Uint8Array(await c.var.service.state(c.req.param("id"))), 200, { "content-type": "application/octet-stream" }));
|
|
1928
2101
|
api.post("/boards/:id/checkpoint", async (c) => {
|
|
@@ -1931,9 +2104,9 @@ function createApp(options = {}) {
|
|
|
1931
2104
|
await c.var.service.assertExists(id);
|
|
1932
2105
|
const update = await readLimited(c.req.raw, MAX_LIVE_BYTES);
|
|
1933
2106
|
if (!update) throw new CanvasError("INVALID", `the update must be at most ${MAX_LIVE_BYTES} bytes`);
|
|
1934
|
-
|
|
1935
|
-
return c.json(await c.var.service.checkpoint(id, update, c.var.actor, authors));
|
|
2107
|
+
return c.json(await handSave(options.live(c.env), c.var.service, id, update, c.var.actor));
|
|
1936
2108
|
});
|
|
2109
|
+
api.get("/boards", async (c) => c.json({ boards: await c.var.service.list() }));
|
|
1937
2110
|
api.post("/boards", async (c) => {
|
|
1938
2111
|
const b = await jsonBody(c);
|
|
1939
2112
|
const id = b.id === void 0 ? void 0 : text(b.id, "id");
|
|
@@ -1949,6 +2122,7 @@ function createApp(options = {}) {
|
|
|
1949
2122
|
const b = await jsonBody(c);
|
|
1950
2123
|
return c.json(await c.var.service.move(c.req.param("id"), text(b.element, "element"), num(b.dx, "dx"), num(b.dy, "dy"), c.var.actor));
|
|
1951
2124
|
});
|
|
2125
|
+
api.patch("/boards/:id/elements/:element", async (c) => c.json(await c.var.service.update(c.req.param("id"), c.req.param("element"), await jsonBody(c), c.var.actor)));
|
|
1952
2126
|
api.delete("/boards/:id/elements/:element", async (c) => {
|
|
1953
2127
|
const removed = await c.var.service.remove(c.req.param("id"), c.req.param("element"));
|
|
1954
2128
|
console.log(JSON.stringify({ level: "info", op: "remove", actorKind: c.var.actor.kind }));
|
|
@@ -1998,6 +2172,7 @@ var app = createApp();
|
|
|
1998
2172
|
var worker_default = { fetch: (request, env, ctx) => app.fetch(request, env, ctx) };
|
|
1999
2173
|
|
|
2000
2174
|
export {
|
|
2175
|
+
WS_PROTOCOL,
|
|
2001
2176
|
authenticate,
|
|
2002
2177
|
sniffImage,
|
|
2003
2178
|
createApp,
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
principalSchema,
|
|
6
6
|
readBoard,
|
|
7
7
|
toSvg
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-JZA525X5.js";
|
|
9
9
|
import {
|
|
10
10
|
FileStoreError
|
|
11
11
|
} from "./chunk-NWQED42L.js";
|
|
@@ -21,6 +21,7 @@ function toBase64(bytes) {
|
|
|
21
21
|
for (let i = 0; i < bytes.length; i += 8192) text += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
|
22
22
|
return btoa(text);
|
|
23
23
|
}
|
|
24
|
+
var sameBytes = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
|
|
24
25
|
var fromBase64 = (text) => Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
|
|
25
26
|
function boardKey(id) {
|
|
26
27
|
try {
|
|
@@ -51,6 +52,17 @@ var CanvasService = class {
|
|
|
51
52
|
return { id, title: board.title };
|
|
52
53
|
});
|
|
53
54
|
}
|
|
55
|
+
/** The first 100 boards (the store sorts by id), with titles. A board whose stored bytes are damaged shows an empty title: it stays in the list so a person can find and repair it. */
|
|
56
|
+
async list() {
|
|
57
|
+
const page = await this.store.query(COLLECTION, { limit: 100 }).catch(storeFailure);
|
|
58
|
+
return page.items.map(({ id, data }) => {
|
|
59
|
+
try {
|
|
60
|
+
return { id, title: decodeStored(data, id, Board.decode).title };
|
|
61
|
+
} catch {
|
|
62
|
+
return { id, title: "" };
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
54
66
|
/** Throws NOT_FOUND unless the board exists. A live room opens only for a board that exists. */
|
|
55
67
|
async assertExists(id) {
|
|
56
68
|
boardKey(id);
|
|
@@ -76,6 +88,10 @@ var CanvasService = class {
|
|
|
76
88
|
connect(id, connector, actor) {
|
|
77
89
|
return this.change(id, (b) => b.addConnector(connector, actor));
|
|
78
90
|
}
|
|
91
|
+
/** Change fields of one shape or connector: position, size, text, colors. */
|
|
92
|
+
update(id, elementId, patch, actor) {
|
|
93
|
+
return this.change(id, (b) => b.update(elementId, patch, actor));
|
|
94
|
+
}
|
|
79
95
|
move(id, elementId, dx, dy, actor) {
|
|
80
96
|
return this.change(id, (b) => ({ moved: b.move(elementId, dx, dy, actor) }));
|
|
81
97
|
}
|
|
@@ -94,22 +110,37 @@ var CanvasService = class {
|
|
|
94
110
|
boardKey(id);
|
|
95
111
|
return this.store.transaction(async (tx) => (await this.load(tx.get(COLLECTION, id), id)).encode());
|
|
96
112
|
}
|
|
113
|
+
/** What the stored board has that a room with state `room` lacks, as a Yjs update, or null when the room has all of it. */
|
|
114
|
+
async missingFrom(id, room) {
|
|
115
|
+
boardKey(id);
|
|
116
|
+
const known = new Y.Doc();
|
|
117
|
+
if (room) Y.applyUpdate(known, room);
|
|
118
|
+
const diff = await this.store.transaction(async (tx) => Y.encodeStateAsUpdate((await this.load(tx.get(COLLECTION, id), id)).doc, Y.encodeStateVector(known)));
|
|
119
|
+
const before = Y.encodeStateAsUpdate(known);
|
|
120
|
+
Y.applyUpdate(known, diff);
|
|
121
|
+
return Y.decodeUpdate(diff).structs.length === 0 && sameBytes(before, Y.encodeStateAsUpdate(known)) ? null : diff;
|
|
122
|
+
}
|
|
97
123
|
/**
|
|
98
|
-
*
|
|
124
|
+
* `writer` names who sent each Yjs item, from the room's log. It sets the stamps where it knows (see `Board.restamp`).
|
|
125
|
+
* Merge a live room's state into the stored board. `authors` are the principals the room saw send updates. A null
|
|
126
|
+
* `actor` trusts only them: the Worker saves a room that way, with nobody present to vouch.
|
|
99
127
|
* An element stamped by anyone else is deleted, and a changed author is put back (see `Board.restamp`),
|
|
100
128
|
* so a client that writes raw Yjs cannot put another author on its work. Broken entries are repaired and
|
|
101
129
|
* a board past the element cap is refused. Returns the ids the checks fixed.
|
|
102
130
|
*
|
|
103
131
|
* Limits: the save corrects the stored board only. The room keeps what its clients wrote, so a client that sees
|
|
104
132
|
* a non-empty `fixed` must join again, or its later edits are lost at the next save. The room remembers its
|
|
105
|
-
* authors for good, so
|
|
133
|
+
* authors for good, so an OLD item that the log does not name (a seeded one, or a range the log dropped) keeps the older rule: its
|
|
134
|
+
* stamp may name anyone who once sent an update in that room. A NEW item with no writer is deleted or undone.
|
|
135
|
+
* The log records who adds an item, not who deletes a key: a deleted optional key is credited to the writer of the editor
|
|
136
|
+
* stamp written in the same save, or undone if there is none.
|
|
106
137
|
* A client that adds a root type of its own makes every later save of that room fail, because the room keeps it.
|
|
107
138
|
*/
|
|
108
|
-
async checkpoint(id, update, actor, authors = []) {
|
|
139
|
+
async checkpoint(id, update, actor, authors = [], writer) {
|
|
109
140
|
boardKey(id);
|
|
110
|
-
const by = principalSchema.safeParse(actor);
|
|
111
|
-
if (!by.success) throw new CanvasError("INVALID", "the actor must be a valid principal");
|
|
112
|
-
const trusted = (who) => [by.data, ...authors].some((a) => a.kind === who.kind && a.id === who.id);
|
|
141
|
+
const by = actor === null ? null : principalSchema.safeParse(actor);
|
|
142
|
+
if (by && !by.success) throw new CanvasError("INVALID", "the actor must be a valid principal");
|
|
143
|
+
const trusted = (who) => [...by?.success ? [by.data] : [], ...authors].some((a) => a.kind === who.kind && a.id === who.id);
|
|
113
144
|
return this.store.transaction(async (tx) => {
|
|
114
145
|
const prior = await this.load(tx.get(COLLECTION, id), id);
|
|
115
146
|
const next = prior.clone();
|
|
@@ -121,7 +152,7 @@ var CanvasService = class {
|
|
|
121
152
|
if (next.doc.store.pendingStructs || next.doc.store.pendingDs) throw new CanvasError("INVALID", "the update is not complete: it needs changes that the board does not have");
|
|
122
153
|
const strange = [...next.doc.share.keys()].filter((key) => key !== "elements" && key !== "meta");
|
|
123
154
|
if (strange.length > 0) throw new CanvasError("INVALID", "the update holds data that is not part of a board");
|
|
124
|
-
const fixed = /* @__PURE__ */ new Set([...next.restamp(prior, trusted), ...next.repair()]);
|
|
155
|
+
const fixed = /* @__PURE__ */ new Set([...next.restamp(prior, trusted, writer), ...next.repair()]);
|
|
125
156
|
const saved = next.encode();
|
|
126
157
|
if (saved.length > MAX_LIVE_BYTES) throw new CanvasError("LIMIT", `the board would hold over ${MAX_LIVE_BYTES} bytes`);
|
|
127
158
|
await tx.put(COLLECTION, id, { update: toBase64(saved) });
|
|
@@ -184,12 +184,58 @@ var Board = class _Board {
|
|
|
184
184
|
* author, and a changed one needs a trusted editor or it goes back to its prior state. An editor stamp changed alone
|
|
185
185
|
* to an untrusted principal is put back. Unknown keys, nested Yjs types, and bad or foreign title values are deleted
|
|
186
186
|
* or undone. Returns the ids it deleted or restored.
|
|
187
|
+
*
|
|
188
|
+
* `writer` says who sent the update that holds a Yjs item, or undefined when it does not know. Where it knows, it
|
|
189
|
+
* decides: a new element gets its creator as author and editor, and an old one gets the sender of its changes as editor.
|
|
190
|
+
* A client cannot claim another principal, and an agent cannot pass as a person. A new item that the log lacks has no
|
|
191
|
+
* sender: it is deleted or undone. Only an old item falls back to `trusted`. A hand save names its poster as the sender
|
|
192
|
+
* of every item that the room does not hold, in `writer`.
|
|
187
193
|
*/
|
|
188
|
-
restamp(prior, trusted) {
|
|
194
|
+
restamp(prior, trusted, writer) {
|
|
189
195
|
const before = new Map(prior.allStored().map((e) => [e.id, e]));
|
|
190
196
|
const body = ({ author: _a, editedBy: _e, ...rest }) => JSON.stringify(rest);
|
|
191
197
|
const fixed = [];
|
|
198
|
+
const isNew = (item) => item.clock >= Y.getState(prior.doc.store, item.client);
|
|
199
|
+
const who = (item) => writer?.(item);
|
|
200
|
+
const pick = (writers, stamped) => writers.find((w) => isMachineMade(w.kind)) ?? (stamped && (writers.length === 0 || writers.some((w) => samePrincipal(w, stamped))) ? stamped : writers[0]);
|
|
192
201
|
this.doc.transact(() => {
|
|
202
|
+
for (const el of writer ? this.allStored() : []) {
|
|
203
|
+
const entry = this.elements.get(el.id);
|
|
204
|
+
const was = prior.elements.get(el.id);
|
|
205
|
+
const by2 = (key) => {
|
|
206
|
+
const item = entry._map.get(key);
|
|
207
|
+
return item && !item.deleted ? who(item.id) : void 0;
|
|
208
|
+
};
|
|
209
|
+
if (!was) {
|
|
210
|
+
const creator = entry._item ? who(entry._item.id) : void 0;
|
|
211
|
+
const writers2 = [...entry.keys()].filter((k) => k !== "author" && k !== "editedBy").map(by2);
|
|
212
|
+
if (!creator || writers2.some((w) => !w)) this.elements.delete(el.id);
|
|
213
|
+
else {
|
|
214
|
+
const editor2 = pick(writers2, by2("editedBy")) ?? creator;
|
|
215
|
+
if (samePrincipal(el.author, creator) && samePrincipal(el.editedBy, editor2)) continue;
|
|
216
|
+
entry.set("author", creator);
|
|
217
|
+
entry.set("editedBy", editor2);
|
|
218
|
+
}
|
|
219
|
+
fixed.push(el.id);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const raw = (map, key) => JSON.stringify(map.get(key));
|
|
223
|
+
const changed = [.../* @__PURE__ */ new Set([...entry.keys(), ...was.keys()])].filter((k) => k !== "author" && k !== "editedBy" && raw(entry, k) !== raw(was, k));
|
|
224
|
+
const found = entry._map.get("editedBy");
|
|
225
|
+
const stamp = found?.deleted ? void 0 : found;
|
|
226
|
+
const stamped = stamp && isNew(stamp.id) ? who(stamp.id) : void 0;
|
|
227
|
+
const writers = changed.map((k) => entry.has(k) ? by2(k) : stamped);
|
|
228
|
+
if (writers.some((w) => !w) || stamp && isNew(stamp.id) && !stamped) {
|
|
229
|
+
this.elements.set(el.id, new Y.Map([...was.entries()]));
|
|
230
|
+
fixed.push(el.id);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const editor = pick(writers, stamped);
|
|
234
|
+
if (editor && !samePrincipal(el.editedBy, editor)) {
|
|
235
|
+
entry.set("editedBy", editor);
|
|
236
|
+
fixed.push(el.id);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
193
239
|
for (const el of this.allStored()) {
|
|
194
240
|
const old = before.get(el.id);
|
|
195
241
|
if (!old) {
|
|
@@ -216,9 +262,22 @@ var Board = class _Board {
|
|
|
216
262
|
if (extra.length > 0 && !fixed.includes(el.id)) fixed.push(el.id);
|
|
217
263
|
}
|
|
218
264
|
const same = (key) => !(this.meta.get(key) instanceof Y.AbstractType) && JSON.stringify(this.meta.get(key)) === JSON.stringify(prior.meta.get(key));
|
|
265
|
+
let titleBroken = false;
|
|
266
|
+
if (writer) {
|
|
267
|
+
const found = this.meta._map.get("title");
|
|
268
|
+
const titleItem = found?.deleted ? void 0 : found;
|
|
269
|
+
const byItem = this.meta._map.get("titleBy");
|
|
270
|
+
const written = [titleItem, byItem?.deleted ? void 0 : byItem].filter((item) => item !== void 0 && isNew(item.id)).map((item) => who(item.id));
|
|
271
|
+
titleBroken = !titleItem && prior.meta.has("title") || written.some((w) => !w);
|
|
272
|
+
const titleWriter = titleBroken ? void 0 : pick(written);
|
|
273
|
+
if (titleWriter && this.titleBy && !samePrincipal(this.titleBy, titleWriter)) {
|
|
274
|
+
this.meta.set("titleBy", titleWriter);
|
|
275
|
+
fixed.push("title");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
219
278
|
const by = this.titleBy;
|
|
220
279
|
const title = this.meta.get("title");
|
|
221
|
-
const honest = by !== void 0 && trusted(by) && (title === void 0 || titleSchema.safeParse(title).success);
|
|
280
|
+
const honest = !titleBroken && by !== void 0 && trusted(by) && (title === void 0 || titleSchema.safeParse(title).success);
|
|
222
281
|
if (!honest && !(same("title") && same("titleBy"))) {
|
|
223
282
|
for (const key of ["title", "titleBy"]) {
|
|
224
283
|
const old = prior.meta.get(key);
|
|
@@ -3,11 +3,11 @@ import {
|
|
|
3
3
|
PRINCIPAL_KINDS,
|
|
4
4
|
SHAPE_TYPES,
|
|
5
5
|
principalSchema
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-JZA525X5.js";
|
|
7
7
|
|
|
8
8
|
// src/version.ts
|
|
9
9
|
import { createRequire } from "module";
|
|
10
|
-
var VERSION = true ? "0.
|
|
10
|
+
var VERSION = true ? "0.4.0" : readPackageVersion();
|
|
11
11
|
|
|
12
12
|
// src/brand.ts
|
|
13
13
|
var BRAND = {
|
|
@@ -91,6 +91,11 @@ function createMcpServer(service, actor) {
|
|
|
91
91
|
{ description: "Make a board.", inputSchema: { id: z.string().optional(), title: z.string().optional() } },
|
|
92
92
|
tool(async (a) => json(await service.create(actor, a)))
|
|
93
93
|
);
|
|
94
|
+
server.registerTool(
|
|
95
|
+
"list_boards",
|
|
96
|
+
{ description: "List the first 100 boards with their titles.", inputSchema: {}, annotations: read },
|
|
97
|
+
tool(async () => json({ boards: await service.list() }))
|
|
98
|
+
);
|
|
94
99
|
server.registerTool(
|
|
95
100
|
"read_board",
|
|
96
101
|
{ description: "Read a board as data: nodes, edges, groups, text, and who made what.", inputSchema: { board }, annotations: read },
|
|
@@ -121,6 +126,14 @@ function createMcpServer(service, actor) {
|
|
|
121
126
|
{ description: "Join two shapes with an arrow.", inputSchema: { board, from: z.string(), to: z.string(), id: z.string().optional(), text: z.string().optional() } },
|
|
122
127
|
tool(async (a) => json(await service.connect(a.board, { id: a.id, from: { id: a.from }, to: { id: a.to }, text: a.text }, actor)))
|
|
123
128
|
);
|
|
129
|
+
server.registerTool(
|
|
130
|
+
"update_element",
|
|
131
|
+
{
|
|
132
|
+
description: "Change fields of a shape (x, y, w, h, text, fill, stroke) or a connector. Resize with w and h.",
|
|
133
|
+
inputSchema: { board, element: z.string(), x: num.optional(), y: num.optional(), w: num.optional(), h: num.optional(), text: z.string().optional(), fill: z.string().optional(), stroke: z.string().optional() }
|
|
134
|
+
},
|
|
135
|
+
tool(async ({ board: b, element, ...patch }) => json(await service.update(b, element, patch, actor)))
|
|
136
|
+
);
|
|
124
137
|
server.registerTool(
|
|
125
138
|
"move",
|
|
126
139
|
{ description: "Move an element, or a whole group.", inputSchema: { board, element: z.string(), dx: num, dy: num } },
|