agentic-canvas 0.3.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 CHANGED
@@ -7,9 +7,9 @@
7
7
 
8
8
  A shared whiteboard for diagrams, drawn by people and agents.
9
9
 
10
- > Status: 0.3.0. The board model, the agent operations, the exports, the CLI, the
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. A remote client calls the Worker API. The web UI is not built yet.
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.
@@ -104,13 +104,91 @@ try {
104
104
  }
105
105
  ```
106
106
 
107
- There is one method per route: `me`, `createBoard`, `readBoard`, `exportSvg`, `addShape`, `addConnector`,
108
- `drawFlow`, `move`, `remove`, `uploadImage`, `getImage`, `state`, and `checkpoint`. Each takes
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
109
  `{ signal }` last, so an `AbortSignal` can stop a call. The token sets the actor. A failed call throws
110
110
  `CanvasApiError` with the API `code` and the HTTP `status`. A call that gets no reply has code `NETWORK`
111
111
  and status 0. The client never retries. A 503 means "try again", and the caller decides, because a
112
112
  repeated write can change a board twice. Pass `fetch` to use your own.
113
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
+
114
192
  ## Features
115
193
 
116
194
  - Shapes: rectangle, ellipse, diamond, text, sticky note, frame, image.
@@ -132,23 +210,28 @@ repeated write can change a board twice. Pass `fetch` to use your own.
132
210
  (EU jurisdiction), through the shared `Store` and `Blobs` ports. Set `CANVAS_API_TOKENS`
133
211
  (a JSON map of token to `{ actor, kind }`, tokens of 32 characters or more) and `CANVAS_REGION`.
134
212
  Routes: `POST /boards`, `GET /boards/:id`, `GET /boards/:id/svg`, `POST /boards/:id/shapes`,
135
- `/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`,
136
214
  `GET /images/:id`. An image is stored under the SHA-256 of its bytes, and the reply gives
137
215
  the `blob:` ref for a shape. A busy database or bucket answers 503: try again.
138
216
  - Live rooms (`agentic-canvas/live-worker`, on `agentic-realtime`). One Durable Object holds one
139
217
  board. A token may carry `"role": "viewer"`: a viewer reads and cannot write. `GET /boards/:id/ws`
140
- opens a room, `GET /me` says who a token is, `GET /boards/:id/state` and
141
- `POST /boards/:id/checkpoint` seed a room from the stored board and save it back, as a merge. A save
142
- checks author stamps: a client cannot put another author on its work.
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.
143
226
  - The SDK joins a room: `import { LiveBoard, LiveService } from "agentic-canvas/live"`. The CLI and the MCP
144
227
  server join when `CANVAS_SERVER` and `CANVAS_TOKEN` are set. The token sets the actor, so
145
228
  `CANVAS_ACTOR` is not needed. `--dir`, `CANVAS_DIR`, and `repair` stay local.
146
229
  - Yjs underneath: two copies of a board merge without conflicts.
147
- - No UI code. The model is plain TypeScript on Yjs, with no outbound calls.
230
+ - The board model has no UI code. It is plain TypeScript on Yjs, with no outbound calls.
148
231
 
149
232
  ## Limits
150
233
 
151
- - Canvas takes principal kinds, regions, and the Store and Blobs ports from `agentic-identity` 0.4.
234
+ - Canvas takes principal kinds, regions, and the Store and Blobs ports from `agentic-identity` 0.5.
152
235
  It does not use the sharing API yet. Its editor and viewer tokens are a separate table in
153
236
  `CANVAS_API_TOKENS`. It has no board delete or erase. When it adopts sharing, grants are keyed
154
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-W7NRP3EX.js";
4
+ } from "./chunk-J52RPFRP.js";
5
5
  import {
6
6
  CanvasError,
7
7
  principalSchema
8
- } from "./chunk-GGQPE65T.js";
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
- return parsed.success && (role === "editor" || role === "viewer") ? { actor: parsed.data, role } : null;
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.var.service.assertExists(id);
2092
+ await saveRoom(c, true);
1924
2093
  const { actor, role } = c.var;
1925
- return options.live(c.env).connect(id, c.req.raw, { principal: { id: actor.id, kind: actor.kind }, role, name: actor.id });
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
- const authors = (await options.live(c.env).meta(id)).contributors.map(({ id: who, kind }) => ({ id: who, kind }));
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-GGQPE65T.js";
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
- * Merge a live room's state into the stored board. `authors` are the principals the room saw send updates.
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 a client may stamp an element with the name of anyone who once sent an update in that room.
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-GGQPE65T.js";
6
+ } from "./chunk-JZA525X5.js";
7
7
 
8
8
  // src/version.ts
9
9
  import { createRequire } from "module";
10
- var VERSION = true ? "0.3.0" : readPackageVersion();
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 } },
package/dist/cli.js CHANGED
@@ -5,16 +5,16 @@ import {
5
5
  operatorActor,
6
6
  runMcp,
7
7
  svgToPng
8
- } from "./chunk-AGODNBN7.js";
8
+ } from "./chunk-TSWXD4YG.js";
9
9
  import {
10
10
  openLocalStore
11
11
  } from "./chunk-UKST5A57.js";
12
12
  import {
13
13
  CanvasService
14
- } from "./chunk-W7NRP3EX.js";
14
+ } from "./chunk-J52RPFRP.js";
15
15
  import {
16
16
  CanvasError
17
- } from "./chunk-GGQPE65T.js";
17
+ } from "./chunk-JZA525X5.js";
18
18
  import "./chunk-NWQED42L.js";
19
19
 
20
20
  // src/cli.ts
@@ -29,9 +29,11 @@ Usage: ${BRAND.command} [--dir <path>] <command> [flags]
29
29
 
30
30
  Commands:
31
31
  new [--id <id>] [--title <t>] Make a board.
32
+ list List the boards with their titles.
32
33
  read <board> Print the board as JSON.
33
34
  add <board> --type <t> --x <n> --y <n> Add a shape. Also: --w --h --text --fill --stroke --src --id.
34
35
  connect <board> <from> <to> [--text <t>] Join two shapes with an arrow.
36
+ update <board> <element> [--x --y --w --h --text --fill --stroke] Change fields of an element. Resize with --w and --h.
35
37
  move <board> <element> --dx <n> --dy <n> Move an element or a group.
36
38
  rm <board> <element> Delete an element.
37
39
  flow <board> --text <t> | --file <p> Draw a flowchart from text ("A -> B: label"). Use --file - for stdin.
@@ -118,6 +120,9 @@ async function run(argv) {
118
120
  case "new":
119
121
  need(0, "[--id <id>] [--title <t>]");
120
122
  return print(await service.create(actor(), { id: v.id, title: v.title }));
123
+ case "list":
124
+ need(0, "");
125
+ return print({ boards: await service.list() });
121
126
  case "read":
122
127
  need(1, "<board>");
123
128
  return print(await service.read(board));
@@ -127,6 +132,11 @@ async function run(argv) {
127
132
  case "connect":
128
133
  need(3, "<board> <from> <to> [--text <t>]");
129
134
  return print(await service.connect(board, { id: v.id, from: { id: rest[0] }, to: { id: rest[1] }, text: v.text }, actor()));
135
+ case "update": {
136
+ need(2, "<board> <element> [--x <n>] [--y <n>] [--w <n>] [--h <n>] [--text <t>] [--fill <c>] [--stroke <c>]");
137
+ const patch = Object.fromEntries(Object.entries({ x: num(v.x), y: num(v.y), w: num(v.w), h: num(v.h), text: v.text, fill: v.fill, stroke: v.stroke }).filter(([, value]) => value !== void 0));
138
+ return print(await service.update(board, rest[0], patch, actor()));
139
+ }
130
140
  case "move":
131
141
  need(2, "<board> <element> --dx <n> --dy <n>");
132
142
  return print(await service.move(board, rest[0], num(v.dx) ?? Number.NaN, num(v.dy) ?? Number.NaN, actor()));
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { E as ErrorCode, B as BoardData, S as ShapeType, a as Shape, b as Endpoint, C as Connector } from './read-BY6f-mmy.js';
1
+ import { E as ErrorCode, B as BoardData, S as ShapeType, a as Shape, b as Endpoint, C as Connector } from './read-Bdg1uiFC.js';
2
2
  import { PrincipalKind } from 'agentic-identity';
3
3
  import 'yjs';
4
4
  import 'zod';
@@ -37,6 +37,8 @@ interface ConnectorInput {
37
37
  endArrow?: boolean;
38
38
  stroke?: string;
39
39
  }
40
+ type ShapePatch = Partial<Pick<ShapeInput, "x" | "y" | "w" | "h" | "text" | "fill" | "stroke" | "src">>;
41
+ type ConnectorPatch = Partial<Pick<ConnectorInput, "from" | "to" | "text" | "startArrow" | "endArrow" | "stroke">>;
40
42
  interface ImageInfo {
41
43
  id: string;
42
44
  ref: string;
@@ -62,6 +64,13 @@ interface CanvasClient {
62
64
  kind: PrincipalKind;
63
65
  role: "editor" | "viewer";
64
66
  }>;
67
+ /** The first 100 boards, with titles. */
68
+ listBoards(options?: CallOptions): Promise<{
69
+ boards: {
70
+ id: string;
71
+ title: string;
72
+ }[];
73
+ }>;
65
74
  createBoard(input?: {
66
75
  id?: string;
67
76
  title?: string;
@@ -81,6 +90,8 @@ interface CanvasClient {
81
90
  nodes: string[];
82
91
  edges: string[];
83
92
  }>;
93
+ /** Change fields of one shape or connector, such as its size or text. The patch holds at least one field. */
94
+ update(id: string, element: string, patch: ShapePatch | ConnectorPatch, options?: CallOptions): Promise<Shape | Connector>;
84
95
  /** Move an element. Returns the ids that moved: a shape takes its connectors with it. */
85
96
  move(id: string, element: string, dx: number, dy: number, options?: CallOptions): Promise<{
86
97
  moved: string[];
@@ -105,4 +116,4 @@ interface CanvasClient {
105
116
  }
106
117
  declare function createClient(options: ClientOptions): CanvasClient;
107
118
 
108
- export { type ApiErrorCode, type CallOptions, CanvasApiError, type CanvasClient, type ClientOptions, type ConnectorInput, type ImageInfo, type ShapeInput, createClient };
119
+ export { type ApiErrorCode, type CallOptions, CanvasApiError, type CanvasClient, type ClientOptions, type ConnectorInput, type ConnectorPatch, type ImageInfo, type ShapeInput, type ShapePatch, createClient };
package/dist/client.js CHANGED
@@ -55,12 +55,14 @@ function createClient(options) {
55
55
  const bytes = async (response) => new Uint8Array(await response.arrayBuffer());
56
56
  return {
57
57
  me: (o) => json("GET", "/me", void 0, o),
58
+ listBoards: (o) => json("GET", "/boards", void 0, o),
58
59
  createBoard: (input = {}, o) => json("POST", "/boards", input, o),
59
60
  readBoard: (id, o) => json("GET", `/boards/${seg(id)}`, void 0, o),
60
61
  exportSvg: async (id, o) => (await send("GET", `/boards/${seg(id)}/svg`, {}, o)).text(),
61
62
  addShape: (id, shape, o) => json("POST", `/boards/${seg(id)}/shapes`, shape, o),
62
63
  addConnector: (id, connector, o) => json("POST", `/boards/${seg(id)}/connectors`, connector, o),
63
64
  drawFlow: (id, text, o) => json("POST", `/boards/${seg(id)}/flow`, { text }, o),
65
+ update: (id, element, patch, o) => json("PATCH", `/boards/${seg(id)}/elements/${seg(element)}`, patch, o),
64
66
  move: (id, element, dx, dy, o) => json("POST", `/boards/${seg(id)}/move`, { element, dx, dy }, o),
65
67
  remove: (id, element, o) => json("DELETE", `/boards/${seg(id)}/elements/${seg(element)}`, void 0, o),
66
68
  uploadImage: async (data, o) => parse(await send("POST", "/images", { body: data, contentType: "application/octet-stream" }, o), o),
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { c as Board, P as Principal } from './read-BY6f-mmy.js';
2
- export { B as BoardData, d as CanvasElement, e as CanvasError, C as Connector, f as EdgeData, b as Endpoint, E as ErrorCode, G as Group, M as MAX_ELEMENTS, g as MAX_TEXT, N as NodeData, h as SHAPE_TYPES, a as Shape, S as ShapeType, p as principalSchema, r as readBoard } from './read-BY6f-mmy.js';
3
- import { B as BoardAccess } from './service-Cpl6wKeY.js';
4
- export { C as CanvasService, D as DrawOptions, F as FlowEdge, a as FlowGraph, b as FlowNode, M as MAX_FLOW_EDGES, c as MAX_FLOW_NODES, P as Placement, d as drawFlow, l as layoutFlow, p as parseFlow } from './service-Cpl6wKeY.js';
1
+ import { c as Board, P as Principal } from './read-Bdg1uiFC.js';
2
+ export { B as BoardData, d as CanvasElement, e as CanvasError, C as Connector, f as EdgeData, b as Endpoint, E as ErrorCode, G as Group, M as MAX_ELEMENTS, g as MAX_TEXT, N as NodeData, h as SHAPE_TYPES, a as Shape, S as ShapeType, p as principalSchema, r as readBoard } from './read-Bdg1uiFC.js';
3
+ import { B as BoardAccess } from './service-Cim7TWnJ.js';
4
+ export { C as CanvasService, D as DrawOptions, F as FlowEdge, a as FlowGraph, b as FlowNode, M as MAX_FLOW_EDGES, c as MAX_FLOW_NODES, P as Placement, d as drawFlow, l as layoutFlow, p as parseFlow } from './service-Cim7TWnJ.js';
5
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  export { FileStoreOptions, createFileStore, openLocalStore } from './store-file.js';
7
7
  export { PRINCIPAL_KINDS, PrincipalKind, isMachineMade } from 'agentic-identity';
package/dist/index.js CHANGED
@@ -6,14 +6,14 @@ import {
6
6
  operatorActor,
7
7
  runMcp,
8
8
  svgToPng
9
- } from "./chunk-AGODNBN7.js";
9
+ } from "./chunk-TSWXD4YG.js";
10
10
  import {
11
11
  createFileStore,
12
12
  openLocalStore
13
13
  } from "./chunk-UKST5A57.js";
14
14
  import {
15
15
  CanvasService
16
- } from "./chunk-W7NRP3EX.js";
16
+ } from "./chunk-J52RPFRP.js";
17
17
  import {
18
18
  Board,
19
19
  CanvasError,
@@ -30,7 +30,7 @@ import {
30
30
  principalSchema,
31
31
  readBoard,
32
32
  toSvg
33
- } from "./chunk-GGQPE65T.js";
33
+ } from "./chunk-JZA525X5.js";
34
34
  import "./chunk-NWQED42L.js";
35
35
  export {
36
36
  ActorConfigError,
@@ -5,10 +5,10 @@ import 'agentic-identity';
5
5
  import 'agentic-realtime';
6
6
  import 'agentic-identity/cloudflare';
7
7
  import 'hono';
8
- import './read-BY6f-mmy.js';
8
+ import './read-Bdg1uiFC.js';
9
9
  import 'yjs';
10
10
  import 'zod';
11
- import './service-Cpl6wKeY.js';
11
+ import './service-Cim7TWnJ.js';
12
12
 
13
13
  type Namespace = Parameters<typeof createDurableObjectRealtime>[0];
14
14
  interface LiveEnv extends WorkerEnv {
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  createApp
3
- } from "./chunk-V477KSZQ.js";
4
- import "./chunk-W7NRP3EX.js";
3
+ } from "./chunk-HPUYT5TC.js";
4
+ import "./chunk-J52RPFRP.js";
5
5
  import {
6
6
  CanvasError
7
- } from "./chunk-GGQPE65T.js";
7
+ } from "./chunk-JZA525X5.js";
8
8
  import "./chunk-NWQED42L.js";
9
9
 
10
10
  // src/live-worker.ts
package/dist/live.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { c as Board, P as Principal, B as BoardData, a as Shape, C as Connector } from './read-BY6f-mmy.js';
1
+ import { c as Board, P as Principal, B as BoardData, a as Shape, C as Connector, d as CanvasElement } from './read-Bdg1uiFC.js';
2
2
  import { Peer } from 'agentic-realtime/client';
3
- import { B as BoardAccess, D as DrawOptions } from './service-Cpl6wKeY.js';
3
+ import { B as BoardAccess, D as DrawOptions } from './service-Cim7TWnJ.js';
4
4
  import 'yjs';
5
5
  import 'zod';
6
6
  import 'agentic-identity';
@@ -79,12 +79,17 @@ declare class LiveService implements BoardAccess {
79
79
  id: string;
80
80
  title: string;
81
81
  }>;
82
+ list(): Promise<{
83
+ id: string;
84
+ title: string;
85
+ }[]>;
82
86
  read(id: string): Promise<BoardData & {
83
87
  id: string;
84
88
  }>;
85
89
  exportSvg(id: string): Promise<string>;
86
90
  add(id: string, shape: unknown, _actor?: Principal): Promise<Shape>;
87
91
  connect(id: string, connector: unknown, _actor?: Principal): Promise<Connector>;
92
+ update(id: string, element: string, patch: unknown, _actor?: Principal): Promise<CanvasElement>;
88
93
  move(id: string, element: string, dx: number, dy: number, _actor?: Principal): Promise<{
89
94
  moved: string[];
90
95
  }>;
package/dist/live.js CHANGED
@@ -5,11 +5,10 @@ import {
5
5
  principalSchema,
6
6
  readBoard,
7
7
  toSvg
8
- } from "./chunk-GGQPE65T.js";
8
+ } from "./chunk-JZA525X5.js";
9
9
 
10
10
  // src/live.ts
11
11
  import { SyncClient } from "agentic-realtime/client";
12
- import * as Y from "yjs";
13
12
  var CODES = ["INVALID", "DUPLICATE_ID", "NOT_FOUND", "BAD_REFERENCE", "LIMIT", "UNAVAILABLE", "FORBIDDEN"];
14
13
  var base = (server) => server.replace(/\/$/, "") + "/api/v1/canvas";
15
14
  async function refusal(response, fallback) {
@@ -61,7 +60,7 @@ var LiveBoard = class _LiveBoard {
61
60
  const id = encodeURIComponent(options.board);
62
61
  const stored = await call(options.server, options.token, `/boards/${id}/state`);
63
62
  if (!stored.ok) throw await refusal(stored, "could not read the stored board");
64
- const state = new Uint8Array(await stored.arrayBuffer());
63
+ await stored.body?.cancel();
65
64
  const url = new URL(`${base(options.server)}/boards/${id}/ws`);
66
65
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
67
66
  let client;
@@ -70,14 +69,6 @@ var LiveBoard = class _LiveBoard {
70
69
  } catch (error) {
71
70
  throw unreachable(error);
72
71
  }
73
- try {
74
- Y.applyUpdate(client.doc, state);
75
- await client.flush();
76
- if (me.role === "editor" && client.denied.length > 0) throw new CanvasError("FORBIDDEN", `the room refused the stored board: ${client.denied[0]}`);
77
- } catch (error) {
78
- await client.disconnect();
79
- throw unreachable(error);
80
- }
81
72
  return new _LiveBoard(new Board(client.doc), me, client, options.board, options, client.denied.length);
82
73
  }
83
74
  /** The other people and agents in the room. `machine` is true for an agent. */
@@ -142,6 +133,13 @@ var LiveService = class {
142
133
  if (typeof id !== "string" || typeof title !== "string") throw new CanvasError("INVALID", "the server sent a board this client does not know");
143
134
  return { id, title };
144
135
  }
136
+ async list() {
137
+ const response = await call(this.options.server, this.options.token, "/boards");
138
+ if (!response.ok) throw await refusal(response, "the server refused the request");
139
+ const { boards } = await json(response);
140
+ if (!Array.isArray(boards) || !boards.every((b) => typeof b?.id === "string" && typeof b?.title === "string")) throw new CanvasError("INVALID", "the server sent a board list this client does not know");
141
+ return boards;
142
+ }
145
143
  async read(id) {
146
144
  return this.use(id, async (live) => ({ id, ...readBoard(live.board) }));
147
145
  }
@@ -154,6 +152,9 @@ var LiveService = class {
154
152
  connect(id, connector, _actor) {
155
153
  return this.write(id, (board, actor) => board.addConnector(connector, actor));
156
154
  }
155
+ update(id, element, patch, _actor) {
156
+ return this.write(id, (board, actor) => board.update(element, patch, actor));
157
+ }
157
158
  move(id, element, dx, dy, _actor) {
158
159
  return this.write(id, (board, actor) => ({ moved: board.move(element, dx, dy, actor) }));
159
160
  }
@@ -108,8 +108,17 @@ declare class Board {
108
108
  * author, and a changed one needs a trusted editor or it goes back to its prior state. An editor stamp changed alone
109
109
  * to an untrusted principal is put back. Unknown keys, nested Yjs types, and bad or foreign title values are deleted
110
110
  * or undone. Returns the ids it deleted or restored.
111
+ *
112
+ * `writer` says who sent the update that holds a Yjs item, or undefined when it does not know. Where it knows, it
113
+ * decides: a new element gets its creator as author and editor, and an old one gets the sender of its changes as editor.
114
+ * A client cannot claim another principal, and an agent cannot pass as a person. A new item that the log lacks has no
115
+ * sender: it is deleted or undone. Only an old item falls back to `trusted`. A hand save names its poster as the sender
116
+ * of every item that the room does not hold, in `writer`.
111
117
  */
112
- restamp(prior: Board, trusted: (who: Principal) => boolean): string[];
118
+ restamp(prior: Board, trusted: (who: Principal) => boolean, writer?: (item: {
119
+ client: number;
120
+ clock: number;
121
+ }) => Principal | undefined): string[];
113
122
  addShape(input: unknown, actor: Principal): Shape;
114
123
  addConnector(input: unknown, actor: Principal): Connector;
115
124
  update(elementId: string, patch: unknown, actor: Principal): CanvasElement;
@@ -1,4 +1,4 @@
1
- import { S as ShapeType, c as Board, P as Principal, B as BoardData, a as Shape, C as Connector } from './read-BY6f-mmy.js';
1
+ import { S as ShapeType, c as Board, P as Principal, B as BoardData, a as Shape, C as Connector, d as CanvasElement } from './read-Bdg1uiFC.js';
2
2
  import { Store } from 'agentic-identity';
3
3
 
4
4
  declare const MAX_FLOW_NODES = 500;
@@ -47,6 +47,11 @@ declare function drawFlow(board: Board, source: string, actor: Principal, option
47
47
  edges: string[];
48
48
  };
49
49
 
50
+ /** Who sent the Yjs item with this id, if the room knows. */
51
+ type Writer = (item: {
52
+ client: number;
53
+ clock: number;
54
+ }) => Principal | undefined;
50
55
  /** One place for every board operation. The CLI, the MCP server, and the Worker call it. */
51
56
  declare class CanvasService {
52
57
  private readonly store;
@@ -58,6 +63,11 @@ declare class CanvasService {
58
63
  id: string;
59
64
  title: string;
60
65
  }>;
66
+ /** 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. */
67
+ list(): Promise<{
68
+ id: string;
69
+ title: string;
70
+ }[]>;
61
71
  /** Throws NOT_FOUND unless the board exists. A live room opens only for a board that exists. */
62
72
  assertExists(id: string): Promise<void>;
63
73
  read(id: string): Promise<BoardData & {
@@ -67,6 +77,8 @@ declare class CanvasService {
67
77
  change<R>(id: string, change: (board: Board) => R): Promise<R>;
68
78
  add(id: string, shape: unknown, actor: Principal): Promise<Shape>;
69
79
  connect(id: string, connector: unknown, actor: Principal): Promise<Connector>;
80
+ /** Change fields of one shape or connector: position, size, text, colors. */
81
+ update(id: string, elementId: string, patch: unknown, actor: Principal): Promise<CanvasElement>;
70
82
  move(id: string, elementId: string, dx: number, dy: number, actor: Principal): Promise<{
71
83
  moved: string[];
72
84
  }>;
@@ -80,18 +92,25 @@ declare class CanvasService {
80
92
  exportSvg(id: string): Promise<string>;
81
93
  /** The stored board as a Yjs update. A client applies it to a room to seed it: the room does not read it. */
82
94
  state(id: string): Promise<Uint8Array>;
95
+ /** 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. */
96
+ missingFrom(id: string, room?: Uint8Array): Promise<Uint8Array | null>;
83
97
  /**
84
- * Merge a live room's state into the stored board. `authors` are the principals the room saw send updates.
98
+ * `writer` names who sent each Yjs item, from the room's log. It sets the stamps where it knows (see `Board.restamp`).
99
+ * Merge a live room's state into the stored board. `authors` are the principals the room saw send updates. A null
100
+ * `actor` trusts only them: the Worker saves a room that way, with nobody present to vouch.
85
101
  * An element stamped by anyone else is deleted, and a changed author is put back (see `Board.restamp`),
86
102
  * so a client that writes raw Yjs cannot put another author on its work. Broken entries are repaired and
87
103
  * a board past the element cap is refused. Returns the ids the checks fixed.
88
104
  *
89
105
  * Limits: the save corrects the stored board only. The room keeps what its clients wrote, so a client that sees
90
106
  * a non-empty `fixed` must join again, or its later edits are lost at the next save. The room remembers its
91
- * authors for good, so a client may stamp an element with the name of anyone who once sent an update in that room.
107
+ * 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
108
+ * stamp may name anyone who once sent an update in that room. A NEW item with no writer is deleted or undone.
109
+ * The log records who adds an item, not who deletes a key: a deleted optional key is credited to the writer of the editor
110
+ * stamp written in the same save, or undone if there is none.
92
111
  * A client that adds a root type of its own makes every later save of that room fail, because the room keeps it.
93
112
  */
94
- checkpoint(id: string, update: Uint8Array, actor: Principal, authors?: Principal[]): Promise<{
113
+ checkpoint(id: string, update: Uint8Array, actor: Principal | null, authors?: Principal[], writer?: Writer): Promise<{
95
114
  elements: number;
96
115
  fixed: string[];
97
116
  }>;
@@ -104,6 +123,6 @@ declare class CanvasService {
104
123
  private load;
105
124
  }
106
125
  /** What the CLI and the MCP server need. `CanvasService` runs it on a store, and `LiveService` runs it in a live room. */
107
- type BoardAccess = Pick<CanvasService, "create" | "read" | "add" | "connect" | "move" | "remove" | "flow" | "exportSvg">;
126
+ type BoardAccess = Pick<CanvasService, "create" | "list" | "read" | "update" | "add" | "connect" | "move" | "remove" | "flow" | "exportSvg">;
108
127
 
109
128
  export { type BoardAccess as B, CanvasService as C, type DrawOptions as D, type FlowEdge as F, MAX_FLOW_EDGES as M, type Placement as P, type FlowGraph as a, type FlowNode as b, MAX_FLOW_NODES as c, drawFlow as d, layoutFlow as l, parseFlow as p };
package/dist/worker.d.ts CHANGED
@@ -2,8 +2,8 @@ import { Blobs } from 'agentic-identity';
2
2
  import { Realtime } from 'agentic-realtime';
3
3
  import { D1Like, R2Like } from 'agentic-identity/cloudflare';
4
4
  import { Hono } from 'hono';
5
- import { P as Principal } from './read-BY6f-mmy.js';
6
- import { C as CanvasService } from './service-Cpl6wKeY.js';
5
+ import { P as Principal } from './read-Bdg1uiFC.js';
6
+ import { C as CanvasService } from './service-Cim7TWnJ.js';
7
7
  import 'yjs';
8
8
  import 'zod';
9
9
 
@@ -40,6 +40,8 @@ interface AppOptions {
40
40
  /** Makes the live-room server from the Worker bindings. Without it, the ws route answers 404. */
41
41
  live?: (env: WorkerEnv) => Realtime;
42
42
  }
43
+ /** A browser cannot set a header on a WebSocket. It offers the token as the sub-protocols `agentic-canvas.bearer` and the token in base64url. */
44
+ declare const WS_PROTOCOL = "agentic-canvas.bearer";
43
45
  /** The principal and role for a bearer token, or null. The kind is never defaulted: a grant without a valid kind gets null. */
44
46
  declare function authenticate(request: Request, env: WorkerEnv): {
45
47
  actor: Principal;
@@ -52,4 +54,4 @@ declare const _default: {
52
54
  fetch: (request: Request, env: WorkerEnv, ctx: unknown) => Response | Promise<Response>;
53
55
  };
54
56
 
55
- export { type AppOptions, type Role, type WorkerEnv, authenticate, createApp, _default as default, sniffImage };
57
+ export { type AppOptions, type Role, WS_PROTOCOL, type WorkerEnv, authenticate, createApp, _default as default, sniffImage };
package/dist/worker.js CHANGED
@@ -1,13 +1,15 @@
1
1
  import {
2
+ WS_PROTOCOL,
2
3
  authenticate,
3
4
  createApp,
4
5
  sniffImage,
5
6
  worker_default
6
- } from "./chunk-V477KSZQ.js";
7
- import "./chunk-W7NRP3EX.js";
8
- import "./chunk-GGQPE65T.js";
7
+ } from "./chunk-HPUYT5TC.js";
8
+ import "./chunk-J52RPFRP.js";
9
+ import "./chunk-JZA525X5.js";
9
10
  import "./chunk-NWQED42L.js";
10
11
  export {
12
+ WS_PROTOCOL,
11
13
  authenticate,
12
14
  createApp,
13
15
  worker_default as default,
package/llms.txt CHANGED
@@ -5,10 +5,11 @@ A shared whiteboard for diagrams, drawn by people and agents.
5
5
  Keywords: whiteboard,canvas,diagrams,drawing,collaboration,agents,mcp,cli.
6
6
 
7
7
  - SDK: import { Board, drawFlow, readBoard, toSvg } from "agentic-canvas".
8
- - CLI: agentic-canvas new|read|add|connect|move|rm|flow|export.
8
+ - CLI: agentic-canvas new|list|read|add|connect|update|move|rm|flow|export.
9
+ - UI: the Worker serves a web UI at its root (run `npm run build`, then `wrangler dev`). A person signs in with a bearer token.
9
10
  - Live: set CANVAS_SERVER and CANVAS_TOKEN, and the CLI and `agentic-canvas mcp` edit the board in its live room.
10
11
  - SDK: import { LiveBoard, LiveService } from "agentic-canvas/live".
11
- - SDK: import { createClient, CanvasApiError } from "agentic-canvas/client". It calls the Worker API with fetch. Methods: me, createBoard, readBoard, exportSvg, addShape, addConnector, drawFlow, move, remove, uploadImage, getImage, state, checkpoint. A failure throws CanvasApiError with `code` and `status`. No retries.
12
+ - SDK: import { createClient, CanvasApiError } from "agentic-canvas/client". It calls the Worker API with fetch. Methods: me, listBoards, createBoard, readBoard, exportSvg, addShape, addConnector, drawFlow, update, move, remove, uploadImage, getImage, state, checkpoint. A failure throws CanvasApiError with `code` and `status`. No retries.
12
13
 
13
14
  ## Docs
14
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-canvas",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A shared whiteboard for diagrams, drawn by people and agents.",
5
5
  "keywords": [
6
6
  "whiteboard",
@@ -64,17 +64,19 @@
64
64
  "agentic-canvas": "./dist/cli.js"
65
65
  },
66
66
  "scripts": {
67
- "build": "tsup",
68
- "typecheck": "tsc --noEmit",
67
+ "build": "tsup && vite build",
68
+ "typecheck": "tsc --noEmit && tsc --noEmit -p ui",
69
69
  "test": "vitest run",
70
70
  "clean": "rm -rf dist",
71
- "ci:local": "npm run build && npm run typecheck && npm test"
71
+ "ci:local": "npm run build && npm run typecheck && npm test && npm run test:e2e",
72
+ "deploy:demo": "wrangler deploy -c wrangler.demo.jsonc",
73
+ "test:e2e": "playwright test -c e2e/playwright.config.ts"
72
74
  },
73
75
  "dependencies": {
74
76
  "@dagrejs/dagre": "^3.1.3",
75
77
  "@modelcontextprotocol/sdk": "^1.12.0",
76
- "agentic-identity": "^0.4.0",
77
- "agentic-realtime": "^0.1.0",
78
+ "agentic-identity": "^0.5.0",
79
+ "agentic-realtime": "^0.3.0",
78
80
  "yjs": "^13.6.0",
79
81
  "zod": "^3.25.0"
80
82
  },
@@ -87,12 +89,21 @@
87
89
  }
88
90
  },
89
91
  "devDependencies": {
92
+ "@axe-core/playwright": "^4.14.0",
93
+ "@playwright/test": "^1.64.1",
90
94
  "@types/node": "^22.0.0",
95
+ "@types/react": "^19.3.0",
96
+ "@types/react-dom": "^19.3.0",
97
+ "@vitejs/plugin-react": "^5.2.0",
98
+ "agentic-design": "^0.2.0",
91
99
  "hono": "^4.13.14",
92
100
  "miniflare": "^4.20260730.0",
93
101
  "playwright-core": "^1.64.1",
102
+ "react": "^19.3.1",
103
+ "react-dom": "^19.3.1",
94
104
  "tsup": "^8.0.0",
95
105
  "typescript": "^5.6.0",
106
+ "vite": "^7.3.7",
96
107
  "vitest": "^3.0.0",
97
108
  "wrangler": "^4.155.0"
98
109
  }