agent-canvas 0.1.26 → 0.1.28

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.
Files changed (50) hide show
  1. package/.github/workflows/ci.yml +25 -0
  2. package/.github/workflows/deploy-worker-prod.yml +20 -0
  3. package/.github/workflows/deploy-worker-stage.yml +22 -0
  4. package/.github/workflows/deploy-worker.yml +73 -0
  5. package/bin/lib/instructions/overview.md +1 -0
  6. package/bun.lock +193 -0
  7. package/daemon/build.ts +22 -0
  8. package/daemon/client/AnnotationEditor.tsx +12 -2
  9. package/daemon/client/AnnotationProvider.tsx +20 -6
  10. package/daemon/client/AnnotationSidebar.tsx +72 -35
  11. package/daemon/client/App.tsx +219 -10
  12. package/daemon/client/FileBrowser.tsx +16 -0
  13. package/daemon/client/FileViewer.tsx +18 -0
  14. package/daemon/client/PlanRenderer.tsx +3 -2
  15. package/daemon/client/ReviewerIdentityDialog.tsx +95 -0
  16. package/daemon/client/SessionSwitcher.tsx +5 -0
  17. package/daemon/client/ShareDialog.tsx +230 -0
  18. package/daemon/client/clientApi.ts +142 -0
  19. package/daemon/client/runtime.ts +22 -0
  20. package/daemon/dist/client.css +1 -1
  21. package/daemon/dist/client.js +74 -74
  22. package/daemon/dist/components.js +50 -50
  23. package/daemon/src/handlers/api.ts +63 -0
  24. package/daemon/src/remote-feedback.test.ts +179 -0
  25. package/daemon/src/remote-feedback.ts +128 -0
  26. package/daemon/src/server.ts +5 -0
  27. package/daemon/src/session.ts +139 -6
  28. package/daemon/src/share.test.ts +221 -0
  29. package/daemon/src/share.ts +187 -0
  30. package/daemon/src/websocket.ts +15 -2
  31. package/package.json +5 -1
  32. package/workers/canvas-share/README.md +159 -0
  33. package/workers/canvas-share/oblaka.ts +56 -0
  34. package/workers/canvas-share/package.json +25 -0
  35. package/workers/canvas-share/public/assets/.gitkeep +0 -0
  36. package/workers/canvas-share/public/assets/client.css +2 -0
  37. package/workers/canvas-share/public/assets/client.js +91 -0
  38. package/workers/canvas-share/public/assets/components.js +63 -0
  39. package/workers/canvas-share/public/assets/jsx-runtime.js +3 -0
  40. package/workers/canvas-share/public/assets/preact-compat.js +1 -0
  41. package/workers/canvas-share/public/assets/runtime.js +1 -0
  42. package/workers/canvas-share/public/index.html +385 -0
  43. package/workers/canvas-share/public/screenshot-dark.png +0 -0
  44. package/workers/canvas-share/public/screenshot-light.png +0 -0
  45. package/workers/canvas-share/src/index.test.ts +165 -0
  46. package/workers/canvas-share/src/index.ts +510 -0
  47. package/workers/canvas-share/src/types.ts +97 -0
  48. package/workers/canvas-share/src/util.ts +156 -0
  49. package/workers/canvas-share/src/validation.ts +117 -0
  50. package/workers/canvas-share/tsconfig.json +16 -0
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Unit tests for the share packager. Uses a temp directory to back a real
3
+ * SessionManager so we exercise the actual disk persistence path. The
4
+ * worker push itself is mocked via fetch override.
5
+ */
6
+
7
+ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
8
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "fs";
9
+ import { join } from "path";
10
+ import { tmpdir } from "os";
11
+ import { SessionManager } from "./session";
12
+ import { buildSharePayload, pushShareToWorker, shareRevision, loadShareConfig } from "./share";
13
+
14
+ let testDir: string;
15
+ let originalSessionsDir: string | undefined;
16
+
17
+ beforeEach(() => {
18
+ testDir = mkdtempSync(join(tmpdir(), "canvas-share-test-"));
19
+ // Override SESSIONS_DIR via env (paths.ts reads at module load — for
20
+ // these tests we instead seed a SessionManager and inject revisions
21
+ // through its public API rather than touching its private dirs).
22
+ originalSessionsDir = process.env.CANVAS_TEST_SESSIONS_DIR;
23
+ });
24
+
25
+ afterEach(() => {
26
+ try { rmSync(testDir, { recursive: true, force: true }); } catch {}
27
+ if (originalSessionsDir !== undefined) {
28
+ process.env.CANVAS_TEST_SESSIONS_DIR = originalSessionsDir;
29
+ } else {
30
+ delete process.env.CANVAS_TEST_SESSIONS_DIR;
31
+ }
32
+ });
33
+
34
+ describe("loadShareConfig", () => {
35
+ test("returns null without endpoint", () => {
36
+ const before = process.env.CANVAS_SHARE_ENDPOINT;
37
+ delete process.env.CANVAS_SHARE_ENDPOINT;
38
+ expect(loadShareConfig("0.0.0")).toBeNull();
39
+ if (before) process.env.CANVAS_SHARE_ENDPOINT = before;
40
+ });
41
+
42
+ test("strips trailing slashes", () => {
43
+ process.env.CANVAS_SHARE_ENDPOINT = "https://example.com/";
44
+ const cfg = loadShareConfig("0.0.0");
45
+ expect(cfg?.endpoint).toBe("https://example.com");
46
+ delete process.env.CANVAS_SHARE_ENDPOINT;
47
+ });
48
+
49
+ test("includes auth token when set", () => {
50
+ process.env.CANVAS_SHARE_ENDPOINT = "https://example.com";
51
+ process.env.CANVAS_SHARE_AUTH_TOKEN = "secret";
52
+ const cfg = loadShareConfig("0.0.0");
53
+ expect(cfg?.authToken).toBe("secret");
54
+ delete process.env.CANVAS_SHARE_ENDPOINT;
55
+ delete process.env.CANVAS_SHARE_AUTH_TOKEN;
56
+ });
57
+ });
58
+
59
+ describe("buildSharePayload", () => {
60
+ test("packages compiled JS + JSX source", () => {
61
+ const sm = new SessionManager(testDir);
62
+ const sid = `test-${Date.now()}`;
63
+ const files = new Map([["plan.jsx", "<Section>hi</Section>"]]);
64
+ sm.upsert(sid, files, "/tmp", "Test label");
65
+ sm.saveCompiled(sid, "plan.jsx", "/* compiled */ export default ()=>null");
66
+
67
+ const payload = buildSharePayload(sm, sid, 1, "0.1.27");
68
+ expect(payload.version).toBe(1);
69
+ expect(payload.origin.sessionId).toBe(sid);
70
+ expect(payload.origin.revision).toBe(1);
71
+ expect(payload.origin.label).toBe("Test label");
72
+ expect(payload.canvasFiles).toHaveLength(1);
73
+ expect(payload.canvasFiles[0].filename).toBe("plan.jsx");
74
+ expect(payload.canvasFiles[0].compiledJs).toContain("/* compiled */");
75
+ expect(payload.canvasFiles[0].sourceJsx).toContain("<Section>hi</Section>");
76
+ expect(payload.runtime.componentsVersion).toBe("0.1.27");
77
+
78
+ sm.remove(sid);
79
+ });
80
+
81
+ test("throws when revision missing", () => {
82
+ const sm = new SessionManager(testDir);
83
+ const sid = `test-${Date.now()}`;
84
+ sm.upsert(sid, new Map([["plan.jsx", "x"]]), "/tmp");
85
+ sm.saveCompiled(sid, "plan.jsx", "x");
86
+ expect(() => buildSharePayload(sm, sid, 99, "0.0.0")).toThrow();
87
+ sm.remove(sid);
88
+ });
89
+
90
+ test("throws when no compiled JS", () => {
91
+ const sm = new SessionManager(testDir);
92
+ const sid = `test-${Date.now()}`;
93
+ sm.upsert(sid, new Map([["plan.jsx", "x"]]), "/tmp");
94
+ // Intentionally skip saveCompiled
95
+ expect(() => buildSharePayload(sm, sid, 1, "0.0.0")).toThrow();
96
+ sm.remove(sid);
97
+ });
98
+ });
99
+
100
+ describe("pushShareToWorker", () => {
101
+ const originalFetch = globalThis.fetch;
102
+ afterEach(() => { globalThis.fetch = originalFetch; });
103
+
104
+ test("posts JSON and unwraps the response", async () => {
105
+ let captured: { url: string; init: RequestInit } | null = null;
106
+ (globalThis as any).fetch = async (url: any, init: any) => {
107
+ captured = { url: String(url), init };
108
+ return new Response(JSON.stringify({
109
+ shareId: "deadbeef",
110
+ url: "https://example.com/s/deadbeef",
111
+ ownerToken: "ot",
112
+ expiresAt: "2026-12-31T00:00:00Z",
113
+ }), { status: 200, headers: { "Content-Type": "application/json" } });
114
+ };
115
+
116
+ const result = await pushShareToWorker(
117
+ {
118
+ version: 1,
119
+ origin: { sessionId: "x", revision: 1, createdAt: "2026-01-01T00:00:00Z" },
120
+ canvasFiles: [{ filename: "p.jsx", compiledJs: "x" }],
121
+ runtime: { componentsVersion: "1.0.0" },
122
+ },
123
+ { endpoint: "https://example.com", componentsVersion: "1.0.0" },
124
+ );
125
+
126
+ expect(result.shareId).toBe("deadbeef");
127
+ expect(result.url).toBe("https://example.com/s/deadbeef");
128
+ expect(result.ownerToken).toBe("ot");
129
+ expect(captured!.url).toBe("https://example.com/shares");
130
+ expect(captured!.init.method).toBe("POST");
131
+ });
132
+
133
+ test("includes Authorization header when authToken set", async () => {
134
+ let captured: any = null;
135
+ (globalThis as any).fetch = async (_url: any, init: any) => {
136
+ captured = init;
137
+ return new Response(JSON.stringify({ shareId: "x", url: "x" }), { status: 200 });
138
+ };
139
+
140
+ await pushShareToWorker(
141
+ {
142
+ version: 1,
143
+ origin: { sessionId: "x", revision: 1, createdAt: "2026-01-01T00:00:00Z" },
144
+ canvasFiles: [{ filename: "p.jsx", compiledJs: "x" }],
145
+ runtime: { componentsVersion: "1.0.0" },
146
+ },
147
+ { endpoint: "https://example.com", componentsVersion: "1.0.0", authToken: "topsecret" },
148
+ );
149
+
150
+ expect(captured.headers["Authorization"]).toBe("Bearer topsecret");
151
+ });
152
+
153
+ test("throws on non-2xx", async () => {
154
+ (globalThis as any).fetch = async () => new Response("nope", { status: 500 });
155
+ await expect(
156
+ pushShareToWorker(
157
+ {
158
+ version: 1,
159
+ origin: { sessionId: "x", revision: 1, createdAt: "2026-01-01T00:00:00Z" },
160
+ canvasFiles: [{ filename: "p.jsx", compiledJs: "x" }],
161
+ runtime: { componentsVersion: "1.0.0" },
162
+ },
163
+ { endpoint: "https://example.com", componentsVersion: "1.0.0" },
164
+ ),
165
+ ).rejects.toThrow(/500/);
166
+ });
167
+
168
+ test("throws on malformed response", async () => {
169
+ (globalThis as any).fetch = async () => new Response(JSON.stringify({ shareId: "x" }), { status: 200 });
170
+ await expect(
171
+ pushShareToWorker(
172
+ {
173
+ version: 1,
174
+ origin: { sessionId: "x", revision: 1, createdAt: "2026-01-01T00:00:00Z" },
175
+ canvasFiles: [{ filename: "p.jsx", compiledJs: "x" }],
176
+ runtime: { componentsVersion: "1.0.0" },
177
+ },
178
+ { endpoint: "https://example.com", componentsVersion: "1.0.0" },
179
+ ),
180
+ ).rejects.toThrow(/invalid response/);
181
+ });
182
+ });
183
+
184
+ describe("shareRevision", () => {
185
+ const originalFetch = globalThis.fetch;
186
+ afterEach(() => { globalThis.fetch = originalFetch; });
187
+
188
+ test("records share entry on the session", async () => {
189
+ (globalThis as any).fetch = async () =>
190
+ new Response(JSON.stringify({
191
+ shareId: "abc123def456",
192
+ url: "https://example.com/s/abc123def456",
193
+ ownerToken: "owner",
194
+ expiresAt: "2026-12-31T00:00:00Z",
195
+ }), { status: 200 });
196
+
197
+ const sm = new SessionManager(testDir);
198
+ const sid = `test-${Date.now()}`;
199
+ sm.upsert(sid, new Map([["plan.jsx", "x"]]), "/tmp");
200
+ sm.saveCompiled(sid, "plan.jsx", "x");
201
+
202
+ const entry = await shareRevision(sm, sid, 1, {
203
+ endpoint: "https://example.com",
204
+ componentsVersion: "1.0.0",
205
+ });
206
+
207
+ expect(entry.shareId).toBe("abc123def456");
208
+ expect(entry.revision).toBe(1);
209
+ expect(entry.ownerToken).toBe("owner");
210
+ expect(entry.expiresAt).toBe("2026-12-31T00:00:00Z");
211
+
212
+ const shares = sm.getShares(sid);
213
+ expect(shares).toHaveLength(1);
214
+ expect(shares[0].shareId).toBe("abc123def456");
215
+
216
+ sm.remove(sid);
217
+ });
218
+ });
219
+
220
+ void mkdirSync;
221
+ void writeFileSync;
@@ -0,0 +1,187 @@
1
+ import type { SessionManager, ShareEntry } from "./session";
2
+
3
+ /**
4
+ * Canvas sharing: packages a specific revision into a self-contained
5
+ * snapshot and POSTs it to the configured cloud endpoint (CF Worker).
6
+ *
7
+ * The cloud endpoint stores the snapshot and returns a public capability
8
+ * URL that the author can send to reviewers. Runtime bundles
9
+ * (react/preact-compat, runtime.js, components.js) are NOT bundled into the
10
+ * payload — the worker serves matching runtime from its own deploy, keyed
11
+ * off `runtime.componentsVersion` in the payload. This keeps shares small
12
+ * while still guaranteeing version compatibility.
13
+ */
14
+
15
+ export interface SharePayload {
16
+ version: 1;
17
+ origin: {
18
+ sessionId: string;
19
+ revision: number;
20
+ label?: string;
21
+ createdAt: string;
22
+ };
23
+ canvasFiles: Array<{
24
+ filename: string;
25
+ compiledJs: string;
26
+ sourceJsx?: string;
27
+ }>;
28
+ runtime: {
29
+ componentsVersion: string;
30
+ };
31
+ }
32
+
33
+ export interface ShareResponse {
34
+ shareId: string;
35
+ url: string;
36
+ ownerToken?: string;
37
+ expiresAt?: string;
38
+ }
39
+
40
+ export interface ShareConfig {
41
+ /** Base URL of the share worker, e.g. `https://canvas-share.example.workers.dev` */
42
+ endpoint: string;
43
+ /** Version of the @agent-canvas runtime bundles — worker uses this to pick matching runtime */
44
+ componentsVersion: string;
45
+ /** Optional bearer token. If the worker has SHARE_AUTH_TOKEN set, this
46
+ * must match. Loaded from CANVAS_SHARE_AUTH_TOKEN env var on the daemon. */
47
+ authToken?: string;
48
+ }
49
+
50
+ export function loadShareConfig(version: string): ShareConfig | null {
51
+ const endpoint = process.env.CANVAS_SHARE_ENDPOINT ?? "https://canvas.contember.com";
52
+ if (!endpoint) return null;
53
+ return {
54
+ endpoint: endpoint.replace(/\/+$/, ""),
55
+ componentsVersion: version,
56
+ ...(process.env.CANVAS_SHARE_AUTH_TOKEN ? { authToken: process.env.CANVAS_SHARE_AUTH_TOKEN } : {}),
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Build a SharePayload for the given session/revision. Reads the already
62
+ * compiled JS from the session's revision directory. Also includes the
63
+ * original JSX source for "view source" functionality on the shared canvas.
64
+ */
65
+ export function buildSharePayload(
66
+ sessionManager: SessionManager,
67
+ sessionId: string,
68
+ revision: number,
69
+ componentsVersion: string,
70
+ ): SharePayload {
71
+ const session = sessionManager.get(sessionId);
72
+ if (!session) throw new Error(`Session ${sessionId} not found`);
73
+
74
+ const revInfo = session.revisions.find((r) => r.revision === revision);
75
+ if (!revInfo) throw new Error(`Revision ${revision} not found in session ${sessionId}`);
76
+
77
+ const canvasFiles: SharePayload["canvasFiles"] = [];
78
+ for (const cf of revInfo.canvasFiles) {
79
+ const compiledJs = sessionManager.getCompiled(sessionId, cf.filename, revision);
80
+ if (!compiledJs) {
81
+ throw new Error(`No compiled JS for ${cf.filename} at revision ${revision}`);
82
+ }
83
+ const sourceJsx = sessionManager.readRevisionJsx(sessionId, revision, cf.filename) ?? undefined;
84
+ canvasFiles.push({
85
+ filename: cf.filename,
86
+ compiledJs,
87
+ ...(sourceJsx ? { sourceJsx } : {}),
88
+ });
89
+ }
90
+
91
+ return {
92
+ version: 1,
93
+ origin: {
94
+ sessionId,
95
+ revision,
96
+ ...(revInfo.label ? { label: revInfo.label } : {}),
97
+ createdAt: revInfo.createdAt,
98
+ },
99
+ canvasFiles,
100
+ runtime: { componentsVersion },
101
+ };
102
+ }
103
+
104
+ /**
105
+ * POST the payload to the CF Worker. Returns the `shareId` + public URL
106
+ * assigned by the worker. Throws on any HTTP error — the caller is expected
107
+ * to surface the failure to the client (dialog) so the user can retry or
108
+ * fix their config.
109
+ */
110
+ export async function pushShareToWorker(
111
+ payload: SharePayload,
112
+ config: ShareConfig,
113
+ ): Promise<ShareResponse> {
114
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
115
+ if (config.authToken) headers["Authorization"] = `Bearer ${config.authToken}`;
116
+
117
+ const res = await fetch(`${config.endpoint}/shares`, {
118
+ method: "POST",
119
+ headers,
120
+ body: JSON.stringify(payload),
121
+ });
122
+ if (!res.ok) {
123
+ const text = await res.text().catch(() => "");
124
+ throw new Error(`Share endpoint returned ${res.status}: ${text || res.statusText}`);
125
+ }
126
+ const data = (await res.json()) as { shareId?: string; url?: string; ownerToken?: string; expiresAt?: string };
127
+ if (!data.shareId || !data.url) {
128
+ throw new Error(`Share endpoint returned invalid response: ${JSON.stringify(data)}`);
129
+ }
130
+ return {
131
+ shareId: data.shareId,
132
+ url: data.url,
133
+ ...(data.ownerToken ? { ownerToken: data.ownerToken } : {}),
134
+ ...(data.expiresAt ? { expiresAt: data.expiresAt } : {}),
135
+ };
136
+ }
137
+
138
+ /**
139
+ * High-level: build payload, push to worker, record the share on the
140
+ * session. Returns the ShareEntry that was recorded.
141
+ */
142
+ export async function shareRevision(
143
+ sessionManager: SessionManager,
144
+ sessionId: string,
145
+ revision: number,
146
+ config: ShareConfig,
147
+ ): Promise<ShareEntry> {
148
+ const payload = buildSharePayload(sessionManager, sessionId, revision, config.componentsVersion);
149
+ const response = await pushShareToWorker(payload, config);
150
+ const entry: ShareEntry = {
151
+ shareId: response.shareId,
152
+ url: response.url,
153
+ revision,
154
+ createdAt: new Date().toISOString(),
155
+ ...(response.ownerToken ? { ownerToken: response.ownerToken } : {}),
156
+ ...(response.expiresAt ? { expiresAt: response.expiresAt } : {}),
157
+ };
158
+ sessionManager.addShare(sessionId, entry);
159
+ return entry;
160
+ }
161
+
162
+ /**
163
+ * Revoke a share by calling the worker's owner-only delete endpoint. Removes
164
+ * it from the local session metadata on success.
165
+ */
166
+ export async function revokeShare(
167
+ sessionManager: SessionManager,
168
+ sessionId: string,
169
+ shareId: string,
170
+ config: ShareConfig,
171
+ ): Promise<void> {
172
+ const session = sessionManager.get(sessionId);
173
+ const entry = session?.shares?.find((s) => s.shareId === shareId);
174
+ if (!entry) throw new Error(`Share ${shareId} not found`);
175
+ if (!entry.ownerToken) throw new Error("No owner token recorded for this share");
176
+
177
+ const res = await fetch(`${config.endpoint}/shares/${shareId}/revoke`, {
178
+ method: "POST",
179
+ headers: { "Authorization": `Bearer ${entry.ownerToken}` },
180
+ });
181
+ if (!res.ok) {
182
+ const text = await res.text().catch(() => "");
183
+ throw new Error(`Revoke failed: ${res.status} ${text}`);
184
+ }
185
+ sessionManager.removeShare(sessionId, shareId);
186
+ }
187
+
@@ -1,5 +1,5 @@
1
1
  import type { ServerWebSocket } from "bun";
2
- import type { SessionManager } from "./session";
2
+ import type { SessionManager, RemoteFeedbackEntry } from "./session";
3
3
 
4
4
  export type WSData = { type: "browser" | "wait"; sessionId: string };
5
5
 
@@ -36,6 +36,19 @@ export function createWebSocketManager(sessionManager: SessionManager) {
36
36
  }
37
37
  }
38
38
 
39
+ function broadcastRemoteFeedback(sessionId: string, revision: number, entries: RemoteFeedbackEntry[]) {
40
+ const sockets = browserSockets.get(sessionId);
41
+ if (!sockets || sockets.size === 0) return;
42
+ const payload = JSON.stringify({
43
+ type: "remote-feedback",
44
+ revision,
45
+ entries,
46
+ });
47
+ for (const ws of sockets) {
48
+ ws.send(payload);
49
+ }
50
+ }
51
+
39
52
  function broadcastWatcherStatus(sessionId: string) {
40
53
  const sockets = browserSockets.get(sessionId);
41
54
  if (!sockets) return;
@@ -140,5 +153,5 @@ export function createWebSocketManager(sessionManager: SessionManager) {
140
153
  },
141
154
  };
142
155
 
143
- return { handlers, broadcastPlanUpdate, broadcastRevisionUpdate, broadcastWatcherStatus };
156
+ return { handlers, broadcastPlanUpdate, broadcastRevisionUpdate, broadcastWatcherStatus, broadcastRemoteFeedback };
144
157
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-canvas",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "description": "Interactive visual canvas for Claude Code — rich annotatable documents in the browser",
5
5
  "bin": {
6
6
  "agent-canvas": "bin/agent-canvas.js"
@@ -8,6 +8,7 @@
8
8
  "scripts": {
9
9
  "build": "bun daemon/build.ts",
10
10
  "typecheck": "tsc --noEmit && tsc --noEmit -p daemon/tsconfig.json",
11
+ "test": "bun test",
11
12
  "prepublishOnly": "bun daemon/build.ts"
12
13
  },
13
14
  "dependencies": {
@@ -28,6 +29,9 @@
28
29
  "type": "git",
29
30
  "url": "https://github.com/contember/agent-canvas"
30
31
  },
32
+ "workspaces": [
33
+ "workers/*"
34
+ ],
31
35
  "license": "MIT",
32
36
  "keywords": [
33
37
  "claude-code",
@@ -0,0 +1,159 @@
1
+ # canvas-share
2
+
3
+ Cloudflare Worker that hosts shared agent-canvas snapshots.
4
+
5
+ ## Architecture
6
+
7
+ - **R2 (`BLOBS`)** stores share payloads, compiled canvas JS, JSX source, and reviewer-uploaded annotation images.
8
+ - **KV (`FEEDBACK`)** stores individual feedback entries (sortable keys for `since` queries) and rate limit counters (TTL 120s).
9
+ - **Static assets (`ASSETS`)** serve runtime bundles (`runtime.js`, `components.js`, `preact-compat.js`, `client.js`, `client.css`) — these are mirrored from `daemon/dist/` automatically by `bun run build`.
10
+
11
+ Validation is enforced with [Zod](https://zod.dev). Limits are centralized in [`src/types.ts`](src/types.ts).
12
+
13
+ ## Infrastructure as code
14
+
15
+ The Cloudflare config is defined programmatically in [`oblaka.ts`](oblaka.ts)
16
+ using [`oblaka-iac`](https://github.com/contember/oblaka). The committed
17
+ [`oblaka.ts`](oblaka.ts) is the source of truth; `wrangler.jsonc` is
18
+ auto-generated and **gitignored** — never edit it by hand.
19
+
20
+ | Command | Generates | Used for |
21
+ |---|---|---|
22
+ | `bun run oblaka` | `wrangler.jsonc` (env `local`) | local dev via Miniflare |
23
+ | `bun run oblaka:stage` | `wrangler.jsonc` (env `stage`) | staging deploy |
24
+ | `bun run oblaka:prod` | `wrangler.jsonc` (env `prod`) | production deploy |
25
+
26
+ `oblaka-iac` auto-prefixes R2 bucket and KV namespace names with the env
27
+ (e.g. `canvas-share-blobs` becomes `stage-canvas-share-blobs`) so the same
28
+ config produces isolated stage and prod resources.
29
+
30
+ ## Local development
31
+
32
+ ```bash
33
+ cd workers/canvas-share
34
+ bun install
35
+
36
+ # Build daemon bundles AND auto-copy them into ./public/assets
37
+ cd ../..
38
+ bun run build
39
+
40
+ # Start the worker locally via Miniflare (no Cloudflare account needed).
41
+ # `bun run dev` runs `oblaka` first to regenerate wrangler.jsonc, then
42
+ # starts wrangler dev --local.
43
+ cd workers/canvas-share
44
+ bun run dev
45
+
46
+ # Point the daemon at it
47
+ cd ../..
48
+ CANVAS_SHARE_ENDPOINT=http://127.0.0.1:8787 bun bin/agent-canvas.ts daemon restart
49
+ ```
50
+
51
+ Then click **Share** on any revision in the canvas UI.
52
+
53
+ ## Testing
54
+
55
+ ```bash
56
+ cd workers/canvas-share
57
+ bun test # unit tests for validation, util
58
+ bun run typecheck # typecheck src/
59
+ ```
60
+
61
+ ## Continuous deployment
62
+
63
+ The repo ships GitHub Actions workflows that deploy the worker automatically:
64
+
65
+ | Workflow | Trigger | Env |
66
+ |---|---|---|
67
+ | `deploy-worker-stage.yml` | Push to `main` touching `workers/canvas-share/**`, `daemon/client/**`, or `daemon/build.ts` | `stage` |
68
+ | `deploy-worker-prod.yml` | Version tag push (`v*`) | `prod` |
69
+
70
+ Both call the reusable `deploy-worker.yml` which:
71
+
72
+ 1. Installs deps + builds the daemon runtime bundles (auto-mirrored into `public/assets/`)
73
+ 2. Runs `bunx oblaka oblaka.ts --env <env> --remote` to create or update the R2 bucket and KV namespace on Cloudflare
74
+ 3. Sets `SHARE_AUTH_TOKEN` via `wrangler secret bulk` (skipped if no token is configured for the env)
75
+ 4. Runs `bunx wrangler deploy`
76
+
77
+ ### Required GitHub secrets
78
+
79
+ | Secret | Purpose |
80
+ |---|---|
81
+ | `CLOUDFLARE_API_TOKEN` | Token with Workers + R2 + KV permissions on the target account |
82
+ | `CLOUDFLARE_ACCOUNT_ID` | Account id the worker deploys to |
83
+ | `SHARE_AUTH_TOKEN_STAGE` | Bearer token daemons must use to POST to stage (optional) |
84
+ | `SHARE_AUTH_TOKEN_PROD` | Bearer token daemons must use to POST to prod (optional) |
85
+
86
+ Both stage and prod use the same Cloudflare account; oblaka auto-prefixes resource names so stage and prod get separate `stage-canvas-share-blobs` and `prod-canvas-share-blobs` buckets (same for KV).
87
+
88
+ Use GitHub Environments (`Settings → Environments → stage / prod`) to gate prod deploys behind a manual approval if you want belt-and-suspenders safety.
89
+
90
+ ## Manual production deploy
91
+
92
+ If you'd rather deploy by hand instead of via CI:
93
+
94
+ 1. (Recommended) Generate a shared bearer token to gate share creation:
95
+ ```bash
96
+ openssl rand -hex 32 | bunx wrangler secret put SHARE_AUTH_TOKEN --env prod
97
+ ```
98
+ Without this, anyone on the internet can pump shares into your R2 bucket.
99
+ Set the same value as `CANVAS_SHARE_AUTH_TOKEN` on the daemon side.
100
+
101
+ 2. (Optional) Override default 30-day share expiration:
102
+ ```bash
103
+ echo "604800" | bunx wrangler secret put SHARE_TTL_SECONDS --env prod # 7 days
104
+ ```
105
+
106
+ 3. Build daemon bundles + deploy. The deploy script regenerates
107
+ `wrangler.jsonc` for the target env, then runs `wrangler deploy`.
108
+ The R2 bucket and KV namespace are created automatically by oblaka
109
+ on first deploy:
110
+ ```bash
111
+ cd ../..
112
+ bun run build
113
+ cd workers/canvas-share
114
+ bun run deploy:prod # or deploy:stage
115
+ ```
116
+
117
+ 4. Configure the daemon:
118
+ ```bash
119
+ export CANVAS_SHARE_ENDPOINT=https://canvas-share.<subdomain>.workers.dev
120
+ export CANVAS_SHARE_AUTH_TOKEN=<the token you generated>
121
+ bunx agent-canvas daemon restart
122
+ ```
123
+
124
+ The Share button in the canvas UI lights up once the daemon sees `CANVAS_SHARE_ENDPOINT`.
125
+
126
+ ## Endpoints
127
+
128
+ | Method | Path | Purpose | Auth |
129
+ |--------|------------------------------------|-------------------------------------------------|---------------------|
130
+ | GET | `/health` | Health check | none |
131
+ | POST | `/shares` | Create a new share | `SHARE_AUTH_TOKEN` |
132
+ | POST | `/shares/:shareId/revoke` | Revoke / delete a share | `ownerToken` |
133
+ | GET | `/shares/:shareId/feedback?since=` | List feedback entries (called by daemon poller) | none (capability) |
134
+ | GET | `/s/:shareId` | HTML shell for reviewers | none (capability) |
135
+ | GET | `/s/:shareId/meta` | Share metadata | none |
136
+ | GET | `/s/:shareId/canvas/:filename` | Serve compiled canvas JS / JSX source | none |
137
+ | POST | `/s/:shareId/feedback` | Submit feedback (called by reviewer browser) | none |
138
+ | POST | `/s/:shareId/upload` | Upload annotation image | none |
139
+ | GET | `/s/:shareId/uploads/:filename` | Serve uploaded image | none |
140
+
141
+ ## Rate limits
142
+
143
+ Per-IP, per-minute, sliding window (KV-backed). Tweak in [`src/types.ts`](src/types.ts).
144
+
145
+ | Bucket | Limit |
146
+ |-----------------|-------|
147
+ | Share creation | 5/min |
148
+ | Feedback POST | 20/min/share |
149
+ | Upload | 30/min/share |
150
+
151
+ ## Security model
152
+
153
+ - **Capability URLs** — anyone with `/s/:shareId` (24-char hex, ~96 bits entropy) has full view+comment access. Brute-forcing is impractical.
154
+ - **Owner token** — returned to the daemon at create time, required to revoke. Stored in the daemon's session metadata so the canvas author can revoke from the Share dialog.
155
+ - **Optional shared bearer token** (`SHARE_AUTH_TOKEN`) — gates share creation so only authorized daemons can push to the worker.
156
+ - **No reviewer auth** — reviewers self-report a name (stored locally in their browser), there is no signup. Suitable for "send to a colleague" flows, **not** public publishing.
157
+ - **Per-share TTL** — defaults to 30 days. Expired shares 404 and are eventually purged by KV's TTL.
158
+ - **Size limits** — share payload max 2 MB, feedback body max 256 KB, image upload max 5 MB.
159
+ - **Validation** — all incoming bodies are validated by Zod schemas before any storage write.
@@ -0,0 +1,56 @@
1
+ import { define, KVNamespace, R2Bucket, Worker } from 'oblaka-iac'
2
+
3
+ /**
4
+ * Per-environment overrides. `local` is what `bunx oblaka oblaka.ts` uses
5
+ * when generating wrangler.jsonc for `wrangler dev --local`. `stage` and
6
+ * `prod` are picked when running `bunx oblaka oblaka.ts --env stage|prod`
7
+ * for actual Cloudflare deploys.
8
+ *
9
+ * Secrets (SHARE_AUTH_TOKEN, SHARE_TTL_SECONDS overrides, etc.) are NOT
10
+ * declared here — set them with `wrangler secret put` after deploy.
11
+ */
12
+ const vars = {
13
+ local: {
14
+ // Empty share auth in local dev so smoke tests don't need a token.
15
+ },
16
+ stage: {},
17
+ prod: {},
18
+ }
19
+
20
+ export default define(({ env }) => {
21
+ if (!(env in vars)) {
22
+ throw new Error(`Unknown environment ${env}`)
23
+ }
24
+
25
+ const blobs = new R2Bucket({
26
+ name: 'canvas-share-blobs',
27
+ })
28
+
29
+ const feedback = new KVNamespace({
30
+ name: 'canvas-share-feedback',
31
+ })
32
+
33
+ return new Worker({
34
+ dir: '.',
35
+ name: 'canvas-share',
36
+ main: './src/index.ts',
37
+ compatibility_flags: ['nodejs_compat'],
38
+ compatibility_date: '2025-01-01',
39
+ observability: { enabled: true },
40
+ // Runtime bundles (preact-compat, runtime, components, client.js + css)
41
+ // are mirrored here from daemon/dist/ by `bun run build` in the repo
42
+ // root. The worker serves them via the ASSETS binding.
43
+ assets: {
44
+ directory: './public',
45
+ binding: 'ASSETS',
46
+ },
47
+ bindings: {
48
+ BLOBS: blobs,
49
+ FEEDBACK: feedback,
50
+ },
51
+ vars: {
52
+ ENVIRONMENT: env,
53
+ ...vars[env as keyof typeof vars],
54
+ },
55
+ })
56
+ })