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