agent-canvas 0.1.44 → 0.2.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.
Files changed (115) hide show
  1. package/README.md +14 -0
  2. package/bin/lib/commands/daemon.ts +3 -6
  3. package/bin/lib/commands/push.ts +1 -1
  4. package/bin/lib/daemon-lifecycle.ts +65 -72
  5. package/bin/lib/helpers.test.ts +1 -0
  6. package/bin/lib/helpers.ts +30 -74
  7. package/daemon/build.ts +37 -16
  8. package/daemon/client/App.tsx +27 -61
  9. package/daemon/client/CompareView.tsx +4 -4
  10. package/daemon/client/FileBrowser.tsx +4 -4
  11. package/daemon/client/FileViewer.tsx +4 -4
  12. package/daemon/client/OverviewView.tsx +2 -2
  13. package/daemon/client/RevisionSelect.tsx +1 -1
  14. package/daemon/client/canvas-runtime.ts +1 -0
  15. package/daemon/client/clientApi.ts +18 -0
  16. package/daemon/client/components/index.ts +3 -16
  17. package/daemon/client/styles.css +4 -457
  18. package/daemon/dist/annotations-runtime.js +1 -0
  19. package/daemon/dist/client.css +1 -1
  20. package/daemon/dist/client.js +76 -76
  21. package/daemon/dist/components.js +59 -59
  22. package/daemon/dist/index.html +1 -0
  23. package/daemon/dist/preact-compat.js +1 -1
  24. package/daemon/dist/runtime.js +1 -1
  25. package/daemon/src/components.ts +13 -0
  26. package/daemon/src/handlers/api.ts +13 -9
  27. package/daemon/src/handlers/files.ts +2 -3
  28. package/daemon/src/handlers/static.ts +1 -1
  29. package/daemon/src/handlers/uploads.ts +2 -3
  30. package/daemon/src/paths.ts +10 -24
  31. package/daemon/src/remote-feedback.test.ts +1 -1
  32. package/daemon/src/remote-feedback.ts +1 -1
  33. package/daemon/src/secrets-api.test.ts +2 -2
  34. package/daemon/src/server.ts +11 -10
  35. package/daemon/src/share-e2e.test.ts +1 -1
  36. package/daemon/src/share.test.ts +1 -1
  37. package/daemon/src/share.ts +1 -1
  38. package/daemon/tsconfig.json +1 -3
  39. package/package.json +11 -4
  40. package/.github/dark.png +0 -0
  41. package/.github/light.png +0 -0
  42. package/.github/workflows/ci.yml +0 -48
  43. package/.github/workflows/deploy-worker-prod.yml +0 -19
  44. package/.github/workflows/deploy-worker-stage.yml +0 -21
  45. package/.github/workflows/deploy-worker.yml +0 -72
  46. package/.github/workflows/release.yml +0 -36
  47. package/CLAUDE.md +0 -86
  48. package/bun.lock +0 -377
  49. package/daemon/client/AnnotationEditor.tsx +0 -315
  50. package/daemon/client/AnnotationProvider.tsx +0 -195
  51. package/daemon/client/AnnotationSidebar.tsx +0 -627
  52. package/daemon/client/FileIcon.tsx +0 -76
  53. package/daemon/client/PlanRenderer.tsx +0 -614
  54. package/daemon/client/Popover.tsx +0 -350
  55. package/daemon/client/RenderErrorContext.ts +0 -11
  56. package/daemon/client/ResponsePreview.tsx +0 -186
  57. package/daemon/client/SessionContext.tsx +0 -3
  58. package/daemon/client/annotationContext.ts +0 -114
  59. package/daemon/client/annotationDom.ts +0 -104
  60. package/daemon/client/annotationDraft.test.ts +0 -188
  61. package/daemon/client/annotationDraft.ts +0 -130
  62. package/daemon/client/compat/preact-all.ts +0 -4
  63. package/daemon/client/components/Callout.tsx +0 -26
  64. package/daemon/client/components/Checklist.tsx +0 -37
  65. package/daemon/client/components/Choice.tsx +0 -176
  66. package/daemon/client/components/CodeBlock.tsx +0 -35
  67. package/daemon/client/components/Diff.tsx +0 -65
  68. package/daemon/client/components/FilePreview.tsx +0 -88
  69. package/daemon/client/components/Image.tsx +0 -45
  70. package/daemon/client/components/Markdown.tsx +0 -44
  71. package/daemon/client/components/Mermaid.tsx +0 -565
  72. package/daemon/client/components/Note.tsx +0 -14
  73. package/daemon/client/components/Priority.tsx +0 -19
  74. package/daemon/client/components/ResponseNote.tsx +0 -19
  75. package/daemon/client/components/Section.tsx +0 -33
  76. package/daemon/client/components/Table.tsx +0 -35
  77. package/daemon/client/components/Task.tsx +0 -76
  78. package/daemon/client/components/UserInput.tsx +0 -174
  79. package/daemon/client/components/renderMarkdownChildren.tsx +0 -97
  80. package/daemon/client/generateMarkdown.ts +0 -273
  81. package/daemon/client/highlightRange.ts +0 -224
  82. package/daemon/client/popoverPosition.ts +0 -31
  83. package/daemon/client/runtime.ts +0 -123
  84. package/daemon/client/theme.css +0 -66
  85. package/daemon/client/useTextAnnotation.tsx +0 -188
  86. package/daemon/client/utils.ts +0 -13
  87. package/daemon/src/compiler.test.ts +0 -57
  88. package/daemon/src/compiler.ts +0 -360
  89. package/daemon/src/handlers/utils.ts +0 -14
  90. package/daemon/src/router.ts +0 -16
  91. package/daemon/src/session-secrets.test.ts +0 -83
  92. package/daemon/src/session.ts +0 -612
  93. package/daemon/src/watcher.ts +0 -71
  94. package/daemon/src/websocket.test.ts +0 -99
  95. package/daemon/src/websocket.ts +0 -211
  96. package/tsconfig.json +0 -14
  97. package/workers/canvas-share/README.md +0 -159
  98. package/workers/canvas-share/oblaka.ts +0 -56
  99. package/workers/canvas-share/package.json +0 -25
  100. package/workers/canvas-share/public/assets/.gitkeep +0 -0
  101. package/workers/canvas-share/public/assets/client.css +0 -2
  102. package/workers/canvas-share/public/assets/client.js +0 -91
  103. package/workers/canvas-share/public/assets/components.js +0 -65
  104. package/workers/canvas-share/public/assets/jsx-runtime.js +0 -3
  105. package/workers/canvas-share/public/assets/preact-compat.js +0 -1
  106. package/workers/canvas-share/public/assets/runtime.js +0 -1
  107. package/workers/canvas-share/public/index.html +0 -385
  108. package/workers/canvas-share/public/screenshot-dark.png +0 -0
  109. package/workers/canvas-share/public/screenshot-light.png +0 -0
  110. package/workers/canvas-share/src/index.test.ts +0 -209
  111. package/workers/canvas-share/src/index.ts +0 -518
  112. package/workers/canvas-share/src/types.ts +0 -119
  113. package/workers/canvas-share/src/util.ts +0 -156
  114. package/workers/canvas-share/src/validation.ts +0 -160
  115. package/workers/canvas-share/tsconfig.json +0 -16
@@ -1,518 +0,0 @@
1
- /**
2
- * Canvas Share Worker
3
- *
4
- * Accepts canvas snapshots pushed by the agent-canvas daemon and serves
5
- * them to reviewers over capability URLs. Collects feedback from reviewers
6
- * and exposes it for the daemon to poll back.
7
- *
8
- * Storage:
9
- * R2 (BLOBS):
10
- * shares/{shareId}/payload.json — full SharePayload (audit/debug)
11
- * shares/{shareId}/record.json — ShareRecord (lightweight metadata)
12
- * shares/{shareId}/canvas/{file}.js — compiled canvas JS
13
- * shares/{shareId}/canvas/{file}.jsx — original JSX (optional)
14
- * shares/{shareId}/uploads/{hash}.{ext} — reviewer uploads (content-addressed)
15
- *
16
- * KV (FEEDBACK):
17
- * fb:{shareId}:{submittedAt}:{id} — individual feedback entries (TTL = share TTL)
18
- * rl:{bucket}:{identity}:{minute} — rate limit counters (TTL 120s)
19
- *
20
- * Security model: capability URLs (24-char hex shareId) + optional bearer
21
- * token gating share creation. No reviewer auth — anyone with the link can
22
- * comment. Owner token (returned to the daemon at create time) can be used
23
- * to revoke / delete the share.
24
- *
25
- * For deployment instructions see workers/canvas-share/README.md.
26
- */
27
-
28
- import type { Env, ShareRecord, FeedbackEntry } from "./types";
29
- import { LIMITS } from "./types";
30
- import {
31
- json,
32
- CORS_HEADERS,
33
- randomHexId,
34
- rateLimitShareCreate,
35
- rateLimitFeedback,
36
- rateLimitUpload,
37
- verifyShareAuth,
38
- readBodyWithLimit,
39
- timingSafeEqual,
40
- } from "./util";
41
- import {
42
- validateSharePayload,
43
- validateFeedbackBody,
44
- validateShareId,
45
- } from "./validation";
46
-
47
- // ----- Share creation -------------------------------------------------------
48
-
49
- async function handleCreateShare(req: Request, env: Env, origin: string): Promise<Response> {
50
- const auth = verifyShareAuth(env, req);
51
- if (auth) return auth;
52
-
53
- const rl = await rateLimitShareCreate(env, req);
54
- if (rl) return rl;
55
-
56
- const bodyText = await readBodyWithLimit(req, LIMITS.SHARE_PAYLOAD_BYTES);
57
- if (typeof bodyText !== "string") return bodyText;
58
-
59
- let parsed: unknown;
60
- try {
61
- parsed = JSON.parse(bodyText);
62
- } catch {
63
- return json({ error: "Invalid JSON" }, { status: 400 });
64
- }
65
-
66
- const validation = validateSharePayload(parsed);
67
- if (typeof validation === "string") return json({ error: validation }, { status: 400 });
68
- const payload = validation;
69
-
70
- const shareId = randomHexId(12); // 24 hex chars = 96 bits
71
- const ownerToken = randomHexId(16); // 128 bits — proof of ownership for delete/revoke
72
- const ttlSeconds = parseInt(env.SHARE_TTL_SECONDS || "", 10) || LIMITS.DEFAULT_SHARE_TTL_SECONDS;
73
- const now = new Date();
74
- const expiresAt = new Date(now.getTime() + ttlSeconds * 1000).toISOString();
75
-
76
- const record: ShareRecord = {
77
- shareId,
78
- ownerToken,
79
- createdAt: now.toISOString(),
80
- expiresAt,
81
- componentsVersion: payload.runtime.componentsVersion,
82
- origin: payload.origin,
83
- canvasFilenames: payload.canvasFiles.map((cf) => cf.filename),
84
- ...(payload.encryption ? { encryption: payload.encryption } : {}),
85
- };
86
-
87
- const prefix = `shares/${shareId}`;
88
-
89
- // Persist payload, record, and each canvas file separately so the worker
90
- // can serve compiled JS without parsing the full payload on every hit.
91
- await env.BLOBS.put(`${prefix}/payload.json`, JSON.stringify(payload), {
92
- httpMetadata: { contentType: "application/json" },
93
- });
94
- await env.BLOBS.put(`${prefix}/record.json`, JSON.stringify(record), {
95
- httpMetadata: { contentType: "application/json" },
96
- });
97
-
98
- for (const cf of payload.canvasFiles) {
99
- const jsName = cf.filename.replace(/\.jsx$/, ".js");
100
- await env.BLOBS.put(`${prefix}/canvas/${jsName}`, cf.compiledJs, {
101
- httpMetadata: { contentType: "application/javascript" },
102
- });
103
- if (cf.sourceJsx) {
104
- await env.BLOBS.put(`${prefix}/canvas/${cf.filename}`, cf.sourceJsx, {
105
- httpMetadata: { contentType: "text/plain; charset=utf-8" },
106
- });
107
- }
108
- }
109
-
110
- return json({
111
- shareId,
112
- url: `${origin}/s/${shareId}`,
113
- ownerToken,
114
- expiresAt,
115
- });
116
- }
117
-
118
- // ----- Share record loading (with cache) ------------------------------------
119
-
120
- async function loadRecord(shareId: string, env: Env): Promise<ShareRecord | null> {
121
- const obj = await env.BLOBS.get(`shares/${shareId}/record.json`);
122
- if (!obj) return null;
123
- try {
124
- const record = (await obj.json()) as ShareRecord;
125
- if (record.expiresAt && new Date(record.expiresAt).getTime() < Date.now()) {
126
- return null; // expired
127
- }
128
- return record;
129
- } catch {
130
- return null;
131
- }
132
- }
133
-
134
- // ----- Share metadata + canvas serving --------------------------------------
135
-
136
- async function handleGetMeta(shareId: string, env: Env): Promise<Response> {
137
- const record = await loadRecord(shareId, env);
138
- if (!record) return json({ error: "Not found or expired" }, { status: 404 });
139
-
140
- // Daemon-compatible meta shape so the same client bundle works unchanged
141
- // in shared mode. A shared canvas only ever exposes its single baked
142
- // revision, but we still surface it as a one-element revisions array.
143
- const rev = record.origin.revision;
144
- return json({
145
- shareId,
146
- origin: record.origin,
147
- projectRoot: undefined,
148
- currentRevision: rev,
149
- canvasFiles: record.canvasFilenames,
150
- revisions: [
151
- {
152
- revision: rev,
153
- ...(record.origin.label ? { label: record.origin.label } : {}),
154
- canvasFiles: record.canvasFilenames.map((filename) => ({ filename })),
155
- createdAt: record.origin.createdAt,
156
- hasFeedback: false,
157
- feedbackConsumed: false,
158
- },
159
- ],
160
- shares: [],
161
- shareEnabled: false,
162
- runtime: { componentsVersion: record.componentsVersion },
163
- // Lets the browser know whether origin.sessionId / origin.label and
164
- // canvas JS bodies need decrypting with the URL-fragment key.
165
- ...(record.encryption ? { encryption: record.encryption } : {}),
166
- });
167
- }
168
-
169
- async function handleGetCanvas(shareId: string, filename: string, env: Env): Promise<Response> {
170
- if (filename.includes("..") || filename.includes("/")) {
171
- return json({ error: "Invalid filename" }, { status: 400 });
172
- }
173
- const record = await loadRecord(shareId, env);
174
- if (!record) return json({ error: "Not found or expired" }, { status: 404 });
175
-
176
- const obj = await env.BLOBS.get(`shares/${shareId}/canvas/${filename}`);
177
- if (!obj) return json({ error: "Not found" }, { status: 404 });
178
- return new Response(obj.body, {
179
- headers: {
180
- "Content-Type": obj.httpMetadata?.contentType || "application/javascript",
181
- // Compiled canvas JS is immutable per share — cache aggressively.
182
- "Cache-Control": "public, max-age=31536000, immutable",
183
- ...CORS_HEADERS,
184
- },
185
- });
186
- }
187
-
188
- // ----- Feedback submit / list ----------------------------------------------
189
-
190
- async function handleSubmitFeedback(shareId: string, req: Request, env: Env): Promise<Response> {
191
- const rl = await rateLimitFeedback(env, req, shareId);
192
- if (rl) return rl;
193
-
194
- const record = await loadRecord(shareId, env);
195
- if (!record) return json({ error: "Share not found or expired" }, { status: 404 });
196
-
197
- const bodyText = await readBodyWithLimit(req, LIMITS.FEEDBACK_BODY_BYTES);
198
- if (typeof bodyText !== "string") return bodyText;
199
-
200
- let parsed: unknown;
201
- try {
202
- parsed = JSON.parse(bodyText);
203
- } catch {
204
- return json({ error: "Invalid JSON" }, { status: 400 });
205
- }
206
-
207
- const validation = validateFeedbackBody(parsed);
208
- if (typeof validation === "string") return json({ error: validation }, { status: 400 });
209
- const body = validation;
210
-
211
- const entry: FeedbackEntry = {
212
- id: randomHexId(8),
213
- shareId,
214
- revision: body.revision,
215
- submittedAt: new Date().toISOString(),
216
- author: {
217
- id: body.author.id || randomHexId(8),
218
- // Ciphertext names are opaque — slicing them would corrupt the IV /
219
- // tag and make the entry undecryptable. The schema already caps the
220
- // overall length so the abuse-protection still holds.
221
- name: body.encryption ? body.author.name : body.author.name.trim().slice(0, LIMITS.AUTHOR_NAME_LENGTH),
222
- },
223
- annotations: body.annotations || [],
224
- ...(body.generalNote ? { generalNote: body.generalNote } : {}),
225
- ...(body.encryption ? { encryption: body.encryption } : {}),
226
- };
227
-
228
- // KV key is sortable so `since` queries can use prefix listing efficiently.
229
- // The full entry is stored as the value AND mirrored into list metadata
230
- // so handleListFeedback can avoid an N+1 round-trip.
231
- const key = `fb:${shareId}:${entry.submittedAt}:${entry.id}`;
232
- const ttlSeconds = Math.max(
233
- 60,
234
- Math.floor((new Date(record.expiresAt).getTime() - Date.now()) / 1000),
235
- );
236
- await env.FEEDBACK.put(key, JSON.stringify(entry), {
237
- expirationTtl: ttlSeconds,
238
- metadata: { submittedAt: entry.submittedAt, revision: entry.revision },
239
- });
240
-
241
- return json({ ok: true, id: entry.id });
242
- }
243
-
244
- async function handleListFeedback(shareId: string, req: Request, env: Env): Promise<Response> {
245
- const url = new URL(req.url);
246
- const since = url.searchParams.get("since") || "";
247
-
248
- const prefix = `fb:${shareId}:`;
249
- // Page through KV. List metadata lets us skip filtered entries without
250
- // fetching values; we only fetch values for keys that pass the `since`
251
- // filter, and we parallelize the gets.
252
- const entries: FeedbackEntry[] = [];
253
- let latestAt = since;
254
- let cursor: string | undefined;
255
-
256
- do {
257
- const list = await env.FEEDBACK.list({
258
- prefix,
259
- cursor,
260
- limit: 1000,
261
- });
262
- cursor = list.list_complete ? undefined : list.cursor;
263
-
264
- const wanted = list.keys.filter((k) => {
265
- const rest = k.name.slice(prefix.length);
266
- const submittedAt = rest.slice(0, rest.lastIndexOf(":"));
267
- return !since || submittedAt > since;
268
- });
269
- if (wanted.length === 0) {
270
- if (list.list_complete) break;
271
- continue;
272
- }
273
-
274
- const fetched = await Promise.all(
275
- wanted.map(async (k) => {
276
- const value = await env.FEEDBACK.get(k.name);
277
- if (!value) return null;
278
- try {
279
- return JSON.parse(value) as FeedbackEntry;
280
- } catch {
281
- return null;
282
- }
283
- }),
284
- );
285
-
286
- for (const entry of fetched) {
287
- if (!entry) continue;
288
- entries.push(entry);
289
- if (entry.submittedAt > latestAt) latestAt = entry.submittedAt;
290
- }
291
- } while (cursor);
292
-
293
- return json({ entries, latestAt });
294
- }
295
-
296
- // ----- Share revoke ---------------------------------------------------------
297
-
298
- async function handleRevokeShare(shareId: string, req: Request, env: Env): Promise<Response> {
299
- const record = await loadRecord(shareId, env);
300
- if (!record) return json({ error: "Not found" }, { status: 404 });
301
-
302
- const header = req.headers.get("Authorization") || "";
303
- const match = header.match(/^Bearer\s+(.+)$/);
304
- if (!match || !timingSafeEqual(match[1], record.ownerToken)) {
305
- return json({ error: "Unauthorized" }, { status: 401 });
306
- }
307
-
308
- // Delete payload + record + canvas files. Uploads and feedback are left
309
- // to expire via TTL — fully purging them on revoke would require listing
310
- // all R2 keys, which is expensive. Marking the record as deleted is
311
- // sufficient because every load goes through `loadRecord` which would
312
- // 404 once the record is gone.
313
- const prefix = `shares/${shareId}/`;
314
- const list = await env.BLOBS.list({ prefix });
315
- await Promise.all(list.objects.map((o) => env.BLOBS.delete(o.key)));
316
-
317
- return json({ ok: true });
318
- }
319
-
320
- // ----- Annotation uploads ---------------------------------------------------
321
-
322
- async function handleUpload(shareId: string, req: Request, env: Env, origin: string): Promise<Response> {
323
- const rl = await rateLimitUpload(env, req, shareId);
324
- if (rl) return rl;
325
-
326
- const record = await loadRecord(shareId, env);
327
- if (!record) return json({ error: "Share not found or expired" }, { status: 404 });
328
-
329
- const cl = req.headers.get("Content-Length");
330
- if (cl && parseInt(cl, 10) > LIMITS.UPLOAD_BYTES) {
331
- return json({ error: "Image too large" }, { status: 413 });
332
- }
333
-
334
- const formData = await req.formData();
335
- const file = formData.get("image") as unknown as File | null;
336
- if (!file || typeof (file as any).arrayBuffer !== "function") {
337
- return json({ error: "No image provided" }, { status: 400 });
338
- }
339
- if (!file.type.startsWith("image/")) return json({ error: "Not an image" }, { status: 400 });
340
- if (file.size > LIMITS.UPLOAD_BYTES) {
341
- return json({ error: "Image too large" }, { status: 413 });
342
- }
343
-
344
- // Content-addressed storage so duplicate uploads dedupe.
345
- const bytes = new Uint8Array(await file.arrayBuffer());
346
- const hashBuf = await crypto.subtle.digest("SHA-256", bytes);
347
- const hashHex = Array.from(new Uint8Array(hashBuf), (b) => b.toString(16).padStart(2, "0"))
348
- .join("")
349
- .slice(0, 32);
350
- const ext = (file.name.split(".").pop() || "png").toLowerCase().replace(/[^a-z0-9]/g, "");
351
- const key = `shares/${shareId}/uploads/${hashHex}.${ext}`;
352
-
353
- await env.BLOBS.put(key, bytes, {
354
- httpMetadata: { contentType: file.type },
355
- });
356
-
357
- return json({
358
- url: `${origin}/s/${shareId}/uploads/${hashHex}.${ext}`,
359
- mime: file.type,
360
- });
361
- }
362
-
363
- async function handleUploadServe(shareId: string, filename: string, env: Env): Promise<Response> {
364
- if (filename.includes("/") || filename.includes("..")) {
365
- return json({ error: "Invalid filename" }, { status: 400 });
366
- }
367
- const obj = await env.BLOBS.get(`shares/${shareId}/uploads/${filename}`);
368
- if (!obj) return json({ error: "Not found" }, { status: 404 });
369
- return new Response(obj.body, {
370
- headers: {
371
- "Content-Type": obj.httpMetadata?.contentType || "application/octet-stream",
372
- "Cache-Control": "public, max-age=86400",
373
- ...CORS_HEADERS,
374
- },
375
- });
376
- }
377
-
378
-
379
- // ----- HTML shell -----------------------------------------------------------
380
-
381
- /** HTML served at /s/:shareId. Loads runtime bundles + booted client in
382
- * shared mode. The `__CANVAS_SHARE__` global is the entry point used by
383
- * clientApi.ts to flip into shared mode. */
384
- function sharedCanvasHtml(shareId: string): string {
385
- return `<!DOCTYPE html>
386
- <html lang="en" data-theme="dark">
387
- <head>
388
- <meta charset="utf-8" />
389
- <meta name="viewport" content="width=device-width, initial-scale=1" />
390
- <title>Shared Canvas</title>
391
- <script>!function(){var p=localStorage.getItem('canvas-theme')||'auto';document.documentElement.dataset.theme=p==='auto'?(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'):p}()</script>
392
- <link rel="stylesheet" href="/assets/client.css" />
393
- <link rel="stylesheet" href="https://unpkg.com/@highlightjs/cdn-assets@11.11.1/styles/github-dark-dimmed.min.css" />
394
- <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
395
- <script>window.__CANVAS_SHARE__ = { shareId: ${JSON.stringify(shareId)}, mode: "shared" };</script>
396
- </head>
397
- <body class="min-h-screen">
398
- <div id="root"></div>
399
- <script src="https://unpkg.com/@highlightjs/cdn-assets@11.11.1/highlight.min.js"></script>
400
- <script src="https://unpkg.com/mermaid@11.4.1/dist/mermaid.min.js"></script>
401
- <script>mermaid.initialize({ startOnLoad: false, theme: document.documentElement.dataset.theme === 'light' ? 'default' : 'dark' });</script>
402
- <script type="importmap">
403
- {
404
- "imports": {
405
- "react": "/assets/preact-compat.js",
406
- "react-dom": "/assets/preact-compat.js",
407
- "react-dom/client": "/assets/preact-compat.js",
408
- "react/jsx-runtime": "/assets/jsx-runtime.js",
409
- "react/jsx-dev-runtime": "/assets/jsx-runtime.js",
410
- "#canvas/components": "/assets/components.js",
411
- "#canvas/runtime": "/assets/runtime.js"
412
- }
413
- }
414
- </script>
415
- <script type="module" src="/assets/client.js"></script>
416
- </body>
417
- </html>`;
418
- }
419
-
420
- // ----- Router ---------------------------------------------------------------
421
-
422
- export default {
423
- async fetch(req: Request, env: Env): Promise<Response> {
424
- const url = new URL(req.url);
425
- const origin = `${url.protocol}//${url.host}`;
426
-
427
- if (req.method === "OPTIONS") {
428
- return new Response(null, { status: 204, headers: CORS_HEADERS });
429
- }
430
-
431
- // Landing page — served from public/index.html via ASSETS binding
432
- if (url.pathname === "/" && req.method === "GET") {
433
- return env.ASSETS.fetch(req);
434
- }
435
-
436
- // Health check
437
- if (url.pathname === "/health" && req.method === "GET") {
438
- return json({ ok: true });
439
- }
440
-
441
- // POST /shares — create
442
- if (url.pathname === "/shares" && req.method === "POST") {
443
- return handleCreateShare(req, env, origin);
444
- }
445
-
446
- // POST /shares/:shareId/revoke — owner-only delete
447
- const revokeMatch = url.pathname.match(/^\/shares\/([a-f0-9]+)\/revoke$/);
448
- if (revokeMatch && req.method === "POST") {
449
- if (!validateShareId(revokeMatch[1])) return json({ error: "Invalid shareId" }, { status: 400 });
450
- return handleRevokeShare(revokeMatch[1], req, env);
451
- }
452
-
453
- // GET /shares/:shareId/feedback — list (called by daemon poller)
454
- const feedbackListMatch = url.pathname.match(/^\/shares\/([a-f0-9]+)\/feedback$/);
455
- if (feedbackListMatch && req.method === "GET") {
456
- if (!validateShareId(feedbackListMatch[1])) return json({ error: "Invalid shareId" }, { status: 400 });
457
- return handleListFeedback(feedbackListMatch[1], req, env);
458
- }
459
-
460
- // GET /s/:shareId — HTML shell
461
- const shellMatch = url.pathname.match(/^\/s\/([a-f0-9]+)\/?$/);
462
- if (shellMatch && req.method === "GET") {
463
- if (!validateShareId(shellMatch[1])) return json({ error: "Invalid shareId" }, { status: 400 });
464
- // Verify the share exists before serving the shell so reviewers see
465
- // a clear 404 instead of a broken canvas.
466
- const record = await loadRecord(shellMatch[1], env);
467
- if (!record) return json({ error: "Share not found or expired" }, { status: 404 });
468
- return new Response(sharedCanvasHtml(shellMatch[1]), {
469
- headers: {
470
- "Content-Type": "text/html; charset=utf-8",
471
- "Cache-Control": "no-cache",
472
- },
473
- });
474
- }
475
-
476
- // GET /s/:shareId/meta
477
- const metaMatch = url.pathname.match(/^\/s\/([a-f0-9]+)\/meta$/);
478
- if (metaMatch && req.method === "GET") {
479
- if (!validateShareId(metaMatch[1])) return json({ error: "Invalid shareId" }, { status: 400 });
480
- return handleGetMeta(metaMatch[1], env);
481
- }
482
-
483
- // GET /s/:shareId/canvas/:filename
484
- const canvasMatch = url.pathname.match(/^\/s\/([a-f0-9]+)\/canvas\/(.+)$/);
485
- if (canvasMatch && req.method === "GET") {
486
- if (!validateShareId(canvasMatch[1])) return json({ error: "Invalid shareId" }, { status: 400 });
487
- return handleGetCanvas(canvasMatch[1], canvasMatch[2], env);
488
- }
489
-
490
- // POST /s/:shareId/feedback
491
- const feedbackPostMatch = url.pathname.match(/^\/s\/([a-f0-9]+)\/feedback$/);
492
- if (feedbackPostMatch && req.method === "POST") {
493
- if (!validateShareId(feedbackPostMatch[1])) return json({ error: "Invalid shareId" }, { status: 400 });
494
- return handleSubmitFeedback(feedbackPostMatch[1], req, env);
495
- }
496
-
497
- // POST /s/:shareId/upload
498
- const uploadMatch = url.pathname.match(/^\/s\/([a-f0-9]+)\/upload$/);
499
- if (uploadMatch && req.method === "POST") {
500
- if (!validateShareId(uploadMatch[1])) return json({ error: "Invalid shareId" }, { status: 400 });
501
- return handleUpload(uploadMatch[1], req, env, origin);
502
- }
503
-
504
- // GET /s/:shareId/uploads/:filename
505
- const uploadServeMatch = url.pathname.match(/^\/s\/([a-f0-9]+)\/uploads\/(.+)$/);
506
- if (uploadServeMatch && req.method === "GET") {
507
- if (!validateShareId(uploadServeMatch[1])) return json({ error: "Invalid shareId" }, { status: 400 });
508
- return handleUploadServe(uploadServeMatch[1], uploadServeMatch[2], env);
509
- }
510
-
511
- // Static assets — served by Wrangler's [assets] binding.
512
- if (url.pathname.startsWith("/assets/")) {
513
- return env.ASSETS.fetch(req);
514
- }
515
-
516
- return json({ error: "Not found" }, { status: 404 });
517
- },
518
- };
@@ -1,119 +0,0 @@
1
- /**
2
- * Wire types shared between the daemon and the worker. Keep these in sync
3
- * with daemon/src/share.ts and daemon/src/session.ts. Treat this file as
4
- * the protocol definition — bump the `version` field in SharePayload
5
- * whenever the schema changes incompatibly.
6
- */
7
-
8
- export interface Env {
9
- BLOBS: R2Bucket;
10
- FEEDBACK: KVNamespace;
11
- ASSETS: Fetcher;
12
- /** Optional. If set, share creation requires this token in
13
- * `Authorization: Bearer <token>`. Recommended in production. */
14
- SHARE_AUTH_TOKEN?: string;
15
- /** Default share TTL in seconds. Defaults to 30 days. */
16
- SHARE_TTL_SECONDS?: string;
17
- }
18
-
19
- export interface EncryptionMeta {
20
- alg: "AES-GCM";
21
- v: 1;
22
- }
23
-
24
- export interface SharePayload {
25
- version: 1;
26
- /** Present when sessionId/label/compiledJs/sourceJsx are ciphertext. */
27
- encryption?: EncryptionMeta;
28
- origin: {
29
- sessionId: string;
30
- revision: number;
31
- label?: string;
32
- createdAt: string;
33
- };
34
- canvasFiles: Array<{
35
- filename: string;
36
- compiledJs: string;
37
- sourceJsx?: string;
38
- }>;
39
- runtime: { componentsVersion: string };
40
- }
41
-
42
- export interface ShareRecord {
43
- shareId: string;
44
- ownerToken: string;
45
- createdAt: string;
46
- expiresAt: string;
47
- componentsVersion: string;
48
- origin: SharePayload["origin"];
49
- canvasFilenames: string[];
50
- /** Mirrored from the SharePayload so consumers (meta endpoint, daemon
51
- * feedback poller) know whether they're looking at ciphertext fields. */
52
- encryption?: EncryptionMeta;
53
- }
54
-
55
- export interface FeedbackPostBody {
56
- author: { id?: string; name: string };
57
- annotations: Array<{
58
- id: string;
59
- snippet: string;
60
- note: string;
61
- createdAt: string;
62
- filePath?: string;
63
- canvasFile?: string;
64
- context?: unknown;
65
- attachments?: Array<{ url: string; mime?: string }>;
66
- }>;
67
- generalNote?: string;
68
- revision: number;
69
- /** Present when name/snippet/note/etc. fields are ciphertext. The worker
70
- * stores the marker verbatim; the daemon uses it to decide whether to
71
- * decrypt with the share's encryption key. */
72
- encryption?: EncryptionMeta;
73
- }
74
-
75
- export interface FeedbackEntry {
76
- id: string;
77
- shareId: string;
78
- revision: number;
79
- submittedAt: string;
80
- author: { id: string; name: string };
81
- annotations: FeedbackPostBody["annotations"];
82
- generalNote?: string;
83
- encryption?: EncryptionMeta;
84
- }
85
-
86
- // --- Limits — single source of truth -------------------------------------
87
-
88
- export const LIMITS = {
89
- /** Maximum size of a single share payload (POST /shares body). */
90
- SHARE_PAYLOAD_BYTES: 2 * 1024 * 1024, // 2 MB
91
- /** Maximum size of a single feedback POST body. */
92
- FEEDBACK_BODY_BYTES: 256 * 1024, // 256 KB
93
- /** Maximum size of an annotation upload. */
94
- UPLOAD_BYTES: 5 * 1024 * 1024, // 5 MB
95
- /** Maximum length of a reviewer-supplied display name (plaintext mode). */
96
- AUTHOR_NAME_LENGTH: 80,
97
- /** Loose upper bound for `author.name` accepted by validation. Plaintext
98
- * names are sliced down to AUTHOR_NAME_LENGTH in the handler; ciphertext
99
- * names from encrypted shares need extra headroom for IV + tag + base64
100
- * overhead and the unknown plaintext length. 1 KB is plenty. */
101
- AUTHOR_NAME_CIPHERTEXT_LENGTH: 1024,
102
- /** Maximum number of annotations per feedback submission. */
103
- MAX_ANNOTATIONS_PER_FEEDBACK: 200,
104
- /** Maximum length of generalNote markdown (plaintext mode). */
105
- GENERAL_NOTE_LENGTH: 10_000,
106
- /** Loose upper bound for `generalNote` accepted by validation. */
107
- GENERAL_NOTE_CIPHERTEXT_LENGTH: 32_000,
108
- /** Default share TTL when SHARE_TTL_SECONDS env var is not set. */
109
- DEFAULT_SHARE_TTL_SECONDS: 30 * 24 * 60 * 60,
110
- } as const;
111
-
112
- export const RATE_LIMITS = {
113
- /** Max share creates per minute, keyed by client IP. */
114
- SHARE_CREATE_PER_MINUTE: 5,
115
- /** Max feedback submissions per minute, keyed by client IP + shareId. */
116
- FEEDBACK_PER_MINUTE: 20,
117
- /** Max uploads per minute, keyed by client IP + shareId. */
118
- UPLOAD_PER_MINUTE: 30,
119
- } as const;