agentic-canvas 0.14.3 → 0.15.1

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,7 +7,7 @@
7
7
 
8
8
  A shared whiteboard for diagrams, drawn by people and agents.
9
9
 
10
- > Status: 0.14.3. The board model, the agent operations, the exports, the CLI, the
10
+ > Status: 0.15.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
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
  > The package exports `agentic-canvas/ui`, a board view you can mount in your own app, new in 0.5.0.
@@ -247,6 +247,21 @@ const data = await canvasAccount.exportAccount({ kind: "human", id: "ada" }, { s
247
247
  const counts = await canvasAccount.eraseAccount({ kind: "human", id: "ada" }, { store, saveRoom, eraseRoom });
248
248
  ```
249
249
 
250
+ Set `resourceType` (default `canvas`) and `collection` (default `boards`) to match the host. Board ids must still use 1 to 64 ASCII letters, digits, underscores, or hyphens, and must not start and end with two underscores. This rule also applies to custom resource types and external content sources. The output collection names stay the same. By default, rows without an `update` field are skipped. Give `isBoard(id, data)` to select rows by a host kind field. Rows of the configured resource type are always selected, so damaged personal data cannot be skipped. Selected rows that do not decode still fail with `INVALID`. Shared-board writes keep other row fields. Each scan reads the sharing resource for every stored row to check its type. An erase scans the collection, then reads stamped boards again inside transactions. Allow for these reads on large collections.
251
+
252
+ ```ts
253
+ const deps = {
254
+ store,
255
+ resourceType: "drawing",
256
+ collection: "drawings",
257
+ isBoard: (_id: string, row: Readonly<Record<string, unknown>>) => row.kind === "drawing",
258
+ };
259
+ const data = await canvasAccount.exportAccount(subject, deps);
260
+ await canvasAccount.eraseAccount(subject, deps);
261
+ ```
262
+
263
+ For content held outside the Store, give `contentSource: { exportContent(id), eraseContent(id) }`. Both methods return promises. The export method returns JSON; each sole-owned board exports as `{ id, content }`. Content must use arrays and plain objects from the current realm (with `Object.prototype` or a null prototype). The export rejects `undefined` values, functions, symbols, class instances, and non-finite numbers, including nested values. A `toJSON()` method can return supported JSON, so dates export as strings. The erase method deletes its content before sharing records go. This mode ignores `collection` and `isBoard`, does not read or write the content collection, and does not call `saveRoom` or `eraseRoom`. It uses sharing records to find sole-owned boards. It still exports and erases grants, invites, and audit data for the configured resource type. It does not inspect or scrub stamps in shared-board content: the host must handle that data separately. For a room backend, the methods can export room history and delete the room. Methods must tolerate retries, including missing content. A rejected method rejects the account call; earlier changes can remain. Sharing records for a failed content delete stay so a retry can finish. Stop writes during the operation.
264
+
250
265
  The export is plain JSON in the collections `boards`, `grants`, `invites`, `authored_elements`, `edited_elements`, `titles`, and `audit`. It has no format or time. The erase returns a count for each name.
251
266
 
252
267
  - A board that the subject alone owns is deleted with its grants, invites, links, and audit entries. The erase calls `eraseRoom(boardId)` first, as the Worker does.
package/dist/account.d.ts CHANGED
@@ -1,11 +1,28 @@
1
- import { Store, Json } from 'agentic-identity';
1
+ import { Json, Store } from 'agentic-identity';
2
2
  import { P as Principal } from './principal-blhsFbdS.js';
3
3
  import 'zod';
4
4
 
5
+ /** Host-owned whole-board content. Methods must tolerate retries. */
6
+ interface CanvasContentSource {
7
+ readonly exportContent: (id: string) => Promise<Json>;
8
+ readonly eraseContent: (id: string) => Promise<void>;
9
+ }
5
10
  interface CanvasAccountDeps {
11
+ /** Sharing resource type. Default: `canvas`. */
12
+ resourceType?: string;
13
+ /** Serialized board collection. Default: `boards`. */
14
+ collection?: string;
15
+ /** Select canvas rows before decoding or saving rooms. Default: rows with an `update` field. */
16
+ isBoard?: (id: string, data: Readonly<Record<string, unknown>>) => boolean;
17
+ /** Whole-board content for sole owners. Replaces collection access and room hooks.
18
+ * Shared-board stamps are outside this mode; the host must handle them separately.
19
+ * Both methods must tolerate retries, including a board already erased.
20
+ * Ignores collection, isBoard, saveRoom, and eraseRoom when supplied.
21
+ */
22
+ contentSource?: CanvasContentSource;
6
23
  /** The Store that holds the boards and the sharing records. */
7
24
  store: Store;
8
- /** Saves the live room of a board to the Store, as the Worker does before it reads a board. Without it, edits that no request has saved yet are not seen, and an erase that resets a room drops them. The call runs for every stored board, so it must be cheap for a board with no room. A board with no room needs no work. Omit it when no room runs. */
25
+ /** Saves the live room of a board to the Store, as the Worker does before it reads a board. Without it, edits that no request has saved yet are not seen, and an erase that resets a room drops them. The call runs only for selected canvas rows, and never with contentSource. It must be cheap for a board with no room. A board with no room needs no work. Omit it when no room runs. */
9
26
  saveRoom?: (boardId: string) => Promise<void>;
10
27
  /** Erases the live room of a board, so it opens again from the Store. The erase calls it for every board that it changes or deletes. Omit it when no room runs. */
11
28
  eraseRoom?: (boardId: string) => Promise<void>;
@@ -30,4 +47,4 @@ interface CanvasAccount {
30
47
  }
31
48
  declare const canvasAccount: CanvasAccount;
32
49
 
33
- export { type CanvasAccount, type CanvasAccountDeps, type CanvasCollection, canvasAccount };
50
+ export { type CanvasAccount, type CanvasAccountDeps, type CanvasCollection, type CanvasContentSource, canvasAccount };
package/dist/account.js CHANGED
@@ -36,16 +36,19 @@ var plain = (value) => JSON.parse(JSON.stringify(value));
36
36
  function check(subject, deps) {
37
37
  if (typeof deps?.store?.query !== "function") throw new CanvasError("INVALID", "canvasAccount needs deps.store");
38
38
  for (const hook of [deps.saveRoom, deps.eraseRoom]) if (hook !== void 0 && typeof hook !== "function") throw new CanvasError("INVALID", "deps.saveRoom and deps.eraseRoom must be functions");
39
+ for (const value of [deps.resourceType, deps.collection]) if (value !== void 0 && (typeof value !== "string" || !value.trim())) throw new CanvasError("INVALID", "resourceType and collection must be nonempty strings");
40
+ if (deps.isBoard !== void 0 && typeof deps.isBoard !== "function") throw new CanvasError("INVALID", "isBoard must be a function");
41
+ if (deps.contentSource !== void 0 && (typeof deps.contentSource?.exportContent !== "function" || typeof deps.contentSource?.eraseContent !== "function")) throw new CanvasError("INVALID", "contentSource needs exportContent and eraseContent");
39
42
  if (!principalSchema.safeParse(subject).success) throw new CanvasError("INVALID", "subject needs an id and a known kind");
40
43
  }
41
- async function held(store, subject) {
44
+ async function held(store, subject, resourceType) {
42
45
  const all = [];
43
46
  const sole = /* @__PURE__ */ new Set();
44
47
  for await (const { id: grantId, data } of pages(store, "grants", { subjectKind: subject.kind, subjectId: subject.id })) {
45
48
  const { resourceId: id, role } = data;
46
49
  if (typeof id !== "string" || typeof role !== "string" || !ROLES.includes(role)) throw new CanvasError("INVALID", `grant "${grantId}" of the subject is damaged`);
47
50
  const resource = await store.get("resources", id);
48
- if (resource === null ? !isBoardId(id) : resource.type !== RESOURCE_TYPE) continue;
51
+ if (resource === null ? !isBoardId(id) : resource.type !== resourceType) continue;
49
52
  all.push({ grantId, resourceId: id, role });
50
53
  if (resource === null || role !== "owner") continue;
51
54
  const owners = await rows(store, "grants", { resourceId: id, role: "owner" });
@@ -54,16 +57,21 @@ async function held(store, subject) {
54
57
  }
55
58
  return { all, sole };
56
59
  }
57
- async function* boards(store, saveRoom) {
60
+ async function* boards(store, deps) {
61
+ if (deps.contentSource) return;
62
+ const collection = deps.collection ?? "boards";
58
63
  let cursor;
59
64
  do {
60
- const page = await store.query("boards", { limit: PAGE, ...cursor === void 0 ? {} : { cursor } });
65
+ const page = await store.query(collection, { limit: PAGE, ...cursor === void 0 ? {} : { cursor } });
61
66
  for (const item of page.items) {
62
67
  const { id } = item;
63
68
  let data = item.data;
64
- if (saveRoom) {
65
- await saveRoom(id);
66
- data = await store.get("boards", id) ?? data;
69
+ const resource = await store.get("resources", id);
70
+ if (resource !== null && resource.type !== (deps.resourceType ?? RESOURCE_TYPE)) continue;
71
+ if (resource === null && !(deps.isBoard ? deps.isBoard(id, data) : Object.hasOwn(data, "update"))) continue;
72
+ if (deps.saveRoom) {
73
+ await deps.saveRoom(id);
74
+ data = await store.get(collection, id) ?? data;
67
75
  }
68
76
  try {
69
77
  yield { id, board: Board.open(fromBase64(String(data.update))) };
@@ -75,7 +83,7 @@ async function* boards(store, saveRoom) {
75
83
  } while (cursor !== void 0);
76
84
  }
77
85
  var isSubject = (value, subject) => typeof value === "object" && value !== null && value.kind === subject.kind && value.id === subject.id;
78
- async function auditOf(store, subject) {
86
+ async function auditOf(store, subject, resourceType) {
79
87
  const found = /* @__PURE__ */ new Map();
80
88
  for (const field of ["principalId", "rootId", "targetId"]) for (const row of await rows(store, "audit", { [field]: subject.id })) found.set(row.id, row);
81
89
  const out = [];
@@ -84,7 +92,7 @@ async function auditOf(store, subject) {
84
92
  const { principalId, principalKind, chain, rootId, resourceId, targetKind, targetId } = row.data;
85
93
  if (typeof resourceId !== "string" || !isBoardId(resourceId)) continue;
86
94
  const resource = await store.get("resources", resourceId);
87
- if (resource !== null && resource.type !== RESOURCE_TYPE) continue;
95
+ if (resource !== null && resource.type !== resourceType) continue;
88
96
  const first = Array.isArray(chain) ? chain[0] : null;
89
97
  const damaged = principalId === subject.id && !known(principalKind, ["anonymous"]) || targetId === subject.id && !known(targetKind, ["invite", "link", "person", "app", "key"]) || (first?.id === subject.id || first === null && rootId === subject.id) && !known(first?.kind);
90
98
  if (damaged) throw new CanvasError("INVALID", `audit entry "${row.id}" is damaged`);
@@ -92,14 +100,14 @@ async function auditOf(store, subject) {
92
100
  }
93
101
  return out;
94
102
  }
95
- async function invitesOf(store, subject) {
103
+ async function invitesOf(store, subject, resourceType) {
96
104
  const out = [];
97
105
  for (const row of await rows(store, "invites", { createdBy: subject.id })) {
98
106
  const { resourceId } = row.data;
99
107
  if (typeof resourceId !== "string") throw new CanvasError("INVALID", `invite "${row.id}" of the subject is damaged`);
100
108
  if (!isBoardId(resourceId)) continue;
101
109
  const resource = await store.get("resources", resourceId);
102
- if (resource === null || resource.type === RESOURCE_TYPE) out.push(row);
110
+ if (resource === null || resource.type === resourceType) out.push(row);
103
111
  }
104
112
  return out;
105
113
  }
@@ -118,10 +126,11 @@ var canvasAccount = {
118
126
  async exportAccount(subject, deps) {
119
127
  check(subject, deps);
120
128
  const { store } = deps;
129
+ const resourceType = deps.resourceType ?? RESOURCE_TYPE;
121
130
  const out = { boards: [], grants: [], invites: [], authored_elements: [], edited_elements: [], titles: [], audit: [] };
122
- const mine = await held(store, subject);
131
+ const mine = await held(store, subject, resourceType);
123
132
  const owned = /* @__PURE__ */ new Map();
124
- for await (const { id, board } of boards(store, deps.saveRoom)) {
133
+ for await (const { id, board } of boards(store, deps)) {
125
134
  if (mine.sole.has(id)) {
126
135
  owned.set(id, plain([{ id, title: board.title, elements: board.rawElements() }])[0]);
127
136
  continue;
@@ -134,23 +143,41 @@ var canvasAccount = {
134
143
  for (const g of mine.all) {
135
144
  out.grants.push(...plain([{ resourceId: g.resourceId, subjectKind: subject.kind, subjectId: subject.id, role: g.role }]));
136
145
  }
137
- for (const id of mine.sole) if (owned.has(id)) out.boards.push(owned.get(id));
138
- for (const { id, data } of await invitesOf(store, subject)) {
146
+ for (const id of mine.sole) {
147
+ if (deps.contentSource) {
148
+ const content = await deps.contentSource.exportContent(id);
149
+ let normalized;
150
+ try {
151
+ normalized = JSON.parse(JSON.stringify(content, (_key, value) => {
152
+ if (value === void 0 || typeof value === "function" || typeof value === "symbol" || typeof value === "number" && !Number.isFinite(value) || typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) {
153
+ throw new Error("not JSON content");
154
+ }
155
+ return value;
156
+ }));
157
+ } catch {
158
+ throw new CanvasError("INVALID", `content source returned invalid JSON for board "${id}"`);
159
+ }
160
+ out.boards.push({ id, content: normalized });
161
+ } else if (owned.has(id)) out.boards.push(owned.get(id));
162
+ }
163
+ for (const { id, data } of await invitesOf(store, subject, resourceType)) {
139
164
  const { resourceId, role, expiresAt, acceptedAt, revokedAt } = data;
140
165
  out.invites.push(...plain([{ id, resourceId, role, expiresAt, acceptedAt, revokedAt }]));
141
166
  }
142
- out.audit.push(...plain((await auditOf(store, subject)).map((r) => r.data)));
167
+ out.audit.push(...plain((await auditOf(store, subject, resourceType)).map((r) => r.data)));
143
168
  return out;
144
169
  },
145
170
  async eraseAccount(subject, deps) {
146
171
  check(subject, deps);
147
172
  const { store } = deps;
173
+ const resourceType = deps.resourceType ?? RESOURCE_TYPE;
174
+ const collection = deps.collection ?? "boards";
148
175
  const sharing = createSharing(store);
149
176
  const total = { boards: 0, grants: 0, invites: 0, authored_elements: 0, edited_elements: 0, titles: 0, audit: 0 };
150
- const ctx = { principalId: subject.id, kind: subject.kind, chain: [{ id: subject.id, kind: subject.kind }], scopes: [`${RESOURCE_TYPE}:admin`] };
151
- const mine = await held(store, subject);
177
+ const ctx = { principalId: subject.id, kind: subject.kind, chain: [{ id: subject.id, kind: subject.kind }], scopes: [`${resourceType}:admin`] };
178
+ const mine = await held(store, subject, resourceType);
152
179
  const stamped = [];
153
- for await (const { id, board } of boards(store, deps.saveRoom)) {
180
+ for await (const { id, board } of boards(store, deps)) {
154
181
  if (mine.sole.has(id)) continue;
155
182
  const found = board.stampedBy(subject);
156
183
  if (found.authored.length > 0 || found.edited.length > 0 || found.titleBy) stamped.push(id);
@@ -159,18 +186,22 @@ var canvasAccount = {
159
186
  if ((await rows(store, "resources", { parentId: id })).length > 0) throw new CanvasError("INVALID", `board "${id}" has child resources: remove them first`);
160
187
  if (!(await sharing.can(ctx, "admin", id)).allowed) throw new CanvasError("INVALID", `the owner grant on board "${id}" is not valid: an operator repairs it first`);
161
188
  }
162
- for (const { id, data } of await auditOf(store, subject)) {
189
+ for (const { id, data } of await auditOf(store, subject, resourceType)) {
163
190
  await store.put("audit", id, scrub(data, subject));
164
191
  total.audit += 1;
165
192
  }
166
- for (const { id } of await invitesOf(store, subject)) {
193
+ for (const { id } of await invitesOf(store, subject, resourceType)) {
167
194
  await store.delete("invites", id);
168
195
  total.invites += 1;
169
196
  }
170
197
  for (const id of mine.sole) {
171
- await deps.eraseRoom?.(id);
172
- await store.delete("boards", id);
173
- await deps.eraseRoom?.(id);
198
+ if (deps.contentSource) await deps.contentSource.eraseContent(id);
199
+ else {
200
+ await deps.eraseRoom?.(id);
201
+ const row = await store.get(collection, id);
202
+ if (row) await store.delete(collection, id);
203
+ await deps.eraseRoom?.(id);
204
+ }
174
205
  await sharing.deleteResource(ctx, id).catch((e) => {
175
206
  if (e instanceof Error && (e.name === "EraseRefusedError" || e.message === "forbidden")) throw new CanvasError("INVALID", e.message);
176
207
  throw e;
@@ -179,7 +210,7 @@ var canvasAccount = {
179
210
  }
180
211
  for (const id of stamped) {
181
212
  const done = await store.transaction(async (tx) => {
182
- const doc = await tx.get("boards", id);
213
+ const doc = await tx.get(collection, id);
183
214
  if (!doc) return null;
184
215
  let board;
185
216
  try {
@@ -188,7 +219,7 @@ var canvasAccount = {
188
219
  throw new CanvasError("INVALID", `stored board "${id}" is damaged`);
189
220
  }
190
221
  const counts = board.eraseStamps(subject, { kind: subject.kind, id: ERASED });
191
- await tx.put("boards", id, { update: toBase64(board.encode()) });
222
+ await tx.put(collection, id, { ...doc, update: toBase64(board.encode()) });
192
223
  return counts;
193
224
  });
194
225
  await deps.eraseRoom?.(id);
@@ -6,7 +6,7 @@ import {
6
6
 
7
7
  // src/version.ts
8
8
  import { createRequire } from "module";
9
- var VERSION = true ? "0.14.3" : readPackageVersion();
9
+ var VERSION = true ? "0.15.1" : readPackageVersion();
10
10
 
11
11
  // src/brand.ts
12
12
  var BRAND = {
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  operatorActor,
6
6
  runMcp,
7
7
  svgToPng
8
- } from "./chunk-BZZKCECH.js";
8
+ } from "./chunk-LHQ2H4BF.js";
9
9
  import {
10
10
  openLocalStore
11
11
  } from "./chunk-NRLF6MGR.js";
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  operatorActor,
7
7
  runMcp,
8
8
  svgToPng
9
- } from "./chunk-BZZKCECH.js";
9
+ } from "./chunk-LHQ2H4BF.js";
10
10
  import {
11
11
  createFileStore,
12
12
  openLocalStore
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-canvas",
3
- "version": "0.14.3",
3
+ "version": "0.15.1",
4
4
  "description": "A shared whiteboard for diagrams, drawn by people and agents.",
5
5
  "keywords": [
6
6
  "whiteboard",
@@ -87,9 +87,9 @@
87
87
  "dependencies": {
88
88
  "@dagrejs/dagre": "^3.1.3",
89
89
  "@modelcontextprotocol/sdk": "^1.12.0",
90
- "agentic-design": ">=0.8.0 <0.10.0",
91
- "agentic-identity": ">=0.9.0 <0.11.0",
92
- "agentic-realtime": ">=0.4.4 <0.6.0",
90
+ "agentic-design": ">=0.8.0 <0.11.0",
91
+ "agentic-identity": ">=0.9.0 <0.12.0",
92
+ "agentic-realtime": ">=0.4.4 <0.7.0",
93
93
  "yjs": "^13.6.0",
94
94
  "zod": "^4.6.7"
95
95
  },
@@ -127,7 +127,10 @@
127
127
  "typescript": "^5.6.0",
128
128
  "vite": "^7.3.7",
129
129
  "vitest": "^3.0.0",
130
- "wrangler": "^4.155.0"
130
+ "wrangler": "^4.155.0",
131
+ "agentic-design-current": "npm:agentic-design@0.10.1",
132
+ "agentic-identity-current": "npm:agentic-identity@0.11.0",
133
+ "agentic-realtime-current": "npm:agentic-realtime@0.6.0"
131
134
  },
132
135
  "overrides": {
133
136
  "zod": "$zod"