agentic-canvas 0.7.0 → 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.7.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.
@@ -157,6 +157,35 @@ revoked without a new deploy. It ignores revocation in the Store: a static grant
157
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
158
  and the switch is not, every API request gets 503 `NOT_CONFIGURED`, so a deploy never trusts the list by accident.
159
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.
188
+
160
189
  ## UI
161
190
 
162
191
  The Worker serves a small web UI at its root. It uses the published `agentic-design` package for
@@ -330,9 +359,9 @@ old author stays on its elements. Only new writes follow the strict rule.
330
359
 
331
360
  | Name | What it is |
332
361
  |---|---|
333
- | `createFileStore(path, options?)`, `openLocalStore(dir)` | A store on one JSON file, or on `canvas.json` in a directory. |
334
- | `FileStoreError` | The error that the store throws. Its `code` is `LOCKED` or `DAMAGED`. |
335
- | `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. |
336
365
  | `STORE_FILE` | The file name that `openLocalStore` uses, `canvas.json`. |
337
366
 
338
367
  ### `agentic-canvas/live`
@@ -377,8 +406,12 @@ The stylesheet is a separate export, `agentic-canvas/ui.css`. Load `agentic-desi
377
406
  ## Limits
378
407
 
379
408
  - Canvas takes principal kinds, regions, and the Store and Blobs ports from `agentic-identity` 0.6.
380
- It does not use the sharing API yet: every principal with `canvas:write` reaches every board. When it adopts sharing, grants are keyed
381
- on kind and id, and a delete calls `deleteResource`.
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.
382
415
  - A board holds at most 10,000 elements. A flow holds at most 500 nodes and 2,000
383
416
  edges.
384
417
  - Flow text has no chains (`A -> B -> C`). Write one edge per line.
@@ -399,13 +432,15 @@ The stylesheet is a separate export, `agentic-canvas/ui.css`. Load `agentic-desi
399
432
  - `move` writes absolute positions. Two peers that move one shape at the same time do not add up:
400
433
  the last writer wins.
401
434
  - Frames are plain shapes. They do not own the shapes inside them yet.
402
- - The store is one file, `canvas.json`, in the store folder. A write takes `canvas.json.lock`.
403
- A lock whose owner is gone, or that is older than 30 seconds, is taken over. A lock that
404
- cannot be read counts as held: the call then waits 10 seconds and fails with `UNAVAILABLE`.
405
- 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.
406
442
  - Node 22 or newer is needed. `agentic-canvas/file-store` exports `createFileStore(path)` and
407
- `openLocalStore(dir)`. A writer that is paused for more than 30 seconds can lose its lock
408
- and then still write.
443
+ `openLocalStore(dir)`.
409
444
  - A room remembers its authors for good, so a client can stamp an element with the name of anyone who
410
445
  once sent an update in that room. A save corrects the stored board and not the room: after a save that
411
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.7.0" : readPackageVersion();
11
+ var VERSION = true ? "0.8.0" : readPackageVersion();
12
12
 
13
13
  // src/brand.ts
14
14
  var BRAND = {
@@ -1,13 +1,15 @@
1
1
  import {
2
2
  CanvasService,
3
- MAX_LIVE_BYTES
4
- } from "./chunk-GPSOLW2Y.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 { createStoreAuth, 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" };
@@ -1986,7 +2132,9 @@ var staticTokensOn = (env) => env.CANVAS_UNSAFE_STATIC_TOKENS === "1";
1986
2132
  function workerAuth(env, store) {
1987
2133
  const auths = [];
1988
2134
  if (store) auths.push(createStoreAuth(store));
1989
- if (staticTokensOn(env)) auths.push(tokenAuth(env));
2135
+ const devAuth = staticTokensOn(env) ? tokenAuth(env) : void 0;
2136
+ if (devAuth) auths.push(devAuth);
2137
+ let dev = false;
1990
2138
  let failure;
1991
2139
  const auth = {
1992
2140
  async authenticate(request) {
@@ -1999,6 +2147,7 @@ function workerAuth(env, store) {
1999
2147
  return null;
2000
2148
  }
2001
2149
  if (!found) continue;
2150
+ dev = auth2 === devAuth;
2002
2151
  let step;
2003
2152
  try {
2004
2153
  step = parsePrincipal({ kind: found.kind, id: found.principalId });
@@ -2011,15 +2160,20 @@ function workerAuth(env, store) {
2011
2160
  return null;
2012
2161
  }
2013
2162
  };
2014
- return { auth, failure: () => failure };
2163
+ return { auth, failure: () => failure, dev: () => dev };
2015
2164
  }
2016
2165
  async function authenticate(request, env, store) {
2017
- const { auth, failure } = workerAuth(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);
2018
2171
  const found = await principalFromWebSocketRequest(request, auth, { protocol: WS_PROTOCOL, scope: "canvas:read" });
2019
2172
  if (failure() !== void 0) throw failure();
2020
2173
  if (found instanceof Response) return null;
2021
- 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() };
2022
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:" } });
2023
2177
  function sniffImage(bytes) {
2024
2178
  const at = (offset, ...sig) => sig.every((b, i) => bytes[offset + i] === b);
2025
2179
  if (at(0, 137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
@@ -2088,8 +2242,7 @@ var loggable = (error) => error instanceof Error ? LOGGABLE.exec(error.message)?
2088
2242
  function createApp(options = {}) {
2089
2243
  const app2 = new Hono3();
2090
2244
  app2.get("/health", (c) => c.json({ ok: true }));
2091
- const api = new Hono3();
2092
- api.use("*", async (c, next) => {
2245
+ const open = async (c) => {
2093
2246
  let region;
2094
2247
  try {
2095
2248
  region = parseRegion(c.env.CANVAS_REGION ?? "");
@@ -2099,14 +2252,34 @@ function createApp(options = {}) {
2099
2252
  if (!c.env.DB || !c.env.IMAGES) return fail("NOT_CONFIGURED", "bind DB (D1) and IMAGES (R2)", 503);
2100
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);
2101
2254
  await ready(c.env.DB);
2102
- const store = createD1Store(c.env.DB, { region });
2103
- const who = await authenticate(c.req.raw, c.env, store);
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);
2104
2275
  if (!who) return fail("UNAUTHORIZED", "send a valid bearer token", 401);
2105
- 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);
2106
2277
  c.set("actor", who.actor);
2107
2278
  c.set("role", who.role);
2279
+ c.set("caller", who);
2108
2280
  c.set("service", new CanvasService(store));
2109
- c.set("blobs", createR2Blobs(c.env.IMAGES, { region, urlBase: new URL(c.req.url).origin, urlSecret: `${crypto.randomUUID()}${crypto.randomUUID()}` }));
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()}` }));
2110
2283
  await next();
2111
2284
  });
2112
2285
  api.get("/me", (c) => c.json({ actor: c.var.actor.id, kind: c.var.actor.kind, role: c.var.role }));
@@ -2124,16 +2297,39 @@ function createApp(options = {}) {
2124
2297
  }
2125
2298
  };
2126
2299
  const sync = async (c, next) => {
2127
- 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
+ }
2128
2305
  await next();
2129
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
+ });
2130
2325
  api.use("/boards/:id/*", sync);
2131
2326
  api.get("/boards/:id/ws", async (c) => {
2132
2327
  if (!options.live) return fail("NOT_FOUND", "this server hosts no live rooms", 404);
2133
2328
  if (c.req.header("upgrade") !== "websocket") return fail("UPGRADE_REQUIRED", "open this route as a WebSocket", 426);
2134
2329
  const id = c.req.param("id");
2135
2330
  await saveRoom(c, true);
2136
- 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";
2137
2333
  const opened = await options.live(c.env).connect(id, c.req.raw, { principal: { id: actor.id, kind: actor.kind }, role, name: actor.id });
2138
2334
  return acceptWebSocketProtocol(c.req.raw, opened, WS_PROTOCOL);
2139
2335
  });
@@ -2146,20 +2342,34 @@ function createApp(options = {}) {
2146
2342
  if (!update) throw new CanvasError("INVALID", `the update must be at most ${MAX_LIVE_BYTES} bytes`);
2147
2343
  return c.json(await handSave(options.live(c.env), c.var.service, id, update, c.var.actor));
2148
2344
  });
2149
- 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)) }));
2150
2346
  api.post("/boards", async (c) => {
2151
2347
  const b = await jsonBody(c);
2152
2348
  const id = b.id === void 0 ? void 0 : text(b.id, "id");
2153
2349
  const title = b.title === void 0 ? void 0 : text(b.title, "title");
2154
- 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);
2155
2359
  });
2156
2360
  api.get("/boards/:id", async (c) => c.json(await c.var.service.read(c.req.param("id"))));
2157
2361
  api.delete("/boards/:id", async (c) => {
2158
2362
  const id = c.req.param("id");
2159
- await c.var.service.assertExists(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
+ });
2160
2369
  const live = options.live?.(c.env);
2161
2370
  if (live) await live.erase(id);
2162
- const deleted = await c.var.service.delete(id);
2371
+ const deleted = present ? await c.var.service.delete(id) : { deleted: id };
2372
+ if (!dev) await c.var.access.unregister(principal, id);
2163
2373
  if (live) {
2164
2374
  await live.erase(id);
2165
2375
  forget(id);
@@ -2167,7 +2377,29 @@ function createApp(options = {}) {
2167
2377
  console.log(JSON.stringify({ level: "info", op: "delete-board", actorKind: c.var.actor.kind }));
2168
2378
  return c.json(deleted);
2169
2379
  });
2170
- 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:" } }));
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"))));
2171
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));
2172
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));
2173
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)));
@@ -2227,6 +2459,7 @@ var worker_default = { fetch: (request, env, ctx) => app.fetch(request, env, ctx
2227
2459
  export {
2228
2460
  WS_PROTOCOL,
2229
2461
  authenticate,
2462
+ identify,
2230
2463
  sniffImage,
2231
2464
  createApp,
2232
2465
  worker_default
@@ -34,6 +34,22 @@ function boardKey(id) {
34
34
  }
35
35
  return id;
36
36
  }
37
+ function isStoreId(id) {
38
+ try {
39
+ assertId(id);
40
+ return /^[A-Za-z0-9_-]{1,128}$/.test(id);
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+ function isBoardId(id) {
46
+ try {
47
+ boardKey(id);
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
37
53
  function storeFailure(error) {
38
54
  if (error instanceof StoreConflictError) throw new CanvasError("UNAVAILABLE", "the board changed at the same time. Try again");
39
55
  if (error instanceof FileStoreError) throw new CanvasError(error.code === "LOCKED" ? "UNAVAILABLE" : "INVALID", error.message);
@@ -57,13 +73,12 @@ var CanvasService = class {
57
73
  /** The first 100 boards (the store sorts by id), with titles. A board whose stored bytes are damaged shows an empty title: it stays in the list so a person can find and repair it. */
58
74
  async list() {
59
75
  const page = await this.store.query(COLLECTION, { limit: 100 }).catch(storeFailure);
60
- return page.items.map(({ id, data }) => {
61
- try {
62
- return { id, title: decodeStored(data, id, Board.decode).title };
63
- } catch {
64
- return { id, title: "" };
65
- }
66
- });
76
+ return page.items.map(({ id, data }) => summary(id, data));
77
+ }
78
+ /** The listed boards that exist, with titles, in the order given. */
79
+ async summaries(ids) {
80
+ const found = await Promise.all(ids.map(async (id) => ({ id, data: await this.store.get(COLLECTION, id).catch(storeFailure) })));
81
+ return found.flatMap(({ id, data }) => data ? [summary(id, data)] : []);
67
82
  }
68
83
  /** Delete a board and everything on it. Throws NOT_FOUND unless the board exists. The Worker erases the live room too. */
69
84
  async delete(id) {
@@ -191,6 +206,13 @@ var CanvasService = class {
191
206
  return decodeStored(doc, id, Board.decode);
192
207
  }
193
208
  };
209
+ function summary(id, data) {
210
+ try {
211
+ return { id, title: decodeStored(data, id, Board.decode).title };
212
+ } catch {
213
+ return { id, title: "" };
214
+ }
215
+ }
194
216
  function decodeStored(doc, id, decode) {
195
217
  try {
196
218
  return decode(fromBase64(String(doc.update)));
@@ -202,5 +224,7 @@ function decodeStored(doc, id, decode) {
202
224
 
203
225
  export {
204
226
  MAX_LIVE_BYTES,
227
+ isStoreId,
228
+ isBoardId,
205
229
  CanvasService
206
230
  };
@@ -0,0 +1,130 @@
1
+ import {
2
+ FileStoreError
3
+ } from "./chunk-NWQED42L.js";
4
+
5
+ // src/store-file.ts
6
+ import { randomUUID } from "crypto";
7
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "fs";
8
+ import { join } from "path";
9
+ import { createFileStore as createIdentityFileStore } from "agentic-identity/file";
10
+ import { StoreConflictError } from "agentic-identity";
11
+ var RETRY_FOR_MS = 1e4;
12
+ var LOCK_STALE_MS = 3e3;
13
+ var LOCK_WAIT_MS = 5e3;
14
+ var HELD = /locked by another process/;
15
+ var NOT_A_STORE_FILE = /is not an agentic-identity store file/;
16
+ async function retried(run) {
17
+ const deadline = Date.now() + RETRY_FOR_MS;
18
+ for (; ; ) {
19
+ try {
20
+ return await run();
21
+ } catch (error) {
22
+ const clash = error instanceof StoreConflictError && !HELD.test(error.message);
23
+ if (!clash || Date.now() > deadline) throw error;
24
+ await new Promise((resolve) => setTimeout(resolve, 5 + Math.random() * 20));
25
+ }
26
+ }
27
+ }
28
+ var isDoc = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
29
+ var sleep = (ms) => void Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
30
+ function legacyCollections(path) {
31
+ try {
32
+ const data = JSON.parse(readFileSync(path, "utf8"));
33
+ const current = isDoc(data) && data.format === 1 && isDoc(data.collections);
34
+ const old = isDoc(data) && !current && Object.values(data).every((docs) => isDoc(docs) && Object.values(docs).every(isDoc));
35
+ return old ? data : void 0;
36
+ } catch {
37
+ return void 0;
38
+ }
39
+ }
40
+ function withLock(path, fn) {
41
+ const lock = `${path}.lock`;
42
+ const mine = `${process.pid}:${randomUUID()}`;
43
+ for (const start = Date.now(); ; ) {
44
+ try {
45
+ writeFileSync(lock, mine, { flag: "wx", mode: 384 });
46
+ break;
47
+ } catch (error) {
48
+ if (error.code !== "EEXIST") return false;
49
+ try {
50
+ if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) unlinkSync(lock);
51
+ } catch {
52
+ }
53
+ if (Date.now() - start > LOCK_WAIT_MS) return false;
54
+ sleep(10);
55
+ }
56
+ }
57
+ const owned = () => {
58
+ try {
59
+ return readFileSync(lock, "utf8") === mine;
60
+ } catch {
61
+ return false;
62
+ }
63
+ };
64
+ try {
65
+ fn(owned);
66
+ } finally {
67
+ if (owned()) rmSync(lock, { force: true });
68
+ }
69
+ return true;
70
+ }
71
+ function upgradeLegacyFile(path) {
72
+ if (legacyCollections(path) === void 0) return true;
73
+ try {
74
+ withLock(path, (owned) => {
75
+ const data = legacyCollections(path);
76
+ if (data === void 0) return;
77
+ const temp = `${path}.${process.pid}.upgrade`;
78
+ try {
79
+ const fd = openSync(temp, "w", 384);
80
+ try {
81
+ writeSync(fd, JSON.stringify({ format: 1, collections: data }));
82
+ fsyncSync(fd);
83
+ } finally {
84
+ closeSync(fd);
85
+ }
86
+ if (owned()) renameSync(temp, path);
87
+ } finally {
88
+ rmSync(temp, { force: true });
89
+ }
90
+ });
91
+ } catch {
92
+ }
93
+ return legacyCollections(path) === void 0;
94
+ }
95
+ function createFileStore(path, options = {}) {
96
+ upgradeLegacyFile(path);
97
+ const inner = createIdentityFileStore({ ...options, path });
98
+ const guard = async (run) => {
99
+ for (let upgraded = false; ; upgraded = true) {
100
+ try {
101
+ return await retried(run);
102
+ } catch (error) {
103
+ if (!(error instanceof Error)) throw error;
104
+ if (HELD.test(error.message)) throw new FileStoreError("LOCKED", "the database file is locked. If no agentic-canvas process runs, remove the .lock file next to it");
105
+ if (!NOT_A_STORE_FILE.test(error.message)) throw error;
106
+ if (legacyCollections(path) === void 0) throw new FileStoreError("DAMAGED", "the database file is not valid JSON. Repair or remove it");
107
+ if (upgraded || !upgradeLegacyFile(path)) throw new FileStoreError("DAMAGED", "the database file is in the older layout and could not be upgraded. Try again, and check that its folder is writable");
108
+ }
109
+ }
110
+ };
111
+ return {
112
+ region: inner.region,
113
+ get: (collection, id) => guard(() => inner.get(collection, id)),
114
+ query: (collection, query) => guard(() => inner.query(collection, query)),
115
+ put: (collection, id, doc) => guard(() => inner.put(collection, id, doc)),
116
+ delete: (collection, id) => guard(() => inner.delete(collection, id)),
117
+ transaction: (fn) => guard(() => inner.transaction(fn))
118
+ };
119
+ }
120
+ function openLocalStore(dir) {
121
+ mkdirSync(dir, { recursive: true });
122
+ return createFileStore(join(dir, STORE_FILE));
123
+ }
124
+ var STORE_FILE = "canvas.json";
125
+
126
+ export {
127
+ createFileStore,
128
+ openLocalStore,
129
+ STORE_FILE
130
+ };
package/dist/cli.js CHANGED
@@ -5,13 +5,13 @@ import {
5
5
  operatorActor,
6
6
  runMcp,
7
7
  svgToPng
8
- } from "./chunk-3QW4UF2R.js";
8
+ } from "./chunk-7IOW6LML.js";
9
9
  import {
10
10
  openLocalStore
11
- } from "./chunk-UKST5A57.js";
11
+ } from "./chunk-NRLF6MGR.js";
12
12
  import {
13
13
  CanvasService
14
- } from "./chunk-GPSOLW2Y.js";
14
+ } from "./chunk-M74ENUWR.js";
15
15
  import "./chunk-NWQED42L.js";
16
16
  import {
17
17
  CanvasError
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { E as ErrorCode, a as BoardData, c as ShapeType, S as Shape, d as Endpoint, C as Connector } from './read-CeWoSAAy.js';
1
+ import { E as ErrorCode, a as BoardData, c as ShapeType, S as Shape, d as Endpoint, C as Connector } from './read--Jd3kE13.js';
2
2
  import { PrincipalKind } from 'agentic-identity';
3
3
  import 'yjs';
4
4
  import 'zod';
package/dist/embed.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as react from 'react';
2
2
  import { CanvasClient } from './client.js';
3
3
  import { PrincipalKind } from 'agentic-identity';
4
- import './read-CeWoSAAy.js';
4
+ import './read--Jd3kE13.js';
5
5
  import 'yjs';
6
6
  import 'zod';
7
7
 
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { B as Board, P as Principal } from './read-CeWoSAAy.js';
2
- export { a as BoardData, b as CanvasElement, e as CanvasError, C as Connector, f as EdgeData, d as Endpoint, E as ErrorCode, G as Group, M as MAX_ELEMENTS, g as MAX_TEXT, N as NodeData, h as SHAPE_TYPES, S as Shape, c as ShapeType, p as principalSchema, r as readBoard } from './read-CeWoSAAy.js';
3
- import { B as BoardAccess } from './service-D9WzMLH1.js';
4
- export { C as CanvasService, D as DrawOptions, F as FlowEdge, a as FlowGraph, b as FlowNode, M as MAX_FLOW_EDGES, c as MAX_FLOW_NODES, P as Placement, d as drawFlow, l as layoutFlow, p as parseFlow } from './service-D9WzMLH1.js';
1
+ import { B as Board, P as Principal } from './read--Jd3kE13.js';
2
+ export { a as BoardData, b as CanvasElement, e as CanvasError, C as Connector, f as EdgeData, d as Endpoint, E as ErrorCode, G as Group, M as MAX_ELEMENTS, g as MAX_TEXT, N as NodeData, h as SHAPE_TYPES, S as Shape, c as ShapeType, p as principalSchema, r as readBoard } from './read--Jd3kE13.js';
3
+ import { B as BoardAccess } from './service-OiTtNadu.js';
4
+ export { C as CanvasService, D as DrawOptions, F as FlowEdge, a as FlowGraph, b as FlowNode, M as MAX_FLOW_EDGES, c as MAX_FLOW_NODES, P as Placement, d as drawFlow, l as layoutFlow, p as parseFlow } from './service-OiTtNadu.js';
5
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  export { FileStoreOptions, createFileStore, openLocalStore } from './store-file.js';
7
7
  export { PRINCIPAL_KINDS, PrincipalKind, isMachineMade } from 'agentic-identity';
package/dist/index.js CHANGED
@@ -6,14 +6,14 @@ import {
6
6
  operatorActor,
7
7
  runMcp,
8
8
  svgToPng
9
- } from "./chunk-3QW4UF2R.js";
9
+ } from "./chunk-7IOW6LML.js";
10
10
  import {
11
11
  createFileStore,
12
12
  openLocalStore
13
- } from "./chunk-UKST5A57.js";
13
+ } from "./chunk-NRLF6MGR.js";
14
14
  import {
15
15
  CanvasService
16
- } from "./chunk-GPSOLW2Y.js";
16
+ } from "./chunk-M74ENUWR.js";
17
17
  import "./chunk-NWQED42L.js";
18
18
  import {
19
19
  Board,
@@ -5,10 +5,10 @@ import 'agentic-identity';
5
5
  import 'agentic-realtime';
6
6
  import 'agentic-identity/cloudflare';
7
7
  import 'hono';
8
- import './read-CeWoSAAy.js';
8
+ import './read--Jd3kE13.js';
9
9
  import 'yjs';
10
10
  import 'zod';
11
- import './service-D9WzMLH1.js';
11
+ import './service-OiTtNadu.js';
12
12
 
13
13
  type Namespace = Parameters<typeof createDurableObjectRealtime>[0];
14
14
  interface LiveEnv extends WorkerEnv {
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createApp
3
- } from "./chunk-EH7WCMK6.js";
4
- import "./chunk-GPSOLW2Y.js";
3
+ } from "./chunk-JQCUVXXD.js";
4
+ import "./chunk-M74ENUWR.js";
5
5
  import "./chunk-NWQED42L.js";
6
6
  import {
7
7
  CanvasError
package/dist/live.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { B as Board, P as Principal, a as BoardData, S as Shape, C as Connector, b as CanvasElement } from './read-CeWoSAAy.js';
1
+ import { B as Board, P as Principal, a as BoardData, S as Shape, C as Connector, b as CanvasElement } from './read--Jd3kE13.js';
2
2
  import { Peer } from 'agentic-realtime/client';
3
- import { B as BoardAccess, D as DrawOptions } from './service-D9WzMLH1.js';
3
+ import { B as BoardAccess, D as DrawOptions } from './service-OiTtNadu.js';
4
4
  import 'yjs';
5
5
  import 'agentic-identity';
6
6
  import 'zod';
@@ -6,22 +6,16 @@ import { z } from 'zod';
6
6
  * The actor of a new write. The rule is the identity kernel's `parsePrincipal`: a known kind, and an id of 1 to 128
7
7
  * characters from A-Z, a-z, 0-9, ".", "_" and "-". The Worker, the CLI and the SDK all use it.
8
8
  */
9
- declare const principalSchema: z.ZodEffects<z.ZodObject<{
9
+ declare const principalSchema: z.ZodPipe<z.ZodObject<{
10
10
  kind: z.ZodString;
11
11
  id: z.ZodString;
12
- }, "strict", z.ZodTypeAny, {
13
- kind: string;
14
- id: string;
15
- }, {
16
- kind: string;
17
- id: string;
18
- }>, {
12
+ }, z.core.$strict>, z.ZodTransform<{
19
13
  kind: PrincipalKind;
20
14
  id: string;
21
15
  }, {
22
16
  kind: string;
23
17
  id: string;
24
- }>;
18
+ }>>;
25
19
  type Principal = {
26
20
  kind: PrincipalKind;
27
21
  id: string;
@@ -1,4 +1,4 @@
1
- import { c as ShapeType, B as Board, P as Principal, a as BoardData, S as Shape, C as Connector, b as CanvasElement } from './read-CeWoSAAy.js';
1
+ import { c as ShapeType, B as Board, P as Principal, a as BoardData, S as Shape, C as Connector, b as CanvasElement } from './read--Jd3kE13.js';
2
2
  import { Store } from 'agentic-identity';
3
3
 
4
4
  declare const MAX_FLOW_NODES = 500;
@@ -68,6 +68,11 @@ declare class CanvasService {
68
68
  id: string;
69
69
  title: string;
70
70
  }[]>;
71
+ /** The listed boards that exist, with titles, in the order given. */
72
+ summaries(ids: string[]): Promise<{
73
+ id: string;
74
+ title: string;
75
+ }[]>;
71
76
  /** Delete a board and everything on it. Throws NOT_FOUND unless the board exists. The Worker erases the live room too. */
72
77
  delete(id: string): Promise<{
73
78
  deleted: string;
@@ -7,19 +7,8 @@ declare class FileStoreError extends Error {
7
7
  }
8
8
 
9
9
  interface FileStoreOptions extends AdapterOptions {
10
- /** How long a write waits for the lock before it fails. Defaults to 10000. */
11
- lockTimeoutMs?: number;
12
- /** A lock older than this is stale, if its owner is alive. Defaults to 30000. */
13
- staleAfterMs?: number;
14
10
  }
15
- /**
16
- * A Store kept in one JSON file. Node only. It is an adapter of the shared Store port and passes `runStoreContract`. Many processes may share the file, for example the CLI
17
- * and the MCP server. Each write takes a lock file, reads the file again, applies the change, and
18
- * replaces the file by an atomic rename. A read sees the last renamed file, so it takes no lock.
19
- * Each call reads the whole file and loads the docs it needs into the shared memory adapter, which runs the
20
- * queries and the transaction rules. Cost grows with the file. Use it for local work, not for scale.
21
- * The lock needs one machine: the process check and the clock do not work across hosts.
22
- */
11
+ /** A Store kept in one JSON file. Node only. It is the file adapter of agentic-identity, with a retry on a write clash. */
23
12
  declare function createFileStore(path: string, options?: FileStoreOptions): Store;
24
13
  /** The store the CLI and the MCP server share: one file in `dir`, which is made if it is missing. */
25
14
  declare function openLocalStore(dir: string): Store;
@@ -2,7 +2,7 @@ import {
2
2
  STORE_FILE,
3
3
  createFileStore,
4
4
  openLocalStore
5
- } from "./chunk-UKST5A57.js";
5
+ } from "./chunk-NRLF6MGR.js";
6
6
  import {
7
7
  FileStoreError
8
8
  } from "./chunk-NWQED42L.js";
package/dist/worker.d.ts CHANGED
@@ -1,12 +1,87 @@
1
- import { Store, Blobs } from 'agentic-identity';
1
+ import { Store, PrincipalContext, Action, Grant, InviteSummary, Blobs } from 'agentic-identity';
2
2
  import { Realtime } from 'agentic-realtime';
3
3
  import { D1Like, R2Like } from 'agentic-identity/cloudflare';
4
4
  import { Hono } from 'hono';
5
- import { P as Principal } from './read-CeWoSAAy.js';
6
- import { C as CanvasService } from './service-D9WzMLH1.js';
5
+ import { P as Principal } from './read--Jd3kE13.js';
6
+ import { C as CanvasService } from './service-OiTtNadu.js';
7
7
  import 'yjs';
8
8
  import 'zod';
9
9
 
10
+ /**
11
+ * Who may do what on a board. A board is an identity resource of type `canvas`. The roles, the grants, the invites,
12
+ * and the links are those of agentic-identity: this class checks input and maps its failures to `CanvasError`.
13
+ * A caller who cannot read a board gets NOT_FOUND, so a board id tells a stranger nothing. A caller who can read
14
+ * it and lacks the role for the action gets FORBIDDEN.
15
+ */
16
+ declare class CanvasAccess {
17
+ private readonly store;
18
+ private readonly sharing;
19
+ private readonly now;
20
+ constructor(store: Store, options?: {
21
+ now?: () => number;
22
+ });
23
+ /** 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. */
24
+ private known;
25
+ /** Make the actor the owner of a new board. */
26
+ register(ctx: PrincipalContext, id: string): Promise<void>;
27
+ /** Remove the sharing records of a board. Needs an owner. */
28
+ unregister(ctx: PrincipalContext, id: string): Promise<void>;
29
+ /** True if the board has sharing records: it was made by an identity credential. */
30
+ registered(id: string): Promise<boolean>;
31
+ /**
32
+ * True if the caller may do `action` on the board. A link token adds the role of its link. It writes an audit entry.
33
+ * An id that belongs to a resource of another type, in a Store shared with another app, is no board: it is denied.
34
+ */
35
+ allowed(ctx: PrincipalContext | null, action: Action, id: string, linkToken?: string): Promise<boolean>;
36
+ /** Throw NOT_FOUND for a caller who cannot read the board, and FORBIDDEN for one who can read it but not do `action`. */
37
+ require(ctx: PrincipalContext | null, action: Action, id: string, linkToken?: string): Promise<void>;
38
+ /** The ids of the boards a person or agent holds a role on, the first 100. A credential without `canvas:read` gets none. */
39
+ readable(ctx: PrincipalContext): Promise<string[]>;
40
+ grant(ctx: PrincipalContext, id: string, input: {
41
+ subjectKind?: unknown;
42
+ subjectId?: unknown;
43
+ role?: unknown;
44
+ }): Promise<{
45
+ ok: true;
46
+ }>;
47
+ revoke(ctx: PrincipalContext, id: string, input: {
48
+ subjectKind?: unknown;
49
+ subjectId?: unknown;
50
+ }): Promise<{
51
+ ok: true;
52
+ }>;
53
+ /** The grants and the invites on a board. An invite never shows its email. Links are not listed: a token shows once. */
54
+ list(ctx: PrincipalContext, id: string): Promise<{
55
+ grants: Grant[];
56
+ invites: InviteSummary[];
57
+ }>;
58
+ invite(ctx: PrincipalContext, id: string, input: {
59
+ role?: unknown;
60
+ email?: unknown;
61
+ handle?: unknown;
62
+ expiresInMs?: unknown;
63
+ }): Promise<{
64
+ id: string;
65
+ }>;
66
+ /** The email comes from a verified sign-in and the handle from the agent's own record, never from the request. */
67
+ accept(ctx: PrincipalContext, inviteId: string): Promise<{
68
+ ok: true;
69
+ }>;
70
+ /** The token shows once. Only its hash is stored. */
71
+ createLink(ctx: PrincipalContext, id: string, input: {
72
+ role?: unknown;
73
+ signedInOnly?: unknown;
74
+ expiresInMs?: unknown;
75
+ }): Promise<{
76
+ id: string;
77
+ token: string;
78
+ }>;
79
+ /** Revoke a link of this board. A link of another board is NOT_FOUND here, even for an owner of both. */
80
+ revokeLink(ctx: PrincipalContext, id: string, linkId: string): Promise<{
81
+ ok: true;
82
+ }>;
83
+ }
84
+
10
85
  interface WorkerEnv {
11
86
  DB: D1Like;
12
87
  IMAGES: R2Like;
@@ -33,13 +108,22 @@ interface WorkerEnv {
33
108
  CANVAS_MAX_IMAGE_BYTES?: string;
34
109
  }
35
110
  type Role = "editor" | "viewer";
111
+ /** The caller. `dev` is true for a token of the static list: it holds no board roles and reaches every board. */
112
+ interface Caller {
113
+ actor: Principal;
114
+ role: Role;
115
+ principal: PrincipalContext;
116
+ dev: boolean;
117
+ }
36
118
  type App = {
37
119
  Bindings: WorkerEnv;
38
120
  Variables: {
39
121
  service: CanvasService;
122
+ access: CanvasAccess;
40
123
  blobs: Blobs;
41
124
  actor: Principal;
42
125
  role: Role;
126
+ caller: Caller;
43
127
  };
44
128
  };
45
129
  interface AppOptions {
@@ -58,6 +142,8 @@ declare function authenticate(request: Request, env: WorkerEnv, store?: Store):
58
142
  actor: Principal;
59
143
  role: Role;
60
144
  } | null>;
145
+ /** Like `authenticate`, with the whole principal context that board roles are checked against. */
146
+ declare function identify(request: Request, env: WorkerEnv, store?: Store): Promise<Caller | null>;
61
147
  /** The image types we accept, found by their first bytes. The client's claim of a type is not trusted. */
62
148
  declare function sniffImage(bytes: Uint8Array): string | null;
63
149
  declare function createApp(options?: AppOptions): Hono<App>;
@@ -65,4 +151,4 @@ declare const _default: {
65
151
  fetch: (request: Request, env: WorkerEnv, ctx: unknown) => Response | Promise<Response>;
66
152
  };
67
153
 
68
- export { type AppOptions, type Role, WS_PROTOCOL, type WorkerEnv, authenticate, createApp, _default as default, sniffImage };
154
+ export { type AppOptions, type Caller, type Role, WS_PROTOCOL, type WorkerEnv, authenticate, createApp, _default as default, identify, sniffImage };
package/dist/worker.js CHANGED
@@ -2,10 +2,11 @@ import {
2
2
  WS_PROTOCOL,
3
3
  authenticate,
4
4
  createApp,
5
+ identify,
5
6
  sniffImage,
6
7
  worker_default
7
- } from "./chunk-EH7WCMK6.js";
8
- import "./chunk-GPSOLW2Y.js";
8
+ } from "./chunk-JQCUVXXD.js";
9
+ import "./chunk-M74ENUWR.js";
9
10
  import "./chunk-NWQED42L.js";
10
11
  import "./chunk-ZSPZMGHG.js";
11
12
  import "./chunk-JOBTIAFJ.js";
@@ -14,5 +15,6 @@ export {
14
15
  authenticate,
15
16
  createApp,
16
17
  worker_default as default,
18
+ identify,
17
19
  sniffImage
18
20
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-canvas",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "A shared whiteboard for diagrams, drawn by people and agents.",
5
5
  "keywords": [
6
6
  "whiteboard",
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "files": [
27
27
  "dist",
28
+ "LICENSE",
28
29
  "llms.txt",
29
30
  "SKILL.md"
30
31
  ],
@@ -80,11 +81,11 @@
80
81
  "dependencies": {
81
82
  "@dagrejs/dagre": "^3.1.3",
82
83
  "@modelcontextprotocol/sdk": "^1.12.0",
83
- "agentic-design": "^0.2.0",
84
- "agentic-identity": "^0.6.0",
84
+ "agentic-design": "^0.3.2",
85
+ "agentic-identity": "^0.7.0",
85
86
  "agentic-realtime": "^0.4.2",
86
87
  "yjs": "^13.6.0",
87
- "zod": "^3.25.0"
88
+ "zod": "^4.6.7"
88
89
  },
89
90
  "peerDependencies": {
90
91
  "playwright-core": ">=1.40.0",
@@ -121,5 +122,8 @@
121
122
  "vite": "^7.3.7",
122
123
  "vitest": "^3.0.0",
123
124
  "wrangler": "^4.155.0"
125
+ },
126
+ "overrides": {
127
+ "zod": "$zod"
124
128
  }
125
129
  }
@@ -1,228 +0,0 @@
1
- import {
2
- FileStoreError
3
- } from "./chunk-NWQED42L.js";
4
-
5
- // src/store-file.ts
6
- import { randomUUID } from "crypto";
7
- import { mkdirSync } from "fs";
8
- import { link, open, readFile, rename, unlink } from "fs/promises";
9
- import { join } from "path";
10
- import { assertCollection, assertId, createMemoryStore, parseRegion, StoreConflictError } from "agentic-identity";
11
- var MAX_TRANSACTION_RUNS = 50;
12
- var clone = (value) => JSON.parse(JSON.stringify(value));
13
- var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
14
- var codeOf = (error) => error?.code;
15
- function processIsAlive(pid) {
16
- try {
17
- process.kill(pid, 0);
18
- return true;
19
- } catch (error) {
20
- return codeOf(error) === "EPERM";
21
- }
22
- }
23
- function lockIsStale(text, staleAfterMs) {
24
- let held;
25
- try {
26
- held = JSON.parse(text);
27
- } catch {
28
- return false;
29
- }
30
- if (typeof held !== "object" || held === null) return false;
31
- const { pid, at } = held;
32
- if (typeof pid !== "number" || !Number.isInteger(pid) || pid < 1 || typeof at !== "number" || !Number.isFinite(at)) return false;
33
- return !processIsAlive(pid) || Date.now() - at > staleAfterMs;
34
- }
35
- function createFileStore(path, options = {}) {
36
- const region = parseRegion(options.region ?? "unknown");
37
- const lockPath = `${path}.lock`;
38
- const timeoutMs = options.lockTimeoutMs ?? 1e4;
39
- const staleAfterMs = options.staleAfterMs ?? 3e4;
40
- for (const ms of [timeoutMs, staleAfterMs]) if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) throw new TypeError("lockTimeoutMs and staleAfterMs must be finite numbers of 0 or more");
41
- let queue = Promise.resolve();
42
- async function load() {
43
- let text;
44
- try {
45
- text = await readFile(path, "utf8");
46
- } catch (error) {
47
- if (codeOf(error) === "ENOENT") return /* @__PURE__ */ new Map();
48
- throw error;
49
- }
50
- try {
51
- const data = JSON.parse(text);
52
- if (typeof data !== "object" || data === null || Array.isArray(data)) throw new Error();
53
- return new Map(
54
- Object.entries(data).map(([name, docs]) => {
55
- if (typeof docs !== "object" || docs === null || Array.isArray(docs)) throw new Error();
56
- for (const doc of Object.values(docs)) if (typeof doc !== "object" || doc === null || Array.isArray(doc)) throw new Error();
57
- return [name, new Map(Object.entries(docs))];
58
- })
59
- );
60
- } catch {
61
- throw new FileStoreError("DAMAGED", `the database file is not valid JSON. Repair or remove it`);
62
- }
63
- }
64
- async function save(snapshot) {
65
- const text = JSON.stringify(Object.fromEntries([...snapshot].map(([name, docs]) => [name, Object.fromEntries(docs)])));
66
- const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
67
- try {
68
- const handle = await open(temp, "w");
69
- try {
70
- await handle.writeFile(text);
71
- await handle.sync();
72
- } finally {
73
- await handle.close();
74
- }
75
- await rename(temp, path);
76
- } catch (error) {
77
- await unlink(temp).catch(() => void 0);
78
- throw error;
79
- }
80
- }
81
- async function takeOverIfStale() {
82
- let seen;
83
- try {
84
- seen = await readFile(lockPath, "utf8");
85
- } catch {
86
- return;
87
- }
88
- if (!lockIsStale(seen, staleAfterMs)) return;
89
- const aside = `${lockPath}.stale.${randomUUID()}`;
90
- try {
91
- await rename(lockPath, aside);
92
- } catch {
93
- return;
94
- }
95
- if (await readFile(aside, "utf8").catch(() => "") !== seen) await link(aside, lockPath).catch(() => void 0);
96
- await unlink(aside).catch(() => void 0);
97
- }
98
- async function acquire() {
99
- const token = randomUUID();
100
- const deadline = Date.now() + timeoutMs;
101
- for (; ; ) {
102
- try {
103
- const handle = await open(lockPath, "wx");
104
- try {
105
- await handle.writeFile(JSON.stringify({ pid: process.pid, at: Date.now(), token }));
106
- } finally {
107
- await handle.close();
108
- }
109
- return token;
110
- } catch (error) {
111
- if (codeOf(error) !== "EEXIST") throw error;
112
- }
113
- await takeOverIfStale();
114
- if (Date.now() > deadline) throw new FileStoreError("LOCKED", `the database file is locked. If no agentic-canvas process runs, remove the .lock file next to it`);
115
- await sleep(5 + Math.random() * 15);
116
- }
117
- }
118
- async function release(token) {
119
- const text = await readFile(lockPath, "utf8").catch(() => "");
120
- if (text.includes(`"token":"${token}"`)) await unlink(lockPath).catch(() => void 0);
121
- }
122
- async function memoryOf(snapshot, only) {
123
- const memory = createMemoryStore({ region });
124
- for (const [name, docs] of snapshot) if (only === void 0 || only === name) for (const [id, doc] of docs) await memory.put(name, id, doc);
125
- return memory;
126
- }
127
- const recording = (inner, log) => ({
128
- get: (collection, id) => inner.get(collection, id),
129
- query: (collection, query) => inner.query(collection, query),
130
- put: async (collection, id, doc) => {
131
- await inner.put(collection, id, doc);
132
- log.push([collection, id, clone(doc)]);
133
- },
134
- delete: async (collection, id) => {
135
- await inner.delete(collection, id);
136
- log.push([collection, id, null]);
137
- }
138
- });
139
- const locked = (body) => {
140
- const run = queue.then(async () => {
141
- const token = await acquire();
142
- try {
143
- return await body();
144
- } finally {
145
- await release(token);
146
- }
147
- });
148
- queue = run.catch(() => void 0);
149
- return run;
150
- };
151
- async function commit(snapshot, log) {
152
- for (const [name, id, doc] of log) {
153
- if (doc === null) snapshot.get(name)?.delete(id);
154
- else snapshot.set(name, (snapshot.get(name) ?? /* @__PURE__ */ new Map()).set(id, doc));
155
- }
156
- if (log.length > 0) await save(snapshot);
157
- }
158
- const writeOne = (write) => locked(async () => {
159
- const log = [];
160
- await write(recording(createMemoryStore({ region }), log));
161
- await commit(await load(), log);
162
- });
163
- const watching = (inner, seen) => ({
164
- ...inner,
165
- async get(collection, id) {
166
- const doc = await inner.get(collection, id);
167
- if (!seen.has(`${collection}/${id}`)) seen.set(`${collection}/${id}`, JSON.stringify(doc));
168
- return doc;
169
- },
170
- async query(collection, query) {
171
- const page = await inner.query(collection, query);
172
- for (const item of page.items) if (!seen.has(`${collection}/${item.id}`)) seen.set(`${collection}/${item.id}`, JSON.stringify(item.data));
173
- return page;
174
- }
175
- });
176
- const unchanged = (snapshot, seen) => [...seen].every(([key, text]) => {
177
- const slash = key.indexOf("/");
178
- return JSON.stringify(snapshot.get(key.slice(0, slash))?.get(key.slice(slash + 1)) ?? null) === text;
179
- });
180
- return {
181
- region,
182
- async get(collection, id) {
183
- assertCollection(collection);
184
- assertId(id);
185
- const doc = (await load()).get(collection)?.get(id);
186
- return doc ? clone(doc) : null;
187
- },
188
- async query(collection, query) {
189
- assertCollection(collection);
190
- return (await memoryOf(await load(), collection)).query(collection, query);
191
- },
192
- put: (collection, id, doc) => writeOne((ops) => ops.put(collection, id, doc)),
193
- delete: (collection, id) => writeOne((ops) => ops.delete(collection, id)),
194
- // Optimistic, as the port requires: run on a copy of the file with no lock, then take the lock and commit
195
- // only if every doc the run read is unchanged. Else run again. The memory adapter keeps the rule that
196
- // every read comes before the first write.
197
- async transaction(fn) {
198
- for (let run = 0; run < MAX_TRANSACTION_RUNS; run++) {
199
- const log = [];
200
- const seen = /* @__PURE__ */ new Map();
201
- const result = await (await memoryOf(await load())).transaction(async (tx) => {
202
- log.length = 0;
203
- seen.clear();
204
- return fn(watching(recording(tx, log), seen));
205
- });
206
- const done = log.length === 0 ? unchanged(await load(), seen) : await locked(async () => {
207
- const current = await load();
208
- if (!unchanged(current, seen)) return false;
209
- await commit(current, log);
210
- return true;
211
- });
212
- if (done) return result;
213
- }
214
- throw new StoreConflictError();
215
- }
216
- };
217
- }
218
- function openLocalStore(dir) {
219
- mkdirSync(dir, { recursive: true });
220
- return createFileStore(join(dir, STORE_FILE));
221
- }
222
- var STORE_FILE = "canvas.json";
223
-
224
- export {
225
- createFileStore,
226
- openLocalStore,
227
- STORE_FILE
228
- };