agentic-canvas 0.3.0 → 0.5.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,10 @@
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.5.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
+ > The package exports `agentic-canvas/ui`, a board view you can mount in your own app, new in 0.5.0.
13
14
 
14
15
  ## Install
15
16
 
@@ -37,7 +38,7 @@ npx agentic-canvas export demo --out demo.svg
37
38
  npx agentic-canvas export demo --format png --out demo.png
38
39
  ```
39
40
 
40
- Other commands: `add`, `connect`, `move`, `rm`. Run `npx agentic-canvas --help`.
41
+ Other commands: `list`, `add`, `connect`, `update`, `move`, `rm`. Run `npx agentic-canvas --help`.
41
42
  `repair` is for the owner only. It needs `CANVAS_ACTOR=human:<id>`.
42
43
  Boards live in `./.agentic-canvas` (or `--dir`, or `CANVAS_DIR`). Every write
43
44
  needs an actor in `CANVAS_ACTOR`, as `<kind>:<id>`. The operator sets it. A write
@@ -51,7 +52,7 @@ CANVAS_ACTOR=agent:helper npx agentic-canvas --dir ./boards mcp
51
52
  ```
52
53
 
53
54
  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
55
+ `list_boards`, `read_board`, `add_shape`, `connect`, `update_element`, `move`, `remove`, `draw_flow`, `export_svg`, and
55
56
  `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
57
  `<kind>:<id>`. That actor stamps every write. No tool takes an actor, author, or kind, so a
57
58
  caller cannot choose who it writes as. An error comes back as a tool error with a code.
@@ -104,13 +105,122 @@ try {
104
105
  }
105
106
  ```
106
107
 
107
- There is one method per route: `me`, `createBoard`, `readBoard`, `exportSvg`, `addShape`, `addConnector`,
108
- `drawFlow`, `move`, `remove`, `uploadImage`, `getImage`, `state`, and `checkpoint`. Each takes
108
+ There is one method per route: `me`, `listBoards`, `createBoard`, `readBoard`, `exportSvg`, `addShape`, `addConnector`,
109
+ `drawFlow`, `update`, `move`, `remove`, `uploadImage`, `getImage`, `state`, and `checkpoint`. Each takes
109
110
  `{ signal }` last, so an `AbortSignal` can stop a call. The token sets the actor. A failed call throws
110
111
  `CanvasApiError` with the API `code` and the HTTP `status`. A call that gets no reply has code `NETWORK`
111
112
  and status 0. The client never retries. A 503 means "try again", and the caller decides, because a
112
113
  repeated write can change a board twice. Pass `fetch` to use your own.
113
114
 
115
+ ## Live demo
116
+
117
+ A demo Worker runs on workers.dev: <https://agentic-canvas-demo.hello-fd1.workers.dev>. `GET /health` answers 200
118
+ and the web UI is at the root. The API needs a token, so a request without one gets 401. The demo's D1
119
+ database and R2 bucket are in the EU, and its live rooms use the EU jurisdiction. The demo data can be
120
+ reset at any time.
121
+
122
+ To redeploy it, a maintainer with a Cloudflare token runs this from the repo root. The deploy is manual and
123
+ no CI secret is used:
124
+
125
+ ```sh
126
+ npm ci && npm run build
127
+ WRANGLER_SEND_METRICS=false CLOUDFLARE_API_TOKEN=... npx wrangler deploy -c wrangler.demo.jsonc
128
+ npx wrangler secret put CANVAS_API_TOKENS -c wrangler.demo.jsonc # only to set or rotate tokens
129
+ ```
130
+
131
+ `wrangler.demo.jsonc` holds the config. The tokens are a Worker secret and are not in the repo. To run your
132
+ own copy, create a D1 database and an R2 bucket (both in the EU), put their names and id in a copy of that
133
+ file, and deploy it the same way.
134
+
135
+ ## UI
136
+
137
+ The Worker serves a small web UI at its root. It uses the published `agentic-design` package for
138
+ tokens, controls, and overlays, and the typed `agentic-canvas/client` for every call. It is
139
+ bundled at build time, so a build needs `agentic-design` from npm and the browser loads nothing
140
+ from another site.
141
+
142
+ Run it on your machine:
143
+
144
+ ```sh
145
+ npm ci
146
+ npm run build # writes the UI to ui-dist/
147
+ cat > .dev.vars <<'EOF2'
148
+ CANVAS_REGION=unknown
149
+ CANVAS_API_TOKENS={"ada-token-0123456789-0123456789-0123456789":{"actor":"ada","kind":"human"}}
150
+ EOF2
151
+ WRANGLER_SEND_METRICS=false npx wrangler dev
152
+ ```
153
+
154
+ Open the address that wrangler prints and paste the token. A token has 32 characters or more. Its
155
+ grant sets the name, the kind (a person or an agent), and the role. A `viewer` token reads and
156
+ cannot change a board. The token stays in the tab's session storage.
157
+
158
+ What it does:
159
+
160
+ - Lists boards, makes a board, and opens one. Empty, loading, and error states have a retry.
161
+ - Draws shapes, connectors, text, and sticky notes. Select, move, resize, connect, and delete an
162
+ item. Pan by dragging the background, zoom with the wheel or the toolbar.
163
+ - Marks an item an agent wrote with an "Agent" tag and in its accessible name. The side panel shows
164
+ who made the item and who last edited it.
165
+ - Shows who is in the board, from the live room (`agentic-realtime`). Edits go over the HTTP API.
166
+ After a save, the editor raises a counter in its room presence, and the other editors read the
167
+ 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.
168
+ - Works with a keyboard. Tab moves between items. Arrow keys move the selected item, Shift with an
169
+ arrow resizes it, Delete removes it, Escape clears the selection. With nothing selected, arrow
170
+ keys pan, `+` and `-` zoom, and `0` resets the view. The side panel also sets text, position, and size,
171
+ and connects shapes, with fields, and the toolbar has pan buttons. A person who cannot drag
172
+ can do everything a drag does.
173
+
174
+ ### Mount the board in your app
175
+
176
+ `agentic-canvas/ui` renders the same board UI into any element. It needs no framework in your app.
177
+ React and react-dom (version 19 or later) are peer dependencies. The Worker's own page uses this code.
178
+
179
+ ```ts
180
+ import "agentic-design/styles.css"; // tokens and fonts
181
+ import "agentic-canvas/ui.css"; // the board styles
182
+ import { createClient } from "agentic-canvas/client";
183
+ import { mount } from "agentic-canvas/ui";
184
+
185
+ const baseUrl = "https://canvas.example.com/api/v1/canvas";
186
+ const token = "...";
187
+ const board = mount(document.getElementById("board")!, {
188
+ boardId: "b1",
189
+ client: createClient({ baseUrl, token }),
190
+ principal: { kind: "human", name: "Ada", role: "editor" },
191
+ readOnly: false,
192
+ room: { baseUrl, token }, // optional: shows who else is in the board
193
+ });
194
+
195
+ board.update({ /* same options, for another board */ });
196
+ board.unmount(); // leaves the room and stops the refresh timer
197
+ ```
198
+
199
+ The principal needs a known `kind` and a `name`. `mount` throws a `TypeError` without them. Only the
200
+ role `editor` can change the board, and `readOnly: true` opens it read only for an editor too. The
201
+ server still checks every write against the token. The host page owns the theme. `ui.css` uses CSS nesting under `.cv-root`, so it does not change the rest of your page. It needs a browser from 2023 or later.
202
+ For a React app, `BoardEditor` is the same component without a root. Wrap it in the providers of
203
+ `agentic-design/react`.
204
+
205
+ Develop and test the UI:
206
+
207
+ ```sh
208
+ npm run build # UI and library
209
+ npx playwright-core install chromium
210
+ npm run test:e2e # Playwright against `wrangler dev`, with axe (WCAG 2.2 AA)
211
+ ```
212
+
213
+ The E2E run creates a board, adds a shape and a note, moves them, reloads, and checks each view
214
+ 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.
215
+
216
+ 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.
217
+
218
+ 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.
219
+
220
+ UI limits: the UI draws with the theme colors and ignores the `fill` and `stroke` of an element.
221
+ It shows no image pixels. A text field of a shape is plain text. Two editors who move one item
222
+ at the same time keep the last write. The board list shows the first 100 boards.
223
+
114
224
  ## Features
115
225
 
116
226
  - Shapes: rectangle, ellipse, diamond, text, sticky note, frame, image.
@@ -132,23 +242,107 @@ repeated write can change a board twice. Pass `fetch` to use your own.
132
242
  (EU jurisdiction), through the shared `Store` and `Blobs` ports. Set `CANVAS_API_TOKENS`
133
243
  (a JSON map of token to `{ actor, kind }`, tokens of 32 characters or more) and `CANVAS_REGION`.
134
244
  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`,
245
+ `/connectors`, `/flow`, `/move`, `GET /boards`, `PATCH /boards/:id/elements/:element`, `DELETE /boards/:id/elements/:element`, `POST /images`,
136
246
  `GET /images/:id`. An image is stored under the SHA-256 of its bytes, and the reply gives
137
247
  the `blob:` ref for a shape. A busy database or bucket answers 503: try again.
138
248
  - Live rooms (`agentic-canvas/live-worker`, on `agentic-realtime`). One Durable Object holds one
139
249
  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.
250
+ opens a room, `GET /me` says who a token is. The Worker seeds a room from the stored board when a client
251
+ joins, and saves the room to the stored board before it serves any board read or write. A raw WebSocket
252
+ client needs no seed and no save call. A room that no request reaches stays unsaved in the Store until the
253
+ next request, and it is safe in the room's own storage. `GET /boards/:id/state` and
254
+ `POST /boards/:id/checkpoint` still work for a client that saves by hand. Every save checks author stamps,
255
+ and the Worker sets them from the room's log of who sent each update (the token's actor and kind): a client
256
+ cannot claim another principal, and an agent cannot pass as a person, even by naming one who is in the room.
257
+ 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
258
  - The SDK joins a room: `import { LiveBoard, LiveService } from "agentic-canvas/live"`. The CLI and the MCP
144
259
  server join when `CANVAS_SERVER` and `CANVAS_TOKEN` are set. The token sets the actor, so
145
260
  `CANVAS_ACTOR` is not needed. `--dir`, `CANVAS_DIR`, and `repair` stay local.
146
261
  - 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.
262
+ - The board model has no UI code. It is plain TypeScript on Yjs, with no outbound calls.
263
+
264
+ ## Exports
265
+
266
+ Every name that each entry point exports is listed here. A name marked (type) exists only at compile time.
267
+
268
+ ### `agentic-canvas`
269
+
270
+ | Name | What it is |
271
+ |---|---|
272
+ | `VERSION`, `BRAND` | The package version. `BRAND` holds the command name, the description and the version that the CLI and the MCP server show. |
273
+ | `Board`, `CanvasElement` (type), `Shape` (type), `Connector` (type), `Group` (type), `Endpoint` (type), `ShapeType` (type) | The board model. `Board` holds the elements. Each element records its `author`, which never changes, and its `editedBy`. |
274
+ | `SHAPE_TYPES` | The shape kinds: `rect`, `ellipse`, `diamond`, `text`, `sticky`, `frame` and `image`. |
275
+ | `MAX_ELEMENTS`, `MAX_TEXT` | The limits: 10,000 elements on a board, and 10,000 UTF-16 code units in one text. |
276
+ | `CanvasError`, `ErrorCode` (type) | The error that the board throws, with a `code` such as `INVALID`, `NOT_FOUND` or `LIMIT`. |
277
+ | `parseFlow(source)`, `layoutFlow(graph)`, `drawFlow(board, source, actor, options?)` | `parseFlow` reads the flow text. `layoutFlow` returns placements with the top-left at (0, 0). `drawFlow` draws the flow onto the board as the actor. |
278
+ | `MAX_FLOW_NODES`, `MAX_FLOW_EDGES` | The flow limits: 500 nodes and 2000 edges. |
279
+ | `FlowGraph` (type), `FlowNode` (type), `FlowEdge` (type), `Placement` (type), `DrawOptions` (type) | Types for a flow and its layout. |
280
+ | `readBoard`, `BoardData` (type), `NodeData` (type), `EdgeData` (type) | Read a board as plain data. |
281
+ | `toSvg(board)`, `svgToPng(svg)` | `toSvg` returns the board as SVG. `svgToPng` turns an SVG string into PNG through Chromium. |
282
+ | `PRINCIPAL_KINDS`, `PrincipalKind` (type), `Principal` (type), `principalSchema`, `isMachineMade` | The principal kinds, the schema of a principal, and the rule that marks machine-made kinds. |
283
+ | `operatorActor(env?)`, `ActorConfigError` | Read the actor from `CANVAS_ACTOR` as `<kind>:<id>`. It throws `ActorConfigError` when the value is missing or bad. |
284
+ | `CanvasService` | The board operations that the CLI, the MCP server and the Worker share. It takes a `Store`. |
285
+ | `createMcpServer(service, actor)`, `runMcp(service, actor)` | Make the MCP server, or run it on stdio. |
286
+ | `createFileStore`, `openLocalStore`, `FileStoreOptions` (type) | Stores on one local file. See `agentic-canvas/file-store`. |
287
+
288
+ ### `agentic-canvas/client`
289
+
290
+ | Name | What it is |
291
+ |---|---|
292
+ | `createClient(options)`, `CanvasApiError` | The remote client. See Remote client above. |
293
+ | `ClientOptions` (type), `CallOptions` (type), `CanvasClient` (type), `ShapeInput` (type), `ConnectorInput` (type), `ShapePatch` (type), `ConnectorPatch` (type), `ImageInfo` (type), `ApiErrorCode` (type) | Types for the client. `ApiErrorCode` adds the client's own codes, such as `NETWORK`, to `ErrorCode`. |
294
+
295
+ ### `agentic-canvas/file-store`
296
+
297
+ | Name | What it is |
298
+ |---|---|
299
+ | `createFileStore(path, options?)`, `openLocalStore(dir)` | A store on one JSON file, or on `canvas.json` in a directory. |
300
+ | `FileStoreError` | The error that the store throws. Its `code` is `LOCKED` or `DAMAGED`. |
301
+ | `FileStoreOptions` (type) | `lockTimeoutMs` (default 10000) and `staleAfterMs` (default 30000), plus the adapter options. |
302
+ | `STORE_FILE` | The file name that `openLocalStore` uses, `canvas.json`. |
303
+
304
+ ### `agentic-canvas/live`
305
+
306
+ | Name | What it is |
307
+ |---|---|
308
+ | `LiveBoard.join(options)` | Join one board's live room. Read the board, change it, `save` to the stored board, then `leave`. |
309
+ | `LiveService` | The CLI and MCP commands, run in a live room. A change joins the room, runs as the token's actor, saves, and leaves. `read` and `exportSvg` join and leave without a save. `create` and `list` call the HTTP API. |
310
+ | `whoAmI(server, token)`, `Me` (type) | Ask the Worker who a token is. It returns the actor and the role. |
311
+ | `LiveOptions` (type) | The server, the board id, the token, and the optional name and reconnect settings. |
312
+
313
+ ### `agentic-canvas/ui`
314
+
315
+ | Name | What it is |
316
+ |---|---|
317
+ | `mount(element, options)` | Render the board UI into an element. It returns `update(options)` and `unmount()`. See Mount the board in your app. |
318
+ | `BoardEditor(options)` | The same board UI as a React component, without a root. |
319
+ | `BoardEditorOptions` (type), `BoardPrincipal` (type), `MountedBoard` (type) | Types for the options, the principal, and the object that `mount` returns. |
320
+ | `CanvasClient` (type), `RoomOptions` (type) | The client type, as in `agentic-canvas/client`, and the live room's base URL and token for the `room` option. |
321
+
322
+ The stylesheet is a separate export, `agentic-canvas/ui.css`. Load `agentic-design/styles.css` too, for the tokens.
323
+
324
+ ### `agentic-canvas/worker`
325
+
326
+ | Name | What it is |
327
+ |---|---|
328
+ | `createApp(options?)` | Make the Hono app for the Worker. Pass `live` to turn on the live-room socket. |
329
+ | `default` | The Worker, without live rooms. |
330
+ | `authenticate(request, env)` | Read a bearer token from the `Authorization` header, or from a WebSocket sub-protocol. Return the actor and the role, or `null`. |
331
+ | `sniffImage(bytes)` | Find the image type from the first bytes, or return `null`. The client's claim of a type is not trusted. |
332
+ | `WS_PROTOCOL` | The sub-protocol name that carries a token on a WebSocket: `agentic-canvas.bearer`. |
333
+ | `WorkerEnv` (type), `AppOptions` (type), `Role` (type) | Types for the Worker bindings, the app options and the role, `editor` or `viewer`. |
334
+
335
+ ### `agentic-canvas/live-worker`
336
+
337
+ | Name | What it is |
338
+ |---|---|
339
+ | `default` | The Worker with live rooms. Bind the `SyncRoom` class as `SYNC_ROOM`. |
340
+ | `SyncRoom` | The Durable Object class of the live rooms, re-exported from `agentic-realtime/worker`. |
341
+ | `LiveEnv` (type) | The Worker bindings, plus `SYNC_ROOM`. |
148
342
 
149
343
  ## Limits
150
344
 
151
- - Canvas takes principal kinds, regions, and the Store and Blobs ports from `agentic-identity` 0.4.
345
+ - Canvas takes principal kinds, regions, and the Store and Blobs ports from `agentic-identity` 0.5.
152
346
  It does not use the sharing API yet. Its editor and viewer tokens are a separate table in
153
347
  `CANVAS_API_TOKENS`. It has no board delete or erase. When it adopts sharing, grants are keyed
154
348
  on kind and id, and a delete calls `deleteResource`.
@@ -1,14 +1,16 @@
1
+ import {
2
+ FileStoreError
3
+ } from "./chunk-NWQED42L.js";
1
4
  import {
2
5
  Board,
3
6
  CanvasError,
4
7
  drawFlow,
5
- principalSchema,
6
8
  readBoard,
7
9
  toSvg
8
- } from "./chunk-GGQPE65T.js";
10
+ } from "./chunk-JCYNPYYJ.js";
9
11
  import {
10
- FileStoreError
11
- } from "./chunk-NWQED42L.js";
12
+ principalSchema
13
+ } from "./chunk-SPP6PINW.js";
12
14
 
13
15
  // src/service.ts
14
16
  import * as Y from "yjs";
@@ -21,6 +23,7 @@ function toBase64(bytes) {
21
23
  for (let i = 0; i < bytes.length; i += 8192) text += String.fromCharCode(...bytes.subarray(i, i + 8192));
22
24
  return btoa(text);
23
25
  }
26
+ var sameBytes = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
24
27
  var fromBase64 = (text) => Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
25
28
  function boardKey(id) {
26
29
  try {
@@ -51,6 +54,17 @@ var CanvasService = class {
51
54
  return { id, title: board.title };
52
55
  });
53
56
  }
57
+ /** 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. */
58
+ async list() {
59
+ const page = await this.store.query(COLLECTION, { limit: 100 }).catch(storeFailure);
60
+ return page.items.map(({ id, data }) => {
61
+ try {
62
+ return { id, title: decodeStored(data, id, Board.decode).title };
63
+ } catch {
64
+ return { id, title: "" };
65
+ }
66
+ });
67
+ }
54
68
  /** Throws NOT_FOUND unless the board exists. A live room opens only for a board that exists. */
55
69
  async assertExists(id) {
56
70
  boardKey(id);
@@ -76,6 +90,10 @@ var CanvasService = class {
76
90
  connect(id, connector, actor) {
77
91
  return this.change(id, (b) => b.addConnector(connector, actor));
78
92
  }
93
+ /** Change fields of one shape or connector: position, size, text, colors. */
94
+ update(id, elementId, patch, actor) {
95
+ return this.change(id, (b) => b.update(elementId, patch, actor));
96
+ }
79
97
  move(id, elementId, dx, dy, actor) {
80
98
  return this.change(id, (b) => ({ moved: b.move(elementId, dx, dy, actor) }));
81
99
  }
@@ -94,22 +112,37 @@ var CanvasService = class {
94
112
  boardKey(id);
95
113
  return this.store.transaction(async (tx) => (await this.load(tx.get(COLLECTION, id), id)).encode());
96
114
  }
115
+ /** 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. */
116
+ async missingFrom(id, room) {
117
+ boardKey(id);
118
+ const known = new Y.Doc();
119
+ if (room) Y.applyUpdate(known, room);
120
+ const diff = await this.store.transaction(async (tx) => Y.encodeStateAsUpdate((await this.load(tx.get(COLLECTION, id), id)).doc, Y.encodeStateVector(known)));
121
+ const before = Y.encodeStateAsUpdate(known);
122
+ Y.applyUpdate(known, diff);
123
+ return Y.decodeUpdate(diff).structs.length === 0 && sameBytes(before, Y.encodeStateAsUpdate(known)) ? null : diff;
124
+ }
97
125
  /**
98
- * Merge a live room's state into the stored board. `authors` are the principals the room saw send updates.
126
+ * `writer` names who sent each Yjs item, from the room's log. It sets the stamps where it knows (see `Board.restamp`).
127
+ * Merge a live room's state into the stored board. `authors` are the principals the room saw send updates. A null
128
+ * `actor` trusts only them: the Worker saves a room that way, with nobody present to vouch.
99
129
  * An element stamped by anyone else is deleted, and a changed author is put back (see `Board.restamp`),
100
130
  * so a client that writes raw Yjs cannot put another author on its work. Broken entries are repaired and
101
131
  * a board past the element cap is refused. Returns the ids the checks fixed.
102
132
  *
103
133
  * Limits: the save corrects the stored board only. The room keeps what its clients wrote, so a client that sees
104
134
  * 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.
135
+ * 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
136
+ * stamp may name anyone who once sent an update in that room. A NEW item with no writer is deleted or undone.
137
+ * The log records who adds an item, not who deletes a key: a deleted optional key is credited to the writer of the editor
138
+ * stamp written in the same save, or undone if there is none.
106
139
  * A client that adds a root type of its own makes every later save of that room fail, because the room keeps it.
107
140
  */
108
- async checkpoint(id, update, actor, authors = []) {
141
+ async checkpoint(id, update, actor, authors = [], writer) {
109
142
  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);
143
+ const by = actor === null ? null : principalSchema.safeParse(actor);
144
+ if (by && !by.success) throw new CanvasError("INVALID", "the actor must be a valid principal");
145
+ const trusted = (who) => [...by?.success ? [by.data] : [], ...authors].some((a) => a.kind === who.kind && a.id === who.id);
113
146
  return this.store.transaction(async (tx) => {
114
147
  const prior = await this.load(tx.get(COLLECTION, id), id);
115
148
  const next = prior.clone();
@@ -121,7 +154,7 @@ var CanvasService = class {
121
154
  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
155
  const strange = [...next.doc.share.keys()].filter((key) => key !== "elements" && key !== "meta");
123
156
  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()]);
157
+ const fixed = /* @__PURE__ */ new Set([...next.restamp(prior, trusted, writer), ...next.repair()]);
125
158
  const saved = next.encode();
126
159
  if (saved.length > MAX_LIVE_BYTES) throw new CanvasError("LIMIT", `the board would hold over ${MAX_LIVE_BYTES} bytes`);
127
160
  await tx.put(COLLECTION, id, { update: toBase64(saved) });
@@ -1,12 +1,12 @@
1
- // src/core/principal.ts
2
- import { PRINCIPAL_KINDS, isMachineMade } from "agentic-identity";
3
- import { z } from "zod";
4
- var principalSchema = z.object({ kind: z.enum(PRINCIPAL_KINDS), id: z.string().min(1).max(200) }).strict();
1
+ import {
2
+ isMachineMade,
3
+ principalSchema
4
+ } from "./chunk-SPP6PINW.js";
5
5
 
6
6
  // src/core/board.ts
7
7
  import { randomUUID } from "crypto";
8
8
  import * as Y from "yjs";
9
- import { z as z2 } from "zod";
9
+ import { z } from "zod";
10
10
  var CanvasError = class extends Error {
11
11
  constructor(code, message) {
12
12
  super(message);
@@ -18,18 +18,18 @@ var CanvasError = class extends Error {
18
18
  var SHAPE_TYPES = ["rect", "ellipse", "diamond", "text", "sticky", "frame", "image"];
19
19
  var MAX_ELEMENTS = 1e4;
20
20
  var MAX_TEXT = 1e4;
21
- var id = z2.string().regex(/^[A-Za-z0-9_-]{1,64}$/);
22
- var coord = z2.number().finite().min(-1e7).max(1e7);
23
- var size = z2.number().finite().gt(0).max(1e7);
24
- var text = z2.string().max(MAX_TEXT);
21
+ var id = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/);
22
+ var coord = z.number().finite().min(-1e7).max(1e7);
23
+ var size = z.number().finite().gt(0).max(1e7);
24
+ var text = z.string().max(MAX_TEXT);
25
25
  var COLOR_PATTERN = /^(#[0-9a-fA-F]{3,8}|[a-z]{3,20})$/;
26
26
  var IMAGE_PATTERN = /^(data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/]+=*|blob:[A-Za-z0-9._-]{1,200})$/;
27
- var color = z2.string().regex(COLOR_PATTERN);
28
- var imageSrc = z2.string().max(2e6).regex(IMAGE_PATTERN);
29
- var endpoint = z2.union([z2.object({ id }).strict(), z2.object({ x: coord, y: coord }).strict()]);
30
- var shapeInput = z2.object({
27
+ var color = z.string().regex(COLOR_PATTERN);
28
+ var imageSrc = z.string().max(2e6).regex(IMAGE_PATTERN);
29
+ var endpoint = z.union([z.object({ id }).strict(), z.object({ x: coord, y: coord }).strict()]);
30
+ var shapeInput = z.object({
31
31
  id: id.optional(),
32
- type: z2.enum(SHAPE_TYPES),
32
+ type: z.enum(SHAPE_TYPES),
33
33
  x: coord,
34
34
  y: coord,
35
35
  w: size.default(120),
@@ -41,40 +41,40 @@ var shapeInput = z2.object({
41
41
  }).strict().refine((s) => s.type === "image" === (s.src !== void 0), {
42
42
  message: "src is required for image shapes and allowed on no other type"
43
43
  });
44
- var connectorInput = z2.object({
44
+ var connectorInput = z.object({
45
45
  id: id.optional(),
46
46
  from: endpoint,
47
47
  to: endpoint,
48
48
  text: text.default(""),
49
- startArrow: z2.boolean().default(false),
50
- endArrow: z2.boolean().default(true),
49
+ startArrow: z.boolean().default(false),
50
+ endArrow: z.boolean().default(true),
51
51
  stroke: color.optional()
52
52
  }).strict();
53
- var shapePatch = z2.object({ x: coord, y: coord, w: size, h: size, text, fill: color, stroke: color, src: imageSrc }).partial().strict();
54
- var connectorPatch = z2.object({ from: endpoint, to: endpoint, text, startArrow: z2.boolean(), endArrow: z2.boolean(), stroke: color }).partial().strict();
55
- var delta = z2.number().finite().min(-2e7).max(2e7);
53
+ var shapePatch = z.object({ x: coord, y: coord, w: size, h: size, text, fill: color, stroke: color, src: imageSrc }).partial().strict();
54
+ var connectorPatch = z.object({ from: endpoint, to: endpoint, text, startArrow: z.boolean(), endArrow: z.boolean(), stroke: color }).partial().strict();
55
+ var delta = z.number().finite().min(-2e7).max(2e7);
56
56
  var MAX_Z = 1e9;
57
57
  var stampFields = {
58
58
  id,
59
- z: z2.number().finite().min(-MAX_Z).max(MAX_Z),
59
+ z: z.number().finite().min(-MAX_Z).max(MAX_Z),
60
60
  groupId: id.optional(),
61
61
  author: principalSchema,
62
62
  editedBy: principalSchema
63
63
  };
64
- var storedShape = z2.object({ ...stampFields, type: z2.enum(SHAPE_TYPES), x: coord, y: coord, w: size, h: size, text, fill: color.optional(), stroke: color.optional(), src: imageSrc.optional() }).refine((s) => s.type === "image" === (s.src !== void 0), { message: "src is required for image shapes and allowed on no other type" });
65
- var storedConnector = z2.object({
64
+ var storedShape = z.object({ ...stampFields, type: z.enum(SHAPE_TYPES), x: coord, y: coord, w: size, h: size, text, fill: color.optional(), stroke: color.optional(), src: imageSrc.optional() }).refine((s) => s.type === "image" === (s.src !== void 0), { message: "src is required for image shapes and allowed on no other type" });
65
+ var storedConnector = z.object({
66
66
  ...stampFields,
67
- type: z2.literal("connector"),
67
+ type: z.literal("connector"),
68
68
  from: endpoint,
69
69
  to: endpoint,
70
70
  text,
71
- startArrow: z2.boolean(),
72
- endArrow: z2.boolean(),
71
+ startArrow: z.boolean(),
72
+ endArrow: z.boolean(),
73
73
  stroke: color.optional()
74
74
  });
75
- var storedGroup = z2.object({ ...stampFields, type: z2.literal("group") });
76
- var storedElement = z2.union([storedShape, storedConnector, storedGroup]);
77
- var titleSchema = z2.string().max(200);
75
+ var storedGroup = z.object({ ...stampFields, type: z.literal("group") });
76
+ var storedElement = z.union([storedShape, storedConnector, storedGroup]);
77
+ var titleSchema = z.string().max(200);
78
78
  function parse(schema, value) {
79
79
  const result = schema.safeParse(value);
80
80
  if (!result.success) {
@@ -133,7 +133,7 @@ var Board = class _Board {
133
133
  return value.success ? value.data : void 0;
134
134
  }
135
135
  setTitle(title, actor) {
136
- const t = parse(z2.string().max(200), title);
136
+ const t = parse(z.string().max(200), title);
137
137
  const by = parse(principalSchema, actor);
138
138
  this.doc.transact(() => {
139
139
  this.meta.set("title", t);
@@ -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);
@@ -265,7 +324,7 @@ var Board = class _Board {
265
324
  /** Shift an element, or a whole group. Returns the ids that moved. Bound connector ends follow their shapes. */
266
325
  move(elementId, dx, dy, actor) {
267
326
  const by = parse(principalSchema, actor);
268
- const [mx, my] = parse(z2.tuple([delta, delta]), [dx, dy]);
327
+ const [mx, my] = parse(z.tuple([delta, delta]), [dx, dy]);
269
328
  this.require(elementId);
270
329
  const free = (e) => hasId(e) ? e : { x: e.x + mx, y: e.y + my };
271
330
  const plan = [];
@@ -294,7 +353,7 @@ var Board = class _Board {
294
353
  }
295
354
  group(ids, actor, groupId) {
296
355
  const by = parse(principalSchema, actor);
297
- const members = parse(z2.array(id).min(2).max(MAX_ELEMENTS), ids);
356
+ const members = parse(z.array(id).min(2).max(MAX_ELEMENTS), ids);
298
357
  const gid = parse(id.optional(), groupId);
299
358
  if (new Set(members).size !== members.length) throw new CanvasError("INVALID", "member ids repeat");
300
359
  const parents = new Set(members.map((m) => this.require(m).groupId));
@@ -364,12 +423,12 @@ var Board = class _Board {
364
423
  }
365
424
  /** The z value just past the top or the bottom. Not clamped: the caller checks the range. */
366
425
  edgeZ(direction) {
367
- let z3 = 0;
426
+ let z2 = 0;
368
427
  for (const m of this.elements.values()) {
369
428
  const value = m instanceof Y.Map ? m.get("z") : void 0;
370
- if (isValidZ(value)) z3 = direction === 1 ? Math.max(z3, value) : Math.min(z3, value);
429
+ if (isValidZ(value)) z2 = direction === 1 ? Math.max(z2, value) : Math.min(z2, value);
371
430
  }
372
- return z3 + direction;
431
+ return z2 + direction;
373
432
  }
374
433
  /** The element and, for a group, everything below it. Cycle-safe. */
375
434
  subtree(elementId) {
@@ -705,9 +764,6 @@ function toSvg(board) {
705
764
  }
706
765
 
707
766
  export {
708
- PRINCIPAL_KINDS,
709
- isMachineMade,
710
- principalSchema,
711
767
  CanvasError,
712
768
  SHAPE_TYPES,
713
769
  MAX_ELEMENTS,
@@ -0,0 +1,10 @@
1
+ // src/core/principal.ts
2
+ import { PRINCIPAL_KINDS, isMachineMade } from "agentic-identity";
3
+ import { z } from "zod";
4
+ var principalSchema = z.object({ kind: z.enum(PRINCIPAL_KINDS), id: z.string().min(1).max(200) }).strict();
5
+
6
+ export {
7
+ PRINCIPAL_KINDS,
8
+ isMachineMade,
9
+ principalSchema
10
+ };