agentic-canvas 0.7.0 → 0.9.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.9.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`
@@ -362,9 +391,10 @@ The stylesheet is a separate export, `agentic-canvas/ui.css`. Load `agentic-desi
362
391
  | `createApp(options?)` | Make the Hono app for the Worker. Pass `live` to turn on the live-room socket. |
363
392
  | `default` | The Worker, without live rooms. |
364
393
  | `authenticate(request, env)` | Read a bearer token from the `Authorization` header, or from a WebSocket sub-protocol. Return a promise of the actor and the role, or `null`. |
394
+ | `identify(request, env, store?)` | Like `authenticate`, with the whole caller: its actor, role, principal context, and whether the dev fallback signed it in. Return a promise of the caller, or `null`. |
365
395
  | `sniffImage(bytes)` | Find the image type from the first bytes, or return `null`. The client's claim of a type is not trusted. |
366
396
  | `WS_PROTOCOL` | The sub-protocol name for a WebSocket: `agentic-canvas`. The token travels in the entry `agentic-canvas.bearer.<token in base64url>`. |
367
- | `WorkerEnv` (type), `AppOptions` (type), `Role` (type) | Types for the Worker bindings, the app options and the role, `editor` or `viewer`. |
397
+ | `WorkerEnv` (type), `AppOptions` (type), `Role` (type), `Caller` (type) | Types for the Worker bindings, the app options, the role, `editor` or `viewer`, and the caller that `identify` returns. |
368
398
 
369
399
  ### `agentic-canvas/live-worker`
370
400
 
@@ -376,9 +406,13 @@ The stylesheet is a separate export, `agentic-canvas/ui.css`. Load `agentic-desi
376
406
 
377
407
  ## Limits
378
408
 
379
- - 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
+ - Canvas takes principal kinds, regions, and the Store and Blobs ports from `agentic-identity` 0.7.
410
+ - 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
411
+ `canvas` resource and an owner grant with `agentic-identity`.
412
+ - 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.
413
+ - A link visitor reads the stored board, which a live room saves at its next save. It cannot load `blob:` images.
414
+ - 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.
415
+ - A link visitor reads. A link never writes. Links are not listed: the token shows once, at creation.
382
416
  - A board holds at most 10,000 elements. A flow holds at most 500 nodes and 2,000
383
417
  edges.
384
418
  - Flow text has no chains (`A -> B -> C`). Write one edge per line.
@@ -399,13 +433,15 @@ The stylesheet is a separate export, `agentic-canvas/ui.css`. Load `agentic-desi
399
433
  - `move` writes absolute positions. Two peers that move one shape at the same time do not add up:
400
434
  the last writer wins.
401
435
  - 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.
436
+ - The store is one file, `canvas.json`, in the store folder. It is the file store of
437
+ agentic-identity: a write takes `canvas.json.lock`, and a lock older than 3 seconds is taken
438
+ over, so a writer that is paused for longer can lose its lock and the write then fails with
439
+ `UNAVAILABLE`. A write that clashes with another process is run again, for up to 10 seconds.
440
+ Remove the lock by hand if no process runs. A file written by an older agentic-canvas is
441
+ upgraded in place the first time a store opens it, and an older agentic-canvas cannot read
442
+ the upgraded file. Stop older agentic-canvas processes before you upgrade.
406
443
  - 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.
444
+ `openLocalStore(dir)`.
409
445
  - A room remembers its authors for good, so a client can stamp an element with the name of anyone who
410
446
  once sent an update in that room. A save corrects the stored board and not the room: after a save that
411
447
  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.9.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
  };