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.
- package/README.md +14 -0
- package/bin/lib/commands/daemon.ts +3 -6
- package/bin/lib/commands/push.ts +1 -1
- package/bin/lib/daemon-lifecycle.ts +65 -72
- package/bin/lib/helpers.test.ts +1 -0
- package/bin/lib/helpers.ts +30 -74
- package/daemon/build.ts +37 -16
- package/daemon/client/App.tsx +27 -61
- package/daemon/client/CompareView.tsx +4 -4
- package/daemon/client/FileBrowser.tsx +4 -4
- package/daemon/client/FileViewer.tsx +4 -4
- package/daemon/client/OverviewView.tsx +2 -2
- package/daemon/client/RevisionSelect.tsx +1 -1
- package/daemon/client/canvas-runtime.ts +1 -0
- package/daemon/client/clientApi.ts +18 -0
- package/daemon/client/components/index.ts +3 -16
- package/daemon/client/styles.css +4 -457
- package/daemon/dist/annotations-runtime.js +1 -0
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +76 -76
- package/daemon/dist/components.js +59 -59
- package/daemon/dist/index.html +1 -0
- package/daemon/dist/preact-compat.js +1 -1
- package/daemon/dist/runtime.js +1 -1
- package/daemon/src/components.ts +13 -0
- package/daemon/src/handlers/api.ts +13 -9
- package/daemon/src/handlers/files.ts +2 -3
- package/daemon/src/handlers/static.ts +1 -1
- package/daemon/src/handlers/uploads.ts +2 -3
- package/daemon/src/paths.ts +10 -24
- package/daemon/src/remote-feedback.test.ts +1 -1
- package/daemon/src/remote-feedback.ts +1 -1
- package/daemon/src/secrets-api.test.ts +2 -2
- package/daemon/src/server.ts +11 -10
- package/daemon/src/share-e2e.test.ts +1 -1
- package/daemon/src/share.test.ts +1 -1
- package/daemon/src/share.ts +1 -1
- package/daemon/tsconfig.json +1 -3
- package/package.json +11 -4
- package/.github/dark.png +0 -0
- package/.github/light.png +0 -0
- package/.github/workflows/ci.yml +0 -48
- package/.github/workflows/deploy-worker-prod.yml +0 -19
- package/.github/workflows/deploy-worker-stage.yml +0 -21
- package/.github/workflows/deploy-worker.yml +0 -72
- package/.github/workflows/release.yml +0 -36
- package/CLAUDE.md +0 -86
- package/bun.lock +0 -377
- package/daemon/client/AnnotationEditor.tsx +0 -315
- package/daemon/client/AnnotationProvider.tsx +0 -195
- package/daemon/client/AnnotationSidebar.tsx +0 -627
- package/daemon/client/FileIcon.tsx +0 -76
- package/daemon/client/PlanRenderer.tsx +0 -614
- package/daemon/client/Popover.tsx +0 -350
- package/daemon/client/RenderErrorContext.ts +0 -11
- package/daemon/client/ResponsePreview.tsx +0 -186
- package/daemon/client/SessionContext.tsx +0 -3
- package/daemon/client/annotationContext.ts +0 -114
- package/daemon/client/annotationDom.ts +0 -104
- package/daemon/client/annotationDraft.test.ts +0 -188
- package/daemon/client/annotationDraft.ts +0 -130
- package/daemon/client/compat/preact-all.ts +0 -4
- package/daemon/client/components/Callout.tsx +0 -26
- package/daemon/client/components/Checklist.tsx +0 -37
- package/daemon/client/components/Choice.tsx +0 -176
- package/daemon/client/components/CodeBlock.tsx +0 -35
- package/daemon/client/components/Diff.tsx +0 -65
- package/daemon/client/components/FilePreview.tsx +0 -88
- package/daemon/client/components/Image.tsx +0 -45
- package/daemon/client/components/Markdown.tsx +0 -44
- package/daemon/client/components/Mermaid.tsx +0 -565
- package/daemon/client/components/Note.tsx +0 -14
- package/daemon/client/components/Priority.tsx +0 -19
- package/daemon/client/components/ResponseNote.tsx +0 -19
- package/daemon/client/components/Section.tsx +0 -33
- package/daemon/client/components/Table.tsx +0 -35
- package/daemon/client/components/Task.tsx +0 -76
- package/daemon/client/components/UserInput.tsx +0 -174
- package/daemon/client/components/renderMarkdownChildren.tsx +0 -97
- package/daemon/client/generateMarkdown.ts +0 -273
- package/daemon/client/highlightRange.ts +0 -224
- package/daemon/client/popoverPosition.ts +0 -31
- package/daemon/client/runtime.ts +0 -123
- package/daemon/client/theme.css +0 -66
- package/daemon/client/useTextAnnotation.tsx +0 -188
- package/daemon/client/utils.ts +0 -13
- package/daemon/src/compiler.test.ts +0 -57
- package/daemon/src/compiler.ts +0 -360
- package/daemon/src/handlers/utils.ts +0 -14
- package/daemon/src/router.ts +0 -16
- package/daemon/src/session-secrets.test.ts +0 -83
- package/daemon/src/session.ts +0 -612
- package/daemon/src/watcher.ts +0 -71
- package/daemon/src/websocket.test.ts +0 -99
- package/daemon/src/websocket.ts +0 -211
- package/tsconfig.json +0 -14
- package/workers/canvas-share/README.md +0 -159
- package/workers/canvas-share/oblaka.ts +0 -56
- package/workers/canvas-share/package.json +0 -25
- package/workers/canvas-share/public/assets/.gitkeep +0 -0
- package/workers/canvas-share/public/assets/client.css +0 -2
- package/workers/canvas-share/public/assets/client.js +0 -91
- package/workers/canvas-share/public/assets/components.js +0 -65
- package/workers/canvas-share/public/assets/jsx-runtime.js +0 -3
- package/workers/canvas-share/public/assets/preact-compat.js +0 -1
- package/workers/canvas-share/public/assets/runtime.js +0 -1
- package/workers/canvas-share/public/index.html +0 -385
- package/workers/canvas-share/public/screenshot-dark.png +0 -0
- package/workers/canvas-share/public/screenshot-light.png +0 -0
- package/workers/canvas-share/src/index.test.ts +0 -209
- package/workers/canvas-share/src/index.ts +0 -518
- package/workers/canvas-share/src/types.ts +0 -119
- package/workers/canvas-share/src/util.ts +0 -156
- package/workers/canvas-share/src/validation.ts +0 -160
- 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;
|