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 +47 -12
- package/dist/{chunk-3QW4UF2R.js → chunk-7IOW6LML.js} +1 -1
- package/dist/{chunk-EH7WCMK6.js → chunk-JQCUVXXD.js} +253 -20
- package/dist/{chunk-GPSOLW2Y.js → chunk-M74ENUWR.js} +31 -7
- package/dist/chunk-NRLF6MGR.js +130 -0
- package/dist/cli.js +3 -3
- package/dist/client.d.ts +1 -1
- package/dist/embed.d.ts +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/live-worker.d.ts +2 -2
- package/dist/live-worker.js +2 -2
- package/dist/live.d.ts +2 -2
- package/dist/{read-CeWoSAAy.d.ts → read--Jd3kE13.d.ts} +3 -9
- package/dist/{service-D9WzMLH1.d.ts → service-OiTtNadu.d.ts} +6 -1
- package/dist/store-file.d.ts +1 -12
- package/dist/store-file.js +1 -1
- package/dist/worker.d.ts +90 -4
- package/dist/worker.js +4 -2
- package/package.json +8 -4
- package/dist/chunk-UKST5A57.js +0 -228
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.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
|
|
335
|
-
| `FileStoreOptions` (type) | `lockTimeoutMs`
|
|
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
|
-
|
|
381
|
-
|
|
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.
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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)`.
|
|
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.
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CanvasService,
|
|
3
|
-
MAX_LIVE_BYTES
|
|
4
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
2103
|
-
|
|
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("
|
|
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))
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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-
|
|
8
|
+
} from "./chunk-7IOW6LML.js";
|
|
9
9
|
import {
|
|
10
10
|
openLocalStore
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-NRLF6MGR.js";
|
|
12
12
|
import {
|
|
13
13
|
CanvasService
|
|
14
|
-
} from "./chunk-
|
|
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
|
|
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
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { B as Board, P as Principal } from './read
|
|
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
|
|
3
|
-
import { B as BoardAccess } from './service-
|
|
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-
|
|
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-
|
|
9
|
+
} from "./chunk-7IOW6LML.js";
|
|
10
10
|
import {
|
|
11
11
|
createFileStore,
|
|
12
12
|
openLocalStore
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-NRLF6MGR.js";
|
|
14
14
|
import {
|
|
15
15
|
CanvasService
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-M74ENUWR.js";
|
|
17
17
|
import "./chunk-NWQED42L.js";
|
|
18
18
|
import {
|
|
19
19
|
Board,
|
package/dist/live-worker.d.ts
CHANGED
|
@@ -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
|
|
8
|
+
import './read--Jd3kE13.js';
|
|
9
9
|
import 'yjs';
|
|
10
10
|
import 'zod';
|
|
11
|
-
import './service-
|
|
11
|
+
import './service-OiTtNadu.js';
|
|
12
12
|
|
|
13
13
|
type Namespace = Parameters<typeof createDurableObjectRealtime>[0];
|
|
14
14
|
interface LiveEnv extends WorkerEnv {
|
package/dist/live-worker.js
CHANGED
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
|
|
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-
|
|
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.
|
|
9
|
+
declare const principalSchema: z.ZodPipe<z.ZodObject<{
|
|
10
10
|
kind: z.ZodString;
|
|
11
11
|
id: z.ZodString;
|
|
12
|
-
},
|
|
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
|
|
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;
|
package/dist/store-file.d.ts
CHANGED
|
@@ -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;
|
package/dist/store-file.js
CHANGED
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
|
|
6
|
-
import { C as CanvasService } from './service-
|
|
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-
|
|
8
|
-
import "./chunk-
|
|
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.
|
|
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
|
|
84
|
-
"agentic-identity": "^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": "^
|
|
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
|
}
|
package/dist/chunk-UKST5A57.js
DELETED
|
@@ -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
|
-
};
|