agentic-canvas 0.6.1 → 0.8.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.6.0. The board model, the agent operations, the exports, the CLI, the
10
+ > Status: 0.8.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`. Run `npx agentic-canvas --help`.
41
+ Other commands: `list`, `add`, `connect`, `update`, `move`, `rm`, `delete` (a whole board). 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
- `list_boards`, `read_board`, `add_shape`, `connect`, `update_element`, `move`, `remove`, `draw_flow`, `export_svg`, and
55
+ `delete_board`, `list_boards`, `read_board`, `add_shape`, `connect`, `update_element`, `move`, `remove`, `draw_flow`, `export_svg`, and
56
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
57
57
  `<kind>:<id>`. That actor stamps every write. No tool takes an actor, author, or kind, so a
58
58
  caller cannot choose who it writes as. An error comes back as a tool error with a code.
@@ -105,7 +105,7 @@ try {
105
105
  }
106
106
  ```
107
107
 
108
- There is one method per route: `me`, `listBoards`, `createBoard`, `readBoard`, `exportSvg`, `addShape`, `addConnector`,
108
+ There is one method per route: `me`, `listBoards`, `createBoard`, `readBoard`, `deleteBoard`, `exportSvg`, `addShape`, `addConnector`,
109
109
  `drawFlow`, `update`, `move`, `remove`, `uploadImage`, `getImage`, `state`, and `checkpoint`. Each takes
110
110
  `{ signal }` last, so an `AbortSignal` can stop a call. The token sets the actor. A failed call throws
111
111
  `CanvasApiError` with the API `code` and the HTTP `status`. A call that gets no reply has code `NETWORK`
@@ -125,12 +125,66 @@ no CI secret is used:
125
125
  ```sh
126
126
  npm ci && npm run build
127
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
128
+ npx wrangler secret put CANVAS_API_TOKENS -c wrangler.demo.jsonc # only to set or rotate the demo tokens
129
129
  ```
130
130
 
131
- `wrangler.demo.jsonc` holds the config. The tokens are a Worker secret and are not in the repo. To run your
131
+ `wrangler.demo.jsonc` holds the config. The demo is a throwaway: it turns on the dev fallback (see "Auth") and
132
+ signs in with a static token list. The tokens are a Worker secret and are not in the repo. To run your
132
133
  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
+ file, and deploy it the same way. Remove `CANVAS_UNSAFE_STATIC_TOKENS` from your copy unless it is a throwaway.
135
+
136
+ ## Auth
137
+
138
+ The Worker signs requests in with `agentic-identity` (`agentic-identity/http`). A bearer key (an `api_key` or
139
+ `pat` credential) is a credential in the same Store as the boards (D1). A `session` credential works as a
140
+ bearer key too, and as the `session` cookie for reads. A write or a socket that rides on the cookie gets 401,
141
+ because the Worker lists no allowed origins. A credential names a principal, and the principal's scopes set the role:
142
+
143
+ - Any scope that covers `canvas:write`, such as `canvas:write`, `canvas:admin`, `*:write`, or `*:admin`, is an editor.
144
+ - Any scope that covers `canvas:read`, such as `canvas:read` or `*:read`, is a viewer. A viewer reads and cannot change a board, over HTTP or in a live room.
145
+ - A principal with neither scope, an unknown, expired, or revoked credential, or a revoked parent in the
146
+ delegation chain, gets 401.
147
+
148
+ Make principals and credentials in the database of this Worker with `agentic-identity` (its Worker, or
149
+ `issueCredential` from its SDK). The two must share one D1 database and its schema (`D1_SCHEMA`). A credential
150
+ that lives in another database gets 401 here. The author kind of every write is the kind of the principal.
151
+ A browser sends its credential as the WebSocket sub-protocol `agentic-canvas.bearer.<token in base64url>`.
152
+
153
+ **Dev fallback.** `CANVAS_API_TOKENS` is a static list of tokens, as JSON: `{ "<token>": { "actor": "ada", "kind": "human", "role": "viewer" } }`.
154
+ It is read only when `CANVAS_UNSAFE_STATIC_TOKENS=1` is set too. Then it adds to the identity credentials: a
155
+ token that is not a credential is looked up in the list. A static token reaches every board and cannot be
156
+ revoked without a new deploy. It ignores revocation in the Store: a static grant named `ada` still writes as
157
+ `ada` after the identity credential of `ada` is revoked. Use it for local work and throwaway demos only. If `CANVAS_API_TOKENS` is set
158
+ and the switch is not, every API request gets 503 `NOT_CONFIGURED`, so a deploy never trusts the list by accident.
159
+
160
+ ## Sharing
161
+
162
+ A board is an `agentic-identity` resource of type `canvas`. The person or agent that makes a board owns it.
163
+ Roles, from least to most: `viewer`, `commenter`, `editor`, `owner`.
164
+
165
+ | Role | May do |
166
+ |---|---|
167
+ | 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. |
168
+ | editor | Also draw: add, update, move, and remove elements. |
169
+ | owner | Also delete the board, grant and revoke roles, list access, invite, and make or revoke share links. |
170
+
171
+ A role never exceeds the scopes of the credential. An agent of an owner holds the owner's role only inside its own
172
+ 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.
173
+
174
+ HTTP routes, under `/api/v1/canvas`. Each needs a bearer token, except the link read:
175
+
176
+ | Route | Does |
177
+ |---|---|
178
+ | `GET /boards/:id/access` | Owner. The grants and the invites. An invite shows no email. |
179
+ | `POST /boards/:id/grants` | Owner. Body `{ subjectKind: "human" or "agent", subjectId, role }`. |
180
+ | `DELETE /boards/:id/grants?subjectKind=&subjectId=` | Owner. Revoke. The last owner cannot be removed. |
181
+ | `POST /boards/:id/invites` | Owner. Body `{ role, email }` or `{ role, handle }`, and optional `expiresInMs`. The role is not `owner`. Returns `{ id }`. |
182
+ | `POST /invites/accept` | Body `{ id }`. The caller must be the person (verified email) or the agent (handle) the invite names. |
183
+ | `POST /boards/:id/links` | Owner. Body `{ role, signedInOnly?, expiresInMs? }`. Returns `{ id, token }`. The token shows once. |
184
+ | `DELETE /boards/:id/links/:link` | Owner. Revoke a link of this board. |
185
+ | `GET /shared/:id` and `GET /shared/:id/svg` | No bearer token needed. Send the link token in `x-link-token`. Read only. |
186
+
187
+ The static dev fallback holds no board roles. Its tokens reach every board and cannot share.
134
188
 
135
189
  ## UI
136
190
 
@@ -146,12 +200,13 @@ npm ci
146
200
  npm run build # writes the UI to ui-dist/
147
201
  cat > .dev.vars <<'EOF2'
148
202
  CANVAS_REGION=unknown
203
+ CANVAS_UNSAFE_STATIC_TOKENS=1
149
204
  CANVAS_API_TOKENS={"ada-token-0123456789-0123456789-0123456789":{"actor":"ada","kind":"human"}}
150
205
  EOF2
151
206
  WRANGLER_SEND_METRICS=false npx wrangler dev
152
207
  ```
153
208
 
154
- Open the address that wrangler prints and paste the token. A token has 32 characters or more. Its
209
+ `CANVAS_UNSAFE_STATIC_TOKENS=1` turns on the dev fallback: see "Auth". Open the address that wrangler prints and paste the token. A static token has 32 characters or more. Its
155
210
  grant sets the name, the kind (a person or an agent), and the role. A `viewer` token reads and
156
211
  cannot change a board. The token stays in the tab's session storage.
157
212
 
@@ -239,11 +294,11 @@ at the same time keep the last write. The board list shows the first 100 boards.
239
294
  - A CLI over a local file store. Several processes can write at once.
240
295
  - An MCP server (stdio) over the same service as the CLI.
241
296
  - A Worker (`agentic-canvas/worker`) under `/api/v1/canvas`. Boards live in D1 and images in R2
242
- (EU jurisdiction), through the shared `Store` and `Blobs` ports. Set `CANVAS_API_TOKENS`
243
- (a JSON map of token to `{ actor, kind }`, tokens of 32 characters or more) and `CANVAS_REGION`.
244
- Routes: `POST /boards`, `GET /boards/:id`, `GET /boards/:id/svg`, `POST /boards/:id/shapes`,
297
+ (EU jurisdiction), through the shared `Store` and `Blobs` ports. Set `CANVAS_REGION`. Callers sign in with
298
+ `agentic-identity` credentials (see "Auth" below). `CANVAS_API_TOKENS` is a dev fallback only.
299
+ Routes: `POST /boards`, `GET /boards/:id`, `DELETE /boards/:id`, `GET /boards/:id/svg`, `POST /boards/:id/shapes`,
245
300
  `/connectors`, `/flow`, `/move`, `GET /boards`, `PATCH /boards/:id/elements/:element`, `DELETE /boards/:id/elements/:element`, `POST /images`,
246
- `GET /images/:id`. An image is stored under the SHA-256 of its bytes, and the reply gives
301
+ `GET /images/:id`. An editor may delete a board. The Worker also erases its live room. An image is stored under the SHA-256 of its bytes, and the reply gives
247
302
  the `blob:` ref for a shape. A busy database or bucket answers 503: try again.
248
303
  - Live rooms (`agentic-canvas/live-worker`, on `agentic-realtime`). One Durable Object holds one
249
304
  board. A token may carry `"role": "viewer"`: a viewer reads and cannot write. `GET /boards/:id/ws`
@@ -304,9 +359,9 @@ old author stays on its elements. Only new writes follow the strict rule.
304
359
 
305
360
  | Name | What it is |
306
361
  |---|---|
307
- | `createFileStore(path, options?)`, `openLocalStore(dir)` | A store on one JSON file, or on `canvas.json` in a directory. |
308
- | `FileStoreError` | The error that the store throws. Its `code` is `LOCKED` or `DAMAGED`. |
309
- | `FileStoreOptions` (type) | `lockTimeoutMs` (default 10000) and `staleAfterMs` (default 30000), plus the adapter options. |
362
+ | `createFileStore(path, options?)`, `openLocalStore(dir)` | A store on one JSON file, or on `canvas.json` in a directory. It is the file store of agentic-identity. |
363
+ | `FileStoreError` | The error for a file that is not a store file, or a lock that stays held. Its `code` is `DAMAGED` or `LOCKED`. |
364
+ | `FileStoreOptions` (type) | The adapter options. The lock options `lockTimeoutMs` and `staleAfterMs` are gone. |
310
365
  | `STORE_FILE` | The file name that `openLocalStore` uses, `canvas.json`. |
311
366
 
312
367
  ### `agentic-canvas/live`
@@ -350,10 +405,13 @@ The stylesheet is a separate export, `agentic-canvas/ui.css`. Load `agentic-desi
350
405
 
351
406
  ## Limits
352
407
 
353
- - Canvas takes principal kinds, regions, and the Store and Blobs ports from `agentic-identity` 0.5.
354
- It does not use the sharing API yet. Its editor and viewer tokens are a separate table in
355
- `CANVAS_API_TOKENS`. It has no board delete or erase. When it adopts sharing, grants are keyed
356
- on kind and id, and a delete calls `deleteResource`.
408
+ - Canvas takes principal kinds, regions, and the Store and Blobs ports from `agentic-identity` 0.6.
409
+ - A board made before sharing has no owner. Identity credentials get 404 on it. Only the dev fallback reaches it. Make it again, or add its
410
+ `canvas` resource and an owner grant with `agentic-identity`.
411
+ - An image is stored under the hash of its bytes, not under a board. Any credential with `canvas:read` can fetch an image if it knows the hash.
412
+ - A link visitor reads the stored board, which a live room saves at its next save. It cannot load `blob:` images.
413
+ - A revoke, or a grant of viewer or commenter, closes the live sockets of the person or agent whose role changed. An agent that acts for that person keeps its socket until it joins again.
414
+ - A link visitor reads. A link never writes. Links are not listed: the token shows once, at creation.
357
415
  - A board holds at most 10,000 elements. A flow holds at most 500 nodes and 2,000
358
416
  edges.
359
417
  - Flow text has no chains (`A -> B -> C`). Write one edge per line.
@@ -374,13 +432,15 @@ The stylesheet is a separate export, `agentic-canvas/ui.css`. Load `agentic-desi
374
432
  - `move` writes absolute positions. Two peers that move one shape at the same time do not add up:
375
433
  the last writer wins.
376
434
  - Frames are plain shapes. They do not own the shapes inside them yet.
377
- - The store is one file, `canvas.json`, in the store folder. A write takes `canvas.json.lock`.
378
- A lock whose owner is gone, or that is older than 30 seconds, is taken over. A lock that
379
- cannot be read counts as held: the call then waits 10 seconds and fails with `UNAVAILABLE`.
380
- Remove the lock by hand if no process runs.
435
+ - The store is one file, `canvas.json`, in the store folder. It is the file store of
436
+ agentic-identity: a write takes `canvas.json.lock`, and a lock older than 3 seconds is taken
437
+ over, so a writer that is paused for longer can lose its lock and the write then fails with
438
+ `UNAVAILABLE`. A write that clashes with another process is run again, for up to 10 seconds.
439
+ Remove the lock by hand if no process runs. A file written by an older agentic-canvas is
440
+ upgraded in place the first time a store opens it, and an older agentic-canvas cannot read
441
+ the upgraded file. Stop older agentic-canvas processes before you upgrade.
381
442
  - Node 22 or newer is needed. `agentic-canvas/file-store` exports `createFileStore(path)` and
382
- `openLocalStore(dir)`. A writer that is paused for more than 30 seconds can lose its lock
383
- and then still write.
443
+ `openLocalStore(dir)`.
384
444
  - A room remembers its authors for good, so a client can stamp an element with the name of anyone who
385
445
  once sent an update in that room. A save corrects the stored board and not the room: after a save that
386
446
  reports `fixed`, join again. A client that adds a root Yjs type of its own blocks later saves of that room.
@@ -8,7 +8,7 @@ import {
8
8
 
9
9
  // src/version.ts
10
10
  import { createRequire } from "module";
11
- var VERSION = true ? "0.6.1" : readPackageVersion();
11
+ var VERSION = true ? "0.8.0" : readPackageVersion();
12
12
 
13
13
  // src/brand.ts
14
14
  var BRAND = {
@@ -104,6 +104,11 @@ function createMcpServer(service, actor) {
104
104
  { description: "Read a board as data: nodes, edges, groups, text, and who made what.", inputSchema: { board }, annotations: read },
105
105
  tool(async (a) => json(await service.read(a.board)))
106
106
  );
107
+ server.registerTool(
108
+ "delete_board",
109
+ { description: "Delete a whole board for good. It cannot be undone.", inputSchema: { board }, annotations: { destructiveHint: true } },
110
+ tool(async (a) => json(await service.delete(a.board)))
111
+ );
107
112
  server.registerTool(
108
113
  "add_shape",
109
114
  {
@@ -1,13 +1,15 @@
1
1
  import {
2
2
  CanvasService,
3
- MAX_LIVE_BYTES
4
- } from "./chunk-66FFQMHI.js";
3
+ MAX_LIVE_BYTES,
4
+ isBoardId,
5
+ isStoreId
6
+ } from "./chunk-M74ENUWR.js";
5
7
  import {
6
8
  CanvasError
7
9
  } from "./chunk-ZSPZMGHG.js";
8
10
 
9
11
  // src/worker.ts
10
- import { hasScope, parsePrincipal, parseRegion } from "agentic-identity";
12
+ import { createStoreAuth, hasScope as hasScope2, parsePrincipal, parseRegion } from "agentic-identity";
11
13
  import { createD1Store, createR2Blobs, D1_SCHEMA } from "agentic-identity/cloudflare";
12
14
  import { acceptWebSocketProtocol, principalFromWebSocketRequest } from "agentic-identity/http";
13
15
 
@@ -1788,6 +1790,150 @@ var Hono3 = class extends Hono {
1788
1790
  }
1789
1791
  };
1790
1792
 
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
+
1791
1937
  // src/room-sync.ts
1792
1938
  import * as Y from "yjs";
1793
1939
  var SEEDER = { kind: "agent", id: "canvas-room" };
@@ -1902,6 +2048,9 @@ function remember(id, mark) {
1902
2048
  if (saved.size > 500) saved.clear();
1903
2049
  saved.set(id, mark);
1904
2050
  }
2051
+ function forget(id) {
2052
+ saved.delete(id);
2053
+ }
1905
2054
  async function pull(live, service, id, force = false) {
1906
2055
  const { updates, compactions } = await live.meta(id);
1907
2056
  const mark = `${updates}:${compactions}`;
@@ -1979,11 +2128,52 @@ function tokenAuth(env) {
1979
2128
  }
1980
2129
  };
1981
2130
  }
1982
- async function authenticate(request, env) {
1983
- const found = await principalFromWebSocketRequest(request, tokenAuth(env), { protocol: WS_PROTOCOL });
2131
+ var staticTokensOn = (env) => env.CANVAS_UNSAFE_STATIC_TOKENS === "1";
2132
+ function workerAuth(env, store) {
2133
+ const auths = [];
2134
+ if (store) auths.push(createStoreAuth(store));
2135
+ const devAuth = staticTokensOn(env) ? tokenAuth(env) : void 0;
2136
+ if (devAuth) auths.push(devAuth);
2137
+ let dev = false;
2138
+ let failure;
2139
+ const auth = {
2140
+ async authenticate(request) {
2141
+ for (const auth2 of auths) {
2142
+ let found;
2143
+ try {
2144
+ found = await auth2.authenticate(request);
2145
+ } catch (error) {
2146
+ failure = error;
2147
+ return null;
2148
+ }
2149
+ if (!found) continue;
2150
+ dev = auth2 === devAuth;
2151
+ let step;
2152
+ try {
2153
+ step = parsePrincipal({ kind: found.kind, id: found.principalId });
2154
+ } catch {
2155
+ return null;
2156
+ }
2157
+ if (step.kind === SEEDER.kind && step.id === SEEDER.id) return null;
2158
+ return found;
2159
+ }
2160
+ return null;
2161
+ }
2162
+ };
2163
+ return { auth, failure: () => failure, dev: () => dev };
2164
+ }
2165
+ async function authenticate(request, env, store) {
2166
+ const found = await identify(request, env, store);
2167
+ return found && { actor: found.actor, role: found.role };
2168
+ }
2169
+ async function identify(request, env, store) {
2170
+ const { auth, failure, dev } = workerAuth(env, store);
2171
+ const found = await principalFromWebSocketRequest(request, auth, { protocol: WS_PROTOCOL, scope: "canvas:read" });
2172
+ if (failure() !== void 0) throw failure();
1984
2173
  if (found instanceof Response) return null;
1985
- return { actor: { kind: found.kind, id: found.principalId }, role: hasScope(found.scopes, "canvas:write") ? "editor" : "viewer" };
2174
+ return { actor: { kind: found.kind, id: found.principalId }, role: hasScope2(found.scopes, "canvas:write") ? "editor" : "viewer", principal: found, dev: dev() };
1986
2175
  }
2176
+ 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:" } });
1987
2177
  function sniffImage(bytes) {
1988
2178
  const at = (offset, ...sig) => sig.every((b, i) => bytes[offset + i] === b);
1989
2179
  if (at(0, 137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
@@ -2052,8 +2242,7 @@ var loggable = (error) => error instanceof Error ? LOGGABLE.exec(error.message)?
2052
2242
  function createApp(options = {}) {
2053
2243
  const app2 = new Hono3();
2054
2244
  app2.get("/health", (c) => c.json({ ok: true }));
2055
- const api = new Hono3();
2056
- api.use("*", async (c, next) => {
2245
+ const open = async (c) => {
2057
2246
  let region;
2058
2247
  try {
2059
2248
  region = parseRegion(c.env.CANVAS_REGION ?? "");
@@ -2061,14 +2250,36 @@ function createApp(options = {}) {
2061
2250
  return fail("NOT_CONFIGURED", 'set CANVAS_REGION to "eu", "us", or "unknown"', 503);
2062
2251
  }
2063
2252
  if (!c.env.DB || !c.env.IMAGES) return fail("NOT_CONFIGURED", "bind DB (D1) and IMAGES (R2)", 503);
2064
- const who = await authenticate(c.req.raw, c.env);
2253
+ if (c.env.CANVAS_API_TOKENS && !staticTokensOn(c.env)) return fail("NOT_CONFIGURED", "CANVAS_API_TOKENS is a dev fallback. Set CANVAS_UNSAFE_STATIC_TOKENS=1 to use it, or remove it and issue agentic-identity credentials", 503);
2254
+ await ready(c.env.DB);
2255
+ return createD1Store(c.env.DB, { region });
2256
+ };
2257
+ const shared = new Hono3();
2258
+ const visit = async (c, route) => {
2259
+ const store = await open(c);
2260
+ if (store instanceof Response) return store;
2261
+ const who = await identify(c.req.raw, c.env, store);
2262
+ if (!who && c.req.header("authorization")) return fail("UNAUTHORIZED", "send a valid bearer token", 401);
2263
+ const id = c.req.param("id");
2264
+ await new CanvasAccess(store).require(who?.principal ?? null, "read", id, c.req.header("x-link-token") ?? "");
2265
+ return route(new CanvasService(store));
2266
+ };
2267
+ shared.get("/:id", (c) => visit(c, async (service) => c.json(await service.read(c.req.param("id")))));
2268
+ shared.get("/:id/svg", (c) => visit(c, async (service) => svgResponse(await service.exportSvg(c.req.param("id")))));
2269
+ app2.route("/api/v1/canvas/shared", shared);
2270
+ const api = new Hono3();
2271
+ api.use("*", async (c, next) => {
2272
+ const store = await open(c);
2273
+ if (store instanceof Response) return store;
2274
+ const who = await identify(c.req.raw, c.env, store);
2065
2275
  if (!who) return fail("UNAUTHORIZED", "send a valid bearer token", 401);
2066
- if (who.role === "viewer" && c.req.method !== "GET") return fail("FORBIDDEN", "this token is a viewer; it cannot change a board", 403);
2276
+ if (who.role === "viewer" && c.req.method !== "GET" && !(c.req.method === "POST" && c.req.path === "/api/v1/canvas/invites/accept")) return fail("FORBIDDEN", "this token is a viewer; it cannot change a board", 403);
2067
2277
  c.set("actor", who.actor);
2068
2278
  c.set("role", who.role);
2069
- await ready(c.env.DB);
2070
- c.set("service", new CanvasService(createD1Store(c.env.DB, { region })));
2071
- c.set("blobs", createR2Blobs(c.env.IMAGES, { region, urlBase: new URL(c.req.url).origin, urlSecret: `${crypto.randomUUID()}${crypto.randomUUID()}` }));
2279
+ c.set("caller", who);
2280
+ c.set("service", new CanvasService(store));
2281
+ c.set("access", new CanvasAccess(store));
2282
+ c.set("blobs", createR2Blobs(c.env.IMAGES, { region: parseRegion(c.env.CANVAS_REGION ?? ""), urlBase: new URL(c.req.url).origin, urlSecret: `${crypto.randomUUID()}${crypto.randomUUID()}` }));
2072
2283
  await next();
2073
2284
  });
2074
2285
  api.get("/me", (c) => c.json({ actor: c.var.actor.id, kind: c.var.actor.kind, role: c.var.role }));
@@ -2086,16 +2297,39 @@ function createApp(options = {}) {
2086
2297
  }
2087
2298
  };
2088
2299
  const sync = async (c, next) => {
2089
- if (!/\/(ws|checkpoint)$/.test(new URL(c.req.url).pathname)) await saveRoom(c, false);
2300
+ if (!/\/(ws|checkpoint)$/.test(new URL(c.req.url).pathname)) {
2301
+ await saveRoom(c, false).catch((error) => {
2302
+ if (!(c.req.method === "DELETE" && error instanceof CanvasError && error.code === "NOT_FOUND")) throw error;
2303
+ });
2304
+ }
2090
2305
  await next();
2091
2306
  };
2307
+ const ADMIN = /* @__PURE__ */ new Set(["access", "grants", "invites", "links"]);
2308
+ const WRITE = /* @__PURE__ */ new Set(["shapes", "connectors", "flow", "move", "elements", "checkpoint"]);
2309
+ const needs = (method, path) => {
2310
+ let decoded;
2311
+ try {
2312
+ decoded = decodeURI(path);
2313
+ } catch {
2314
+ return "admin";
2315
+ }
2316
+ const part = decoded.split("/boards/")[1].split("/");
2317
+ if (part.length === 1) return method === "DELETE" ? "admin" : method === "GET" ? "read" : "write";
2318
+ if (ADMIN.has(part[1])) return "admin";
2319
+ return WRITE.has(part[1]) || method !== "GET" ? "write" : "read";
2320
+ };
2321
+ api.use("/boards/:id/*", async (c, next) => {
2322
+ if (!c.var.caller.dev) await c.var.access.require(c.var.caller.principal, needs(c.req.method, new URL(c.req.url).pathname), c.req.param("id"));
2323
+ await next();
2324
+ });
2092
2325
  api.use("/boards/:id/*", sync);
2093
2326
  api.get("/boards/:id/ws", async (c) => {
2094
2327
  if (!options.live) return fail("NOT_FOUND", "this server hosts no live rooms", 404);
2095
2328
  if (c.req.header("upgrade") !== "websocket") return fail("UPGRADE_REQUIRED", "open this route as a WebSocket", 426);
2096
2329
  const id = c.req.param("id");
2097
2330
  await saveRoom(c, true);
2098
- const { actor, role } = c.var;
2331
+ const { actor, caller } = c.var;
2332
+ const role = caller.dev ? c.var.role : await c.var.access.allowed(caller.principal, "write", id) ? "editor" : "viewer";
2099
2333
  const opened = await options.live(c.env).connect(id, c.req.raw, { principal: { id: actor.id, kind: actor.kind }, role, name: actor.id });
2100
2334
  return acceptWebSocketProtocol(c.req.raw, opened, WS_PROTOCOL);
2101
2335
  });
@@ -2108,15 +2342,64 @@ function createApp(options = {}) {
2108
2342
  if (!update) throw new CanvasError("INVALID", `the update must be at most ${MAX_LIVE_BYTES} bytes`);
2109
2343
  return c.json(await handSave(options.live(c.env), c.var.service, id, update, c.var.actor));
2110
2344
  });
2111
- api.get("/boards", async (c) => c.json({ boards: await c.var.service.list() }));
2345
+ api.get("/boards", async (c) => c.json({ boards: c.var.caller.dev ? await c.var.service.list() : await c.var.service.summaries(await c.var.access.readable(c.var.caller.principal)) }));
2112
2346
  api.post("/boards", async (c) => {
2113
2347
  const b = await jsonBody(c);
2114
2348
  const id = b.id === void 0 ? void 0 : text(b.id, "id");
2115
2349
  const title = b.title === void 0 ? void 0 : text(b.title, "title");
2116
- return c.json(await c.var.service.create(c.var.actor, { id, title }), 201);
2350
+ if (c.var.caller.dev && id !== void 0 && await c.var.access.registered(id)) throw new CanvasError("DUPLICATE_ID", `board "${id}" already exists`);
2351
+ const created = await c.var.service.create(c.var.actor, { id, title });
2352
+ if (!c.var.caller.dev) {
2353
+ await c.var.access.register(c.var.caller.principal, created.id).catch(async (error) => {
2354
+ await c.var.service.delete(created.id).catch((failed) => console.error(JSON.stringify({ level: "error", op: "undo-create", message: loggable(failed) })));
2355
+ throw error;
2356
+ });
2357
+ }
2358
+ return c.json(created, 201);
2117
2359
  });
2118
2360
  api.get("/boards/:id", async (c) => c.json(await c.var.service.read(c.req.param("id"))));
2119
- api.get("/boards/:id/svg", async (c) => new Response(await c.var.service.exportSvg(c.req.param("id")), { headers: { "content-type": "image/svg+xml", "x-content-type-options": "nosniff", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; img-src data:" } }));
2361
+ api.delete("/boards/:id", async (c) => {
2362
+ const id = c.req.param("id");
2363
+ const { dev, principal } = c.var.caller;
2364
+ if (dev && await c.var.access.registered(id)) throw new CanvasError("FORBIDDEN", "this board has owners: only an owner deletes it");
2365
+ const present = await c.var.service.assertExists(id).then(() => true, (error) => {
2366
+ if (error instanceof CanvasError && error.code === "NOT_FOUND" && !dev) return false;
2367
+ throw error;
2368
+ });
2369
+ const live = options.live?.(c.env);
2370
+ if (live) await live.erase(id);
2371
+ const deleted = present ? await c.var.service.delete(id) : { deleted: id };
2372
+ if (!dev) await c.var.access.unregister(principal, id);
2373
+ if (live) {
2374
+ await live.erase(id);
2375
+ forget(id);
2376
+ }
2377
+ console.log(JSON.stringify({ level: "info", op: "delete-board", actorKind: c.var.actor.kind }));
2378
+ return c.json(deleted);
2379
+ });
2380
+ api.get("/boards/:id/svg", async (c) => svgResponse(await c.var.service.exportSvg(c.req.param("id"))));
2381
+ api.get("/boards/:id/access", async (c) => c.json(await c.var.access.list(c.var.caller.principal, c.req.param("id"))));
2382
+ api.post("/boards/:id/grants", async (c) => {
2383
+ const id = c.req.param("id");
2384
+ const input = await jsonBody(c);
2385
+ const done = await c.var.access.grant(c.var.caller.principal, id, input);
2386
+ if (options.live && (input.role === "viewer" || input.role === "commenter") && typeof input.subjectId === "string") {
2387
+ await options.live(c.env).revoke(id, input.subjectId).catch((error) => console.error(JSON.stringify({ level: "error", op: "revoke-room", message: loggable(error) })));
2388
+ }
2389
+ return c.json(done);
2390
+ });
2391
+ api.delete("/boards/:id/grants", async (c) => {
2392
+ const id = c.req.param("id");
2393
+ const subjectKind = c.req.query("subjectKind");
2394
+ const subjectId = c.req.query("subjectId");
2395
+ const done = await c.var.access.revoke(c.var.caller.principal, id, { subjectKind, subjectId });
2396
+ if (options.live && subjectId) await options.live(c.env).revoke(id, subjectId).catch((error) => console.error(JSON.stringify({ level: "error", op: "revoke-room", message: loggable(error) })));
2397
+ return c.json(done);
2398
+ });
2399
+ 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));
2400
+ 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
+ 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
+ api.post("/invites/accept", async (c) => c.json(await c.var.access.accept(c.var.caller.principal, text((await jsonBody(c)).id, "id"))));
2120
2403
  api.post("/boards/:id/shapes", async (c) => c.json(await c.var.service.add(c.req.param("id"), await jsonBody(c), c.var.actor), 201));
2121
2404
  api.post("/boards/:id/connectors", async (c) => c.json(await c.var.service.connect(c.req.param("id"), await jsonBody(c), c.var.actor), 201));
2122
2405
  api.post("/boards/:id/flow", async (c) => c.json(await c.var.service.flow(c.req.param("id"), text((await jsonBody(c)).text, "text"), c.var.actor)));
@@ -2176,6 +2459,7 @@ var worker_default = { fetch: (request, env, ctx) => app.fetch(request, env, ctx
2176
2459
  export {
2177
2460
  WS_PROTOCOL,
2178
2461
  authenticate,
2462
+ identify,
2179
2463
  sniffImage,
2180
2464
  createApp,
2181
2465
  worker_default