agentic-canvas 0.12.1 → 0.14.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,7 +7,7 @@
7
7
 
8
8
  A shared whiteboard for diagrams, drawn by people and agents.
9
9
 
10
- > Status: 0.12.1. The board model, the agent operations, the exports, the CLI, the
10
+ > Status: 0.14.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.
@@ -38,7 +38,7 @@ npx agentic-canvas export demo --out demo.svg
38
38
  npx agentic-canvas export demo --format png --out demo.png
39
39
  ```
40
40
 
41
- Other commands: `list`, `add`, `connect`, `update`, `move`, `rm`, `delete` (a whole board), and `access`, `grant`, `revoke`, `invite` (sharing, server only). Run `npx agentic-canvas --help`.
41
+ Other commands: `list`, `add`, `connect`, `update`, `move`, `rm`, `delete` (a whole board), and `access`, `grant`, `revoke`, `invite`, `links`, `link`, `revoke-link` (sharing, server only). Run `npx agentic-canvas --help`.
42
42
  `repair` is for the owner only. It needs `CANVAS_ACTOR=human:<id>`.
43
43
  Boards live in `./.agentic-canvas` (or `--dir`, or `CANVAS_DIR`). Every write
44
44
  needs an actor in `CANVAS_ACTOR`, as `<kind>:<id>`. The operator sets it. A write
@@ -52,7 +52,7 @@ CANVAS_ACTOR=agent:helper npx agentic-canvas --dir ./boards mcp
52
52
  ```
53
53
 
54
54
  The server speaks MCP on stdio. It has one tool for each CLI command: `create_board`,
55
- `delete_board`, `list_boards`, `read_board`, `add_shape`, `connect`, `update_element`, `move`, `remove`, `draw_flow`, `export_svg`, `export_png`, and the sharing tools `list_access`, `grant_access`, `revoke_access`, and `create_invite`. `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
55
+ `delete_board`, `list_boards`, `read_board`, `add_shape`, `connect`, `update_element`, `move`, `remove`, `draw_flow`, `export_svg`, `export_png`, and the sharing tools `list_access`, `grant_access`, `revoke_access`, `create_invite`, `list_links`, `create_link`, and `revoke_link`. `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.
58
58
 
@@ -183,7 +183,7 @@ Roles, from least to most: `viewer`, `commenter`, `editor`, `owner`.
183
183
  |---|---|
184
184
  | viewer, commenter | Read the board, its SVG, and join its live room to read. A board has no comments yet, so a commenter reads as a viewer. |
185
185
  | editor | Also draw: add, update, move, and remove elements. |
186
- | owner | Also delete the board, grant and revoke roles, list access, invite, and make or revoke share links. |
186
+ | owner | Also delete the board, grant and revoke roles, list access, invite, and list, make, or revoke share links. |
187
187
 
188
188
  A role never exceeds the scopes of the credential. An agent of an owner holds the owner's role only inside its own
189
189
  scopes: it needs `canvas:admin` to share, and `canvas:write` to draw. A caller with no role on a board gets 404, so a read or a write tells a stranger nothing. (A create with an id that is taken answers 409.) A caller with a role that is too low gets 403. `GET /boards` lists the boards you hold a role on.
@@ -197,6 +197,7 @@ HTTP routes, under `/api/v1/canvas`. Each needs a bearer token, except the link
197
197
  | `DELETE /boards/:id/grants?subjectKind=&subjectId=` | Owner. Revoke. The last owner cannot be removed. |
198
198
  | `POST /boards/:id/invites` | Owner. Body `{ role, email }` or `{ role, handle }`, and optional `expiresInMs`. The role is not `owner`. Returns `{ id }`. |
199
199
  | `POST /invites/accept` | Body `{ id }`. The caller must be the person (verified email) or the agent (handle) the invite names. |
200
+ | `GET /boards/:id/links` | Owner. The links of the board: `{ id, role, signedInOnly, expiresAt?, revokedAt? }`. No token. Up to 200, and `truncated: true` if there are more. `skipped` counts damaged records left out. |
200
201
  | `POST /boards/:id/links` | Owner. Body `{ role, signedInOnly?, expiresInMs? }`. Returns `{ id, token }`. The token shows once. |
201
202
  | `DELETE /boards/:id/links/:link` | Owner. Revoke a link of this board. |
202
203
  | `GET /shared/:id` and `GET /shared/:id/svg` | No bearer token needed. Send the link token in `x-link-token`. Read only. |
@@ -212,6 +213,9 @@ await canvas.grant("demo", { subjectKind: "agent", subjectId: "helper", role: "e
212
213
  await canvas.invite("demo", { role: "viewer", email: "ada@example.com", expiresInMs: 86_400_000 });
213
214
  const { grants, invites } = await canvas.access("demo"); // an invite shows no email
214
215
  await canvas.revoke("demo", { subjectKind: "agent", subjectId: "helper" });
216
+ const link = await canvas.createLink("demo", { role: "viewer", expiresInMs: 86_400_000 }); // link.token shows once
217
+ const { links } = await canvas.links("demo"); // no tokens
218
+ await canvas.revokeLink("demo", link.id);
215
219
  ```
216
220
 
217
221
  ```sh
@@ -220,16 +224,49 @@ npx agentic-canvas access demo
220
224
  npx agentic-canvas grant demo agent:helper --role editor
221
225
  npx agentic-canvas invite demo --role viewer --handle helper --expires-in-ms 86400000
222
226
  npx agentic-canvas revoke demo agent:helper
227
+ npx agentic-canvas link demo --role viewer --expires-in-ms 86400000 # prints the token once
228
+ npx agentic-canvas links demo
229
+ npx agentic-canvas revoke-link demo <link-id>
223
230
  ```
224
231
 
225
- With `CANVAS_SERVER` set, `npx agentic-canvas mcp` serves `list_access`, `grant_access`, `revoke_access`, and `create_invite` as well.
232
+ With `CANVAS_SERVER` set, `npx agentic-canvas mcp` serves `list_access`, `grant_access`, `revoke_access`, `create_invite`, `list_links`, `create_link`, and `revoke_link` as well.
226
233
  Each prints or returns the server's JSON. A refusal has the server's code, such as `NOT_FOUND`, `FORBIDDEN`, or `INVALID`.
227
- Share links and invite acceptance stay on the HTTP API.
234
+ Invite acceptance stays on the HTTP API.
228
235
 
229
236
  A live MCP server with an admin token can share boards: an agent that holds it can grant `owner` and revoke grants. Give an agent a token with `canvas:write` only, unless it must share.
230
237
 
231
238
  The static dev fallback holds no board roles. Its tokens reach every board and cannot share.
232
239
 
240
+ ## Account export and erase
241
+
242
+ A host that runs a person's data export and erase imports `canvasAccount` from `agentic-canvas/account`. It never reads the storage layout.
243
+
244
+ ```ts
245
+ import { canvasAccount } from "agentic-canvas/account";
246
+ const data = await canvasAccount.exportAccount({ kind: "human", id: "ada" }, { store, saveRoom, eraseRoom });
247
+ const counts = await canvasAccount.eraseAccount({ kind: "human", id: "ada" }, { store, saveRoom, eraseRoom });
248
+ ```
249
+
250
+ 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
+
252
+ - 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.
253
+ - On any other board, the subject's grant and invites go. The elements it made are deleted. A connector of someone else that was bound to one of them goes too, and no count or export lists it. The members of a deleted group move to its parent. The editor of an element it only edited becomes `(erased)`, and a title it set is cleared.
254
+ - Its audit entries (it acted, it roots the chain, or a grant named it) stay with the id set to `(erased)`. The kinds stay, so an entry still says if a machine acted.
255
+ - A second erase counts zero. Stop writes first: a write between export and erase is erased and not exported.
256
+ - The erase reads every stored board to find the subject's elements, before it changes data. It stops with `INVALID` on a board that does not decode, on a damaged grant of the subject, on a damaged invite of the subject, on a board with child resources, on an owner grant that the sharing records do not accept, on a damaged co-owner grant, and on an audit entry that names the subject, as the actor, the root of the chain, or the target, with an unknown kind. The checks may add audit entries about the subject: the erase scrubs them.
257
+ - Live rooms: give `saveRoom(boardId)`, which saves the room to the Store as the Worker does before a read. Without it, edits that no request has saved yet are not seen, and the reset below drops them. Give `eraseRoom(boardId)` too: the erase calls it for every board that it changes or deletes, so the room opens again from the Store.
258
+ - `boards` holds each stored entry as it is, with full images and groups. It includes the elements that others made on the board.
259
+ - Run the canvas erase before the identity erase of the person: the identity erase removes the grants that this erase finds boards by.
260
+ - The subject is any principal kind, for example `agent_of_human`: call once for each principal. The id follows the strict identity rule: an old board that has an id from the older, looser rule cannot be named.
261
+ - Known gaps:
262
+ - Invites and audit entries name a principal by id with no kind, so `human:ada` and `agent:ada` share them. Do not give two principals one id.
263
+ - An audit entry where the subject is only a middle step of the chain is not found.
264
+ - An invite that someone else made for the subject's email or handle stays. The package cannot map a principal to an address.
265
+ - Image bytes in the image store have no uploader record, so they are not exported or erased. Only the `blob:` key in an element is.
266
+ - A deleted element leaves its id in the stored Yjs history. Its content is gone, and an id that the caller chose can stay in the stored bytes.
267
+ - A connector of someone else that the subject edited, and that goes with a deleted shape, is in `edited_elements` of the export and not in its erase count.
268
+ - The shape is provisional. It is the `AccountPackage` contract that a later `agentic-identity` release adds to `agentic-identity/account`. The `AccountApp` of 0.9.0 is a different contract, and this does not fit it.
269
+
233
270
  ## UI
234
271
 
235
272
  The Worker serves a small web UI at its root. It uses the published `agentic-design` package for
@@ -431,7 +468,7 @@ old author stays on its elements. Only new writes follow the strict rule.
431
468
  |---|---|
432
469
  | `createClient(options)`, `CanvasApiError` | The remote client. See Remote client above. |
433
470
  | `parseSubject(text)` | Reads `human:<id>` or `agent:<id>` as `{ subjectKind, subjectId }`. It throws a `TypeError` for any other text. |
434
- | `BoardSharing` (type), `BoardRole` (type), `SubjectKind` (type), `Subject` (type), `BoardGrant` (type), `BoardInvite` (type), `InviteInput` (type) | Types for sharing. |
471
+ | `BoardSharing` (type), `BoardRole` (type), `SubjectKind` (type), `Subject` (type), `BoardGrant` (type), `BoardInvite` (type), `InviteInput` (type), `BoardLink` (type), `LinkInput` (type) | Types for sharing. |
435
472
  | `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`. |
436
473
 
437
474
  ### `agentic-canvas/file-store`
@@ -0,0 +1,33 @@
1
+ import { Store, Json } from 'agentic-identity';
2
+ import { P as Principal } from './principal-blhsFbdS.js';
3
+ import 'zod';
4
+
5
+ interface CanvasAccountDeps {
6
+ /** The Store that holds the boards and the sharing records. */
7
+ 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. */
9
+ saveRoom?: (boardId: string) => Promise<void>;
10
+ /** 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
+ eraseRoom?: (boardId: string) => Promise<void>;
12
+ }
13
+ type CanvasCollection = "boards" | "grants" | "invites" | "authored_elements" | "edited_elements" | "titles" | "audit";
14
+ interface CanvasAccount {
15
+ id: "canvas";
16
+ /**
17
+ * Collections: `boards` (each board the subject is the only owner of, with its elements), `grants` (every role the subject
18
+ * holds on a board), `invites` (made by the subject, with no email), `authored_elements` (made by the subject on a board of someone else, with
19
+ * its `boardId`), `edited_elements` (made by someone else, last edited by the subject: `boardId` and `elementId`), `titles` (boards
20
+ * of someone else whose title the subject set), and `audit` (entries on a board where the subject acted as the principal, as the root of the chain, or was the target of a grant, as stored). It holds no format, version, or time.
21
+ */
22
+ exportAccount(subject: Principal, deps: CanvasAccountDeps): Promise<Record<CanvasCollection, Json[]>>;
23
+ /**
24
+ * Deletes the boards the subject is the only owner of, with their grants, invites, links, and audit entries. On every other
25
+ * board it removes the subject's grant and invites, deletes the elements the subject made, sets the editor of the
26
+ * elements it edited to `(erased)`, clears a title it set, and sets its audit entries to `(erased)`. Counts by the same collection
27
+ * names. A second run counts zero. Run it with writes stopped: a write after the export is erased and not exported.
28
+ */
29
+ eraseAccount(subject: Principal, deps: CanvasAccountDeps): Promise<Record<CanvasCollection, number>>;
30
+ }
31
+ declare const canvasAccount: CanvasAccount;
32
+
33
+ export { type CanvasAccount, type CanvasAccountDeps, type CanvasCollection, canvasAccount };
@@ -0,0 +1,213 @@
1
+ import {
2
+ RESOURCE_TYPE
3
+ } from "./chunk-A2F73EQJ.js";
4
+ import {
5
+ fromBase64,
6
+ isBoardId,
7
+ toBase64
8
+ } from "./chunk-GLNF43N4.js";
9
+ import "./chunk-NWQED42L.js";
10
+ import "./chunk-BKG3FXX3.js";
11
+ import {
12
+ Board,
13
+ CanvasError,
14
+ PRINCIPAL_KINDS,
15
+ principalSchema
16
+ } from "./chunk-ZP6BLCB7.js";
17
+
18
+ // src/account.ts
19
+ import { createSharing, ROLES, SUBJECT_KINDS } from "agentic-identity";
20
+ var ERASED = "(erased)";
21
+ var PAGE = 100;
22
+ async function* pages(store, collection, where) {
23
+ let cursor;
24
+ do {
25
+ const page = await store.query(collection, { where, limit: PAGE, ...cursor === void 0 ? {} : { cursor } });
26
+ yield* page.items;
27
+ cursor = page.nextCursor;
28
+ } while (cursor !== void 0);
29
+ }
30
+ var rows = async (store, collection, where) => {
31
+ const out = [];
32
+ for await (const row of pages(store, collection, where)) out.push(row);
33
+ return out;
34
+ };
35
+ var plain = (value) => JSON.parse(JSON.stringify(value));
36
+ function check(subject, deps) {
37
+ if (typeof deps?.store?.query !== "function") throw new CanvasError("INVALID", "canvasAccount needs deps.store");
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
+ if (!principalSchema.safeParse(subject).success) throw new CanvasError("INVALID", "subject needs an id and a known kind");
40
+ }
41
+ async function held(store, subject) {
42
+ const all = [];
43
+ const sole = /* @__PURE__ */ new Set();
44
+ for await (const { id: grantId, data } of pages(store, "grants", { subjectKind: subject.kind, subjectId: subject.id })) {
45
+ const { resourceId: id, role } = data;
46
+ if (typeof id !== "string" || typeof role !== "string" || !ROLES.includes(role)) throw new CanvasError("INVALID", `grant "${grantId}" of the subject is damaged`);
47
+ const resource = await store.get("resources", id);
48
+ if (resource === null ? !isBoardId(id) : resource.type !== RESOURCE_TYPE) continue;
49
+ all.push({ grantId, resourceId: id, role });
50
+ if (resource === null || role !== "owner") continue;
51
+ const owners = await rows(store, "grants", { resourceId: id, role: "owner" });
52
+ if (owners.some((o) => typeof o.data.subjectId !== "string" || !SUBJECT_KINDS.includes(o.data.subjectKind))) throw new CanvasError("INVALID", `an owner grant on board "${id}" is damaged`);
53
+ if (owners.every((o) => o.data.subjectKind === subject.kind && o.data.subjectId === subject.id)) sole.add(id);
54
+ }
55
+ return { all, sole };
56
+ }
57
+ async function* boards(store, saveRoom) {
58
+ let cursor;
59
+ do {
60
+ const page = await store.query("boards", { limit: PAGE, ...cursor === void 0 ? {} : { cursor } });
61
+ for (const item of page.items) {
62
+ const { id } = item;
63
+ let data = item.data;
64
+ if (saveRoom) {
65
+ await saveRoom(id);
66
+ data = await store.get("boards", id) ?? data;
67
+ }
68
+ try {
69
+ yield { id, board: Board.open(fromBase64(String(data.update))) };
70
+ } catch {
71
+ throw new CanvasError("INVALID", `stored board "${id}" is damaged`);
72
+ }
73
+ }
74
+ cursor = page.nextCursor;
75
+ } while (cursor !== void 0);
76
+ }
77
+ var isSubject = (value, subject) => typeof value === "object" && value !== null && value.kind === subject.kind && value.id === subject.id;
78
+ async function auditOf(store, subject) {
79
+ const found = /* @__PURE__ */ new Map();
80
+ for (const field of ["principalId", "rootId", "targetId"]) for (const row of await rows(store, "audit", { [field]: subject.id })) found.set(row.id, row);
81
+ const out = [];
82
+ const known = (kind, extra = []) => PRINCIPAL_KINDS.includes(kind) || extra.includes(kind);
83
+ for (const row of found.values()) {
84
+ const { principalId, principalKind, chain, rootId, resourceId, targetKind, targetId } = row.data;
85
+ if (typeof resourceId !== "string" || !isBoardId(resourceId)) continue;
86
+ const resource = await store.get("resources", resourceId);
87
+ if (resource !== null && resource.type !== RESOURCE_TYPE) continue;
88
+ const first = Array.isArray(chain) ? chain[0] : null;
89
+ 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
+ if (damaged) throw new CanvasError("INVALID", `audit entry "${row.id}" is damaged`);
91
+ if (isSubject({ kind: principalKind, id: principalId }, subject) || isSubject(first, subject) || isSubject({ kind: targetKind, id: targetId }, subject)) out.push(row);
92
+ }
93
+ return out;
94
+ }
95
+ async function invitesOf(store, subject) {
96
+ const out = [];
97
+ for (const row of await rows(store, "invites", { createdBy: subject.id })) {
98
+ const { resourceId } = row.data;
99
+ if (typeof resourceId !== "string") throw new CanvasError("INVALID", `invite "${row.id}" of the subject is damaged`);
100
+ if (!isBoardId(resourceId)) continue;
101
+ const resource = await store.get("resources", resourceId);
102
+ if (resource === null || resource.type === RESOURCE_TYPE) out.push(row);
103
+ }
104
+ return out;
105
+ }
106
+ function scrub(data, subject) {
107
+ const erase = (step) => isSubject(step, subject) ? { ...step, id: ERASED } : step;
108
+ return {
109
+ ...data,
110
+ ...isSubject({ kind: data.principalKind, id: data.principalId }, subject) ? { principalId: ERASED } : {},
111
+ ...data.rootId === subject.id ? { rootId: ERASED } : {},
112
+ ...isSubject({ kind: data.targetKind, id: data.targetId }, subject) ? { targetId: ERASED } : {},
113
+ ...Array.isArray(data.chain) ? { chain: data.chain.map(erase) } : {}
114
+ };
115
+ }
116
+ var canvasAccount = {
117
+ id: "canvas",
118
+ async exportAccount(subject, deps) {
119
+ check(subject, deps);
120
+ const { store } = deps;
121
+ const out = { boards: [], grants: [], invites: [], authored_elements: [], edited_elements: [], titles: [], audit: [] };
122
+ const mine = await held(store, subject);
123
+ const owned = /* @__PURE__ */ new Map();
124
+ for await (const { id, board } of boards(store, deps.saveRoom)) {
125
+ if (mine.sole.has(id)) {
126
+ owned.set(id, plain([{ id, title: board.title, elements: board.rawElements() }])[0]);
127
+ continue;
128
+ }
129
+ const found = board.stampedBy(subject);
130
+ out.authored_elements.push(...plain(found.authored.map((element) => ({ boardId: id, element }))));
131
+ out.edited_elements.push(...plain(found.edited.map((elementId) => ({ boardId: id, elementId }))));
132
+ if (found.titleBy) out.titles.push(...plain([{ boardId: id, title: board.title }]));
133
+ }
134
+ for (const g of mine.all) {
135
+ out.grants.push(...plain([{ resourceId: g.resourceId, subjectKind: subject.kind, subjectId: subject.id, role: g.role }]));
136
+ }
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)) {
139
+ const { resourceId, role, expiresAt, acceptedAt, revokedAt } = data;
140
+ out.invites.push(...plain([{ id, resourceId, role, expiresAt, acceptedAt, revokedAt }]));
141
+ }
142
+ out.audit.push(...plain((await auditOf(store, subject)).map((r) => r.data)));
143
+ return out;
144
+ },
145
+ async eraseAccount(subject, deps) {
146
+ check(subject, deps);
147
+ const { store } = deps;
148
+ const sharing = createSharing(store);
149
+ 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);
152
+ const stamped = [];
153
+ for await (const { id, board } of boards(store, deps.saveRoom)) {
154
+ if (mine.sole.has(id)) continue;
155
+ const found = board.stampedBy(subject);
156
+ if (found.authored.length > 0 || found.edited.length > 0 || found.titleBy) stamped.push(id);
157
+ }
158
+ for (const id of mine.sole) {
159
+ if ((await rows(store, "resources", { parentId: id })).length > 0) throw new CanvasError("INVALID", `board "${id}" has child resources: remove them first`);
160
+ 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
+ }
162
+ for (const { id, data } of await auditOf(store, subject)) {
163
+ await store.put("audit", id, scrub(data, subject));
164
+ total.audit += 1;
165
+ }
166
+ for (const { id } of await invitesOf(store, subject)) {
167
+ await store.delete("invites", id);
168
+ total.invites += 1;
169
+ }
170
+ for (const id of mine.sole) {
171
+ await deps.eraseRoom?.(id);
172
+ await store.delete("boards", id);
173
+ await deps.eraseRoom?.(id);
174
+ await sharing.deleteResource(ctx, id).catch((e) => {
175
+ if (e instanceof Error && (e.name === "EraseRefusedError" || e.message === "forbidden")) throw new CanvasError("INVALID", e.message);
176
+ throw e;
177
+ });
178
+ total.boards += 1;
179
+ }
180
+ for (const id of stamped) {
181
+ const done = await store.transaction(async (tx) => {
182
+ const doc = await tx.get("boards", id);
183
+ if (!doc) return null;
184
+ let board;
185
+ try {
186
+ board = Board.open(fromBase64(String(doc.update)));
187
+ } catch {
188
+ throw new CanvasError("INVALID", `stored board "${id}" is damaged`);
189
+ }
190
+ const counts = board.eraseStamps(subject, { kind: subject.kind, id: ERASED });
191
+ await tx.put("boards", id, { update: toBase64(board.encode()) });
192
+ return counts;
193
+ });
194
+ await deps.eraseRoom?.(id);
195
+ if (!done) continue;
196
+ total.authored_elements += done.authored;
197
+ total.edited_elements += done.edited;
198
+ total.titles += done.title ? 1 : 0;
199
+ }
200
+ for (const g of mine.all) {
201
+ if (mine.sole.has(g.resourceId)) {
202
+ total.grants += 1;
203
+ continue;
204
+ }
205
+ await store.delete("grants", g.grantId);
206
+ total.grants += 1;
207
+ }
208
+ return total;
209
+ }
210
+ };
211
+ export {
212
+ canvasAccount
213
+ };
@@ -1,15 +1,16 @@
1
+ import {
2
+ CanvasAccess
3
+ } from "./chunk-A2F73EQJ.js";
1
4
  import {
2
5
  CanvasService,
3
- MAX_LIVE_BYTES,
4
- isBoardId,
5
- isStoreId
6
- } from "./chunk-Z6KLGAFP.js";
6
+ MAX_LIVE_BYTES
7
+ } from "./chunk-GLNF43N4.js";
7
8
  import {
8
9
  CanvasError
9
- } from "./chunk-MVWUIBGV.js";
10
+ } from "./chunk-ZP6BLCB7.js";
10
11
 
11
12
  // src/worker.ts
12
- import { createStoreAuth, hasScope as hasScope2, parsePrincipal, parseRegion } from "agentic-identity";
13
+ import { createStoreAuth, hasScope, parsePrincipal, parseRegion } from "agentic-identity";
13
14
  import { createD1Store, createR2Blobs, D1_SCHEMA } from "agentic-identity/cloudflare";
14
15
  import { acceptWebSocketProtocol, principalFromWebSocketRequest } from "agentic-identity/http";
15
16
 
@@ -1790,150 +1791,6 @@ var Hono3 = class extends Hono {
1790
1791
  }
1791
1792
  };
1792
1793
 
1793
- // src/access.ts
1794
- import { createSharing, EraseRefusedError, hasScope, ROLES, SUBJECT_KINDS } from "agentic-identity";
1795
- var RESOURCE_TYPE = "canvas";
1796
- var SHARE_ROLES = ROLES.filter((r) => r !== "owner");
1797
- var BAD_INPUT = /^(give an email or a handle|invalid handle|invalid email|invalid subject( kind "[^"]*")?|the last owner cannot be removed|expiresInMs must be .*|expiresAt must be .*|invalid role .*)$/;
1798
- function refuse(error) {
1799
- if (error instanceof EraseRefusedError) throw new CanvasError("INVALID", error.message);
1800
- if (error instanceof Error && error.message === "forbidden") throw new CanvasError("FORBIDDEN", "you may not do this on this board");
1801
- if (error instanceof Error && BAD_INPUT.test(error.message)) throw new CanvasError("INVALID", error.message);
1802
- throw error;
1803
- }
1804
- var oneOf = (value, list, name) => {
1805
- if (typeof value !== "string" || !list.includes(value)) throw new CanvasError("INVALID", `${name} must be one of: ${list.join(", ")}`);
1806
- return value;
1807
- };
1808
- var storeId = (value, name) => {
1809
- if (typeof value !== "string" || !isStoreId(value)) throw new CanvasError("INVALID", `${name} must be 1 to 128 of A-Z, a-z, 0-9, _ and -, and not like __x__`);
1810
- return value;
1811
- };
1812
- var word = (value, name) => {
1813
- if (typeof value !== "string" || value === "") throw new CanvasError("INVALID", `${name} must be a non-empty string`);
1814
- return value;
1815
- };
1816
- var lifetime = (value, name) => {
1817
- if (value === void 0) return void 0;
1818
- if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new CanvasError("INVALID", `${name} must be a whole number of milliseconds above 0`);
1819
- return value;
1820
- };
1821
- var CanvasAccess = class {
1822
- constructor(store, options = {}) {
1823
- this.store = store;
1824
- this.sharing = createSharing(store, options);
1825
- this.now = options.now ?? Date.now;
1826
- }
1827
- store;
1828
- sharing;
1829
- now;
1830
- /** A board id that the Store refuses names no board. The gate checks this for a role holder: a token of the dev list skips the gate. */
1831
- known(id) {
1832
- if (!isBoardId(id)) throw new CanvasError("NOT_FOUND", `no board "${id}"`);
1833
- }
1834
- /** Make the actor the owner of a new board. */
1835
- async register(ctx, id) {
1836
- await this.sharing.createResource(ctx, { id, type: RESOURCE_TYPE }).catch((error) => {
1837
- if (error instanceof Error && error.message === "resource exists") throw new CanvasError("DUPLICATE_ID", `board "${id}" already exists`);
1838
- if (error instanceof Error && error.message === "resource id has old audit entries") throw new CanvasError("UNAVAILABLE", "this board id was used a moment ago. Try again, or use another id");
1839
- return refuse(error);
1840
- });
1841
- }
1842
- /** Remove the sharing records of a board. Needs an owner. */
1843
- async unregister(ctx, id) {
1844
- await this.sharing.deleteResource(ctx, id).catch(refuse);
1845
- }
1846
- /** True if the board has sharing records: it was made by an identity credential. */
1847
- async registered(id) {
1848
- if (!isBoardId(id)) return false;
1849
- return (await this.store.get("resources", id))?.type === RESOURCE_TYPE;
1850
- }
1851
- /**
1852
- * True if the caller may do `action` on the board. A link token adds the role of its link. It writes an audit entry.
1853
- * An id that belongs to a resource of another type, in a Store shared with another app, is no board: it is denied.
1854
- */
1855
- async allowed(ctx, action, id, linkToken) {
1856
- if (!await this.registered(id)) return false;
1857
- return (await this.sharing.can(ctx, action, id, linkToken === void 0 ? {} : { linkToken })).allowed;
1858
- }
1859
- /** Throw NOT_FOUND for a caller who cannot read the board, and FORBIDDEN for one who can read it but not do `action`. */
1860
- async require(ctx, action, id, linkToken) {
1861
- if (await this.allowed(ctx, action, id, linkToken)) return;
1862
- if (action === "read" || !await this.allowed(ctx, "read", id, linkToken)) throw new CanvasError("NOT_FOUND", `no board "${id}"`);
1863
- throw new CanvasError("FORBIDDEN", `this needs the ${action === "admin" ? "owner" : "editor"} role on the board`);
1864
- }
1865
- /** The ids of the boards a person or agent holds a role on, the first 100. A credential without `canvas:read` gets none. */
1866
- async readable(ctx) {
1867
- const root = ctx.chain[0];
1868
- if (!root || !SUBJECT_KINDS.includes(root.kind) || !hasScope(ctx.scopes, `${RESOURCE_TYPE}:read`)) return [];
1869
- const ids = [];
1870
- let cursor;
1871
- for (let pages = 0; pages < 10 && ids.length < 100; pages++) {
1872
- const page = await this.store.query("grants", { where: { subjectKind: root.kind, subjectId: root.id }, limit: 100, ...cursor === void 0 ? {} : { cursor } });
1873
- for (const { data } of page.items) {
1874
- if (ids.length >= 100 || typeof data.resourceId !== "string" || !ROLES.includes(data.role)) continue;
1875
- if (await this.registered(data.resourceId)) ids.push(data.resourceId);
1876
- }
1877
- if ((cursor = page.nextCursor) === void 0) break;
1878
- }
1879
- return ids;
1880
- }
1881
- async grant(ctx, id, input) {
1882
- this.known(id);
1883
- const subject = { kind: oneOf(input.subjectKind, SUBJECT_KINDS, "subjectKind"), id: storeId(input.subjectId, "subjectId") };
1884
- await this.sharing.grant(ctx, id, subject, oneOf(input.role, ROLES, "role")).catch(refuse);
1885
- return { ok: true };
1886
- }
1887
- async revoke(ctx, id, input) {
1888
- this.known(id);
1889
- const subject = { kind: oneOf(input.subjectKind, SUBJECT_KINDS, "subjectKind"), id: storeId(input.subjectId, "subjectId") };
1890
- await this.sharing.revokeGrant(ctx, id, subject).catch(refuse);
1891
- return { ok: true };
1892
- }
1893
- /** The grants and the invites on a board. An invite never shows its email. Links are not listed: a token shows once. */
1894
- async list(ctx, id) {
1895
- this.known(id);
1896
- const [grants, invites] = await Promise.all([this.sharing.listGrants(ctx, id, { limit: 200 }), this.sharing.listInvites(ctx, id, { limit: 200 })]).catch(refuse);
1897
- return { grants: grants.grants, invites: invites.invites };
1898
- }
1899
- async invite(ctx, id, input) {
1900
- this.known(id);
1901
- const role = oneOf(input.role, SHARE_ROLES, "role");
1902
- const expiresInMs = lifetime(input.expiresInMs, "expiresInMs");
1903
- const who = input.email !== void 0 ? { email: word(input.email, "email") } : input.handle !== void 0 ? { handle: word(input.handle, "handle") } : {};
1904
- if (input.email !== void 0 && input.handle !== void 0) throw new CanvasError("INVALID", "give an email or a handle, not both");
1905
- return this.sharing.createInvite(ctx, id, { role, ...who, ...expiresInMs === void 0 ? {} : { expiresInMs } }).catch(refuse);
1906
- }
1907
- /** The email comes from a verified sign-in and the handle from the agent's own record, never from the request. */
1908
- async accept(ctx, inviteId) {
1909
- if (!isStoreId(inviteId)) throw new CanvasError("FORBIDDEN", "you may not do this on this board");
1910
- const user = await this.store.get("users", ctx.principalId);
1911
- const agent = await this.store.get("principals", ctx.principalId);
1912
- const identity = {
1913
- ...user?.emailVerified === true && typeof user.email === "string" ? { email: user.email } : {},
1914
- ...typeof agent?.handle === "string" ? { handle: agent.handle } : {}
1915
- };
1916
- await this.sharing.acceptInvite(ctx, word(inviteId, "id"), identity).catch(refuse);
1917
- return { ok: true };
1918
- }
1919
- /** The token shows once. Only its hash is stored. */
1920
- async createLink(ctx, id, input) {
1921
- this.known(id);
1922
- const role = oneOf(input.role, SHARE_ROLES, "role");
1923
- const expiresInMs = lifetime(input.expiresInMs, "expiresInMs");
1924
- if (input.signedInOnly !== void 0 && typeof input.signedInOnly !== "boolean") throw new CanvasError("INVALID", "signedInOnly must be true or false");
1925
- return this.sharing.createLink(ctx, id, { role, signedInOnly: input.signedInOnly === true, ...expiresInMs === void 0 ? {} : { expiresAt: this.now() + expiresInMs } }).catch(refuse);
1926
- }
1927
- /** Revoke a link of this board. A link of another board is NOT_FOUND here, even for an owner of both. */
1928
- async revokeLink(ctx, id, linkId) {
1929
- this.known(id);
1930
- const link = isStoreId(linkId) ? await this.store.get("links", linkId) : null;
1931
- if (!link || link.resourceId !== id) throw new CanvasError("NOT_FOUND", "no such link on this board");
1932
- await this.sharing.revokeLink(ctx, linkId).catch(refuse);
1933
- return { ok: true };
1934
- }
1935
- };
1936
-
1937
1794
  // src/room-sync.ts
1938
1795
  import * as Y from "yjs";
1939
1796
  var SEEDER = { kind: "agent", id: "canvas-room" };
@@ -2171,7 +2028,7 @@ async function identify(request, env, store) {
2171
2028
  const found = await principalFromWebSocketRequest(request, auth, { protocol: WS_PROTOCOL, scope: "canvas:read" });
2172
2029
  if (failure() !== void 0) throw failure();
2173
2030
  if (found instanceof Response) return null;
2174
- return { actor: { kind: found.kind, id: found.principalId }, role: hasScope2(found.scopes, "canvas:write") ? "editor" : "viewer", principal: found, dev: dev() };
2031
+ return { actor: { kind: found.kind, id: found.principalId }, role: hasScope(found.scopes, "canvas:write") ? "editor" : "viewer", principal: found, dev: dev() };
2175
2032
  }
2176
2033
  var svgResponse = (svg) => new Response(svg, { headers: { "content-type": "image/svg+xml", "x-content-type-options": "nosniff", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; img-src data:" } });
2177
2034
  function sniffImage(bytes) {
@@ -2397,6 +2254,7 @@ function createApp(options = {}) {
2397
2254
  return c.json(done);
2398
2255
  });
2399
2256
  api.post("/boards/:id/invites", async (c) => c.json(await c.var.access.invite(c.var.caller.principal, c.req.param("id"), await jsonBody(c)), 201));
2257
+ api.get("/boards/:id/links", async (c) => c.json(await c.var.access.listLinks(c.var.caller.principal, c.req.param("id"))));
2400
2258
  api.post("/boards/:id/links", async (c) => c.json(await c.var.access.createLink(c.var.caller.principal, c.req.param("id"), await jsonBody(c)), 201));
2401
2259
  api.delete("/boards/:id/links/:link", async (c) => c.json(await c.var.access.revokeLink(c.var.caller.principal, c.req.param("id"), c.req.param("link"))));
2402
2260
  api.post("/invites/accept", async (c) => c.json(await c.var.access.accept(c.var.caller.principal, text((await jsonBody(c)).id, "id"))));