agent-canvas 0.1.34 → 0.1.36

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.
@@ -1,5 +1,6 @@
1
- import type { SessionManager, RemoteFeedbackEntry } from "./session";
1
+ import type { SessionManager, RemoteFeedbackEntry, RemoteAnnotation } from "./session";
2
2
  import { loadShareConfig } from "./share";
3
+ import { importShareKey, decryptString } from "../client/shareCrypto";
3
4
 
4
5
  /**
5
6
  * Background polling loop for remote feedback.
@@ -19,12 +20,72 @@ import { loadShareConfig } from "./share";
19
20
  const POLL_INTERVAL_MS = 5_000;
20
21
  const MAX_BACKOFF_MS = 60_000;
21
22
 
23
+ /** Wire-format feedback entry as returned by the worker. When the
24
+ * reviewer submitted from an encrypted share their browser ciphertexted
25
+ * the secret fields and stamped this with `encryption`; the daemon
26
+ * decrypts using the key stashed on the local ShareEntry. */
27
+ interface WireFeedbackEntry {
28
+ id: string;
29
+ shareId: string;
30
+ revision: number;
31
+ submittedAt: string;
32
+ author: { id: string; name: string };
33
+ annotations: Array<{
34
+ id: string;
35
+ snippet: string;
36
+ note: string;
37
+ createdAt: string;
38
+ filePath?: string;
39
+ canvasFile?: string;
40
+ context?: unknown;
41
+ attachments?: Array<{ url: string; mime?: string }>;
42
+ }>;
43
+ generalNote?: string;
44
+ encryption?: { alg: string; v: number };
45
+ }
46
+
22
47
  interface WorkerFeedbackResponse {
23
- entries: RemoteFeedbackEntry[];
48
+ entries: WireFeedbackEntry[];
24
49
  /** Server-reported high water mark — echo back in the next `since` param */
25
50
  latestAt?: string;
26
51
  }
27
52
 
53
+ /** Decrypt an encrypted-wire feedback entry into the plaintext shape used
54
+ * throughout the rest of the daemon. Throws if any field fails to
55
+ * decrypt — caller treats that as a transient error so we re-try later. */
56
+ async function decryptFeedbackEntry(entry: WireFeedbackEntry, key: CryptoKey): Promise<RemoteFeedbackEntry> {
57
+ const annotations: RemoteAnnotation[] = await Promise.all(
58
+ entry.annotations.map(async (a) => {
59
+ const out: RemoteAnnotation = {
60
+ id: a.id,
61
+ snippet: await decryptString(key, a.snippet),
62
+ note: await decryptString(key, a.note),
63
+ createdAt: a.createdAt,
64
+ };
65
+ if (a.filePath) out.filePath = await decryptString(key, a.filePath);
66
+ if (a.canvasFile) out.canvasFile = await decryptString(key, a.canvasFile);
67
+ if (typeof a.context === "string" && a.context.length > 0) {
68
+ out.context = JSON.parse(await decryptString(key, a.context));
69
+ }
70
+ if (a.attachments) out.attachments = a.attachments;
71
+ return out;
72
+ }),
73
+ );
74
+
75
+ return {
76
+ id: entry.id,
77
+ shareId: entry.shareId,
78
+ revision: entry.revision,
79
+ submittedAt: entry.submittedAt,
80
+ author: {
81
+ id: entry.author.id,
82
+ name: await decryptString(key, entry.author.name),
83
+ },
84
+ annotations,
85
+ ...(entry.generalNote ? { generalNote: await decryptString(key, entry.generalNote) } : {}),
86
+ };
87
+ }
88
+
28
89
  export function startRemoteFeedbackPoller(
29
90
  sessionManager: SessionManager,
30
91
  broadcastRemoteFeedback: (sessionId: string, revision: number, entries: RemoteFeedbackEntry[]) => void,
@@ -32,6 +93,18 @@ export function startRemoteFeedbackPoller(
32
93
  ): { stop: () => void } {
33
94
  let stopped = false;
34
95
 
96
+ // Per-share CryptoKey cache — `importKey` is async and pure given the
97
+ // same encoded key, so we memoize it for the lifetime of the poller.
98
+ const keyCache = new Map<string, Promise<CryptoKey>>();
99
+ function getKey(shareId: string, encoded: string): Promise<CryptoKey> {
100
+ let cached = keyCache.get(shareId);
101
+ if (!cached) {
102
+ cached = importShareKey(encoded);
103
+ keyCache.set(shareId, cached);
104
+ }
105
+ return cached;
106
+ }
107
+
35
108
  // Per-share consecutive error count → exponential backoff so a dead
36
109
  // worker doesn't generate a fire-hose of failed requests.
37
110
  const errorCounts = new Map<string, number>();
@@ -41,7 +114,11 @@ export function startRemoteFeedbackPoller(
41
114
  return Math.min(MAX_BACKOFF_MS, POLL_INTERVAL_MS * Math.pow(2, errors - 1));
42
115
  }
43
116
 
44
- async function pollShare(sessionId: string, share: { shareId: string; lastFeedbackAt?: string }, endpoint: string) {
117
+ async function pollShare(
118
+ sessionId: string,
119
+ share: { shareId: string; lastFeedbackAt?: string; encryptionKey?: string },
120
+ endpoint: string,
121
+ ) {
45
122
  try {
46
123
  const since = share.lastFeedbackAt ?? "";
47
124
  const url = `${endpoint}/shares/${share.shareId}/feedback${since ? `?since=${encodeURIComponent(since)}` : ""}`;
@@ -60,8 +137,31 @@ export function startRemoteFeedbackPoller(
60
137
 
61
138
  if (!data.entries || data.entries.length === 0) return;
62
139
 
63
- const byRev = new Map<number, RemoteFeedbackEntry[]>();
140
+ // Decrypt any entries that came in encrypted. Plaintext entries
141
+ // (legacy submissions from non-encrypted shares) pass through.
142
+ const decrypted: RemoteFeedbackEntry[] = [];
64
143
  for (const e of data.entries) {
144
+ if (e.encryption) {
145
+ if (!share.encryptionKey) {
146
+ console.warn(`[remote-feedback] ${share.shareId}: received encrypted feedback but no local key — skipping entry ${e.id}`);
147
+ continue;
148
+ }
149
+ try {
150
+ const key = await getKey(share.shareId, share.encryptionKey);
151
+ decrypted.push(await decryptFeedbackEntry(e, key));
152
+ } catch (err: any) {
153
+ console.warn(`[remote-feedback] ${share.shareId}: failed to decrypt entry ${e.id}: ${err.message || err}`);
154
+ continue;
155
+ }
156
+ } else {
157
+ decrypted.push(e as RemoteFeedbackEntry);
158
+ }
159
+ }
160
+
161
+ if (decrypted.length === 0) return;
162
+
163
+ const byRev = new Map<number, RemoteFeedbackEntry[]>();
164
+ for (const e of decrypted) {
65
165
  if (!byRev.has(e.revision)) byRev.set(e.revision, []);
66
166
  byRev.get(e.revision)!.push(e);
67
167
  }
@@ -78,6 +78,11 @@ export interface ShareEntry {
78
78
  expiresAt?: string;
79
79
  /** ISO timestamp of the most recent remote feedback we've seen */
80
80
  lastFeedbackAt?: string;
81
+ /** Base64url-encoded AES-GCM key for end-to-end encrypted shares. The
82
+ * same key is also embedded in `url` as a `#k=...` fragment so reviewers
83
+ * can decrypt; the daemon keeps a copy here so it can decrypt feedback
84
+ * it polls back from the worker. Absent on legacy unencrypted shares. */
85
+ encryptionKey?: string;
81
86
  }
82
87
 
83
88
  export interface SessionData {
@@ -0,0 +1,277 @@
1
+ /**
2
+ * End-to-end test: exercises daemon-side encryption + the real worker
3
+ * fetch handler against in-memory R2/KV stubs, then plays the role of
4
+ * the browser to decrypt the canvas JS and meta, and finally the role of
5
+ * a reviewer to submit encrypted feedback and verify the daemon poller
6
+ * decrypts it back.
7
+ *
8
+ * No network, no Miniflare — just direct fetch-handler invocation, which
9
+ * exercises every production code path except the actual Workers runtime.
10
+ */
11
+
12
+ import { describe, expect, test } from "bun:test";
13
+ import { mkdtempSync, rmSync } from "fs";
14
+ import { join } from "path";
15
+ import { tmpdir } from "os";
16
+
17
+ import workerHandler from "../../workers/canvas-share/src/index";
18
+ import type { Env } from "../../workers/canvas-share/src/types";
19
+ import { SessionManager } from "./session";
20
+ import { buildSharePayload, encryptSharePayload } from "./share";
21
+ import {
22
+ generateShareKey,
23
+ exportShareKey,
24
+ importShareKey,
25
+ encryptString,
26
+ decryptString,
27
+ ENCRYPTION_META,
28
+ } from "../client/shareCrypto";
29
+
30
+ // ---- Minimal in-memory R2 / KV stubs ---------------------------------------
31
+
32
+ function makeR2(): R2Bucket {
33
+ const store = new Map<string, { body: Uint8Array; contentType?: string }>();
34
+ return {
35
+ async put(key: string, value: any, opts?: any) {
36
+ const bytes = typeof value === "string"
37
+ ? new TextEncoder().encode(value)
38
+ : value instanceof Uint8Array
39
+ ? value
40
+ : new Uint8Array(await new Response(value).arrayBuffer());
41
+ store.set(key, { body: bytes, contentType: opts?.httpMetadata?.contentType });
42
+ return null as any;
43
+ },
44
+ async get(key: string) {
45
+ const v = store.get(key);
46
+ if (!v) return null;
47
+ return {
48
+ body: new Response(v.body).body,
49
+ async text() { return new TextDecoder().decode(v.body); },
50
+ async json() { return JSON.parse(new TextDecoder().decode(v.body)); },
51
+ async arrayBuffer() { return v.body.buffer; },
52
+ httpMetadata: { contentType: v.contentType },
53
+ } as any;
54
+ },
55
+ async delete(key: string) { store.delete(key); },
56
+ async list(opts?: any) {
57
+ const prefix = opts?.prefix ?? "";
58
+ const objects = [];
59
+ for (const k of store.keys()) {
60
+ if (k.startsWith(prefix)) objects.push({ key: k });
61
+ }
62
+ return { objects, truncated: false, delimitedPrefixes: [] } as any;
63
+ },
64
+ } as unknown as R2Bucket;
65
+ }
66
+
67
+ function makeKV(): KVNamespace {
68
+ const store = new Map<string, { value: string; meta?: any; expireAt?: number }>();
69
+ return {
70
+ async put(key: string, value: string, opts?: any) {
71
+ const expireAt = opts?.expirationTtl ? Date.now() + opts.expirationTtl * 1000 : undefined;
72
+ store.set(key, { value, meta: opts?.metadata, expireAt });
73
+ },
74
+ async get(key: string) {
75
+ const v = store.get(key);
76
+ if (!v) return null;
77
+ if (v.expireAt && v.expireAt < Date.now()) { store.delete(key); return null; }
78
+ return v.value;
79
+ },
80
+ async delete(key: string) { store.delete(key); },
81
+ async list(opts?: any) {
82
+ const prefix = opts?.prefix ?? "";
83
+ const keys = [];
84
+ for (const [k, v] of store.entries()) {
85
+ if (!k.startsWith(prefix)) continue;
86
+ if (v.expireAt && v.expireAt < Date.now()) continue;
87
+ keys.push({ name: k, metadata: v.meta });
88
+ }
89
+ return { keys, list_complete: true, cursor: "" } as any;
90
+ },
91
+ } as unknown as KVNamespace;
92
+ }
93
+
94
+ function makeEnv(): Env {
95
+ return {
96
+ BLOBS: makeR2(),
97
+ FEEDBACK: makeKV(),
98
+ ASSETS: { fetch: async () => new Response("asset", { status: 200 }) } as any,
99
+ };
100
+ }
101
+
102
+ // ---- The full flow ---------------------------------------------------------
103
+
104
+ describe("encrypted share e2e (daemon → worker → browser → reviewer → daemon)", () => {
105
+ test("round-trips canvas JS, meta, and feedback through the real worker handler", async () => {
106
+ const testDir = mkdtempSync(join(tmpdir(), "canvas-e2e-"));
107
+ try {
108
+ // ----- 1. Daemon side: prepare a session + revision ------------------
109
+ const sm = new SessionManager(testDir);
110
+ const sid = "sess-secret-id";
111
+ const SECRET_LABEL = "Secret quarterly plan";
112
+ const SECRET_COMPILED = "/* SECRET */ export default function Plan(){ return null; }";
113
+ const SECRET_JSX = "<Section title='Top secret'>Confidential strategy.</Section>";
114
+
115
+ sm.upsert(sid, new Map([["plan.jsx", SECRET_JSX]]), "/tmp", SECRET_LABEL);
116
+ sm.saveCompiled(sid, "plan.jsx", SECRET_COMPILED);
117
+
118
+ // ----- 2. Daemon encrypts the payload --------------------------------
119
+ const plaintext = buildSharePayload(sm, sid, 1, "1.0.0");
120
+ const key = await generateShareKey();
121
+ const encodedKey = await exportShareKey(key);
122
+ const encryptedPayload = await encryptSharePayload(plaintext, key);
123
+
124
+ // ----- 3. POST it to the real worker handler -------------------------
125
+ const env = makeEnv();
126
+ const createRes = await workerHandler.fetch(
127
+ new Request("https://example.com/shares", {
128
+ method: "POST",
129
+ headers: { "Content-Type": "application/json" },
130
+ body: JSON.stringify(encryptedPayload),
131
+ }),
132
+ env,
133
+ );
134
+ expect(createRes.status).toBe(200);
135
+ const created = await createRes.json() as { shareId: string; url: string; ownerToken: string };
136
+ expect(created.shareId).toMatch(/^[a-f0-9]{24}$/);
137
+ expect(created.url).toBe(`https://example.com/s/${created.shareId}`);
138
+
139
+ // ----- 4. Confirm what's stored in R2 is ciphertext, NOT plaintext ----
140
+ const storedPayloadObj = await env.BLOBS.get(`shares/${created.shareId}/payload.json`);
141
+ const storedPayload = JSON.parse(await storedPayloadObj!.text()) as any;
142
+ expect(storedPayload.encryption).toEqual({ alg: "AES-GCM", v: 1 });
143
+ expect(storedPayload.origin.sessionId).not.toBe(sid);
144
+ expect(storedPayload.origin.label).not.toBe(SECRET_LABEL);
145
+ expect(JSON.stringify(storedPayload)).not.toContain(SECRET_LABEL);
146
+ expect(JSON.stringify(storedPayload)).not.toContain("SECRET");
147
+ expect(JSON.stringify(storedPayload)).not.toContain("Confidential");
148
+
149
+ const storedCanvasJsObj = await env.BLOBS.get(`shares/${created.shareId}/canvas/plan.js`);
150
+ const storedCanvasJs = await storedCanvasJsObj!.text();
151
+ expect(storedCanvasJs).not.toContain("SECRET");
152
+ expect(storedCanvasJs).not.toContain("function Plan");
153
+
154
+ // ----- 5. Browser side: fetch /meta, decrypt origin.label/sessionId --
155
+ const metaRes = await workerHandler.fetch(
156
+ new Request(`https://example.com/s/${created.shareId}/meta`),
157
+ env,
158
+ );
159
+ expect(metaRes.status).toBe(200);
160
+ const meta = await metaRes.json() as any;
161
+ expect(meta.encryption).toEqual({ alg: "AES-GCM", v: 1 });
162
+
163
+ const browserKey = await importShareKey(encodedKey);
164
+ const decryptedSessionId = await decryptString(browserKey, meta.origin.sessionId);
165
+ const decryptedLabel = await decryptString(browserKey, meta.origin.label);
166
+ expect(decryptedSessionId).toBe(sid);
167
+ expect(decryptedLabel).toBe(SECRET_LABEL);
168
+
169
+ // ----- 6. Browser fetches canvas JS and decrypts it -------------------
170
+ const canvasRes = await workerHandler.fetch(
171
+ new Request(`https://example.com/s/${created.shareId}/canvas/plan.js`),
172
+ env,
173
+ );
174
+ expect(canvasRes.status).toBe(200);
175
+ const ciphertextBody = await canvasRes.text();
176
+ const decryptedJs = await decryptString(browserKey, ciphertextBody);
177
+ expect(decryptedJs).toBe(SECRET_COMPILED);
178
+
179
+ // ----- 7. Reviewer submits encrypted feedback ------------------------
180
+ const SECRET_NAME = "Dr. Confidential";
181
+ const SECRET_NOTE = "This section reveals our pricing strategy.";
182
+ const SECRET_GENERAL = "Overall: please redact slide 3 before sharing further.";
183
+ const encryptedFeedback = {
184
+ author: {
185
+ id: "reviewer-1",
186
+ name: await encryptString(browserKey, SECRET_NAME),
187
+ },
188
+ revision: 1,
189
+ annotations: [{
190
+ id: "ann-1",
191
+ createdAt: "2026-05-18T12:00:00Z",
192
+ snippet: await encryptString(browserKey, "[Section] Top secret"),
193
+ note: await encryptString(browserKey, SECRET_NOTE),
194
+ canvasFile: await encryptString(browserKey, "plan.jsx"),
195
+ }],
196
+ generalNote: await encryptString(browserKey, SECRET_GENERAL),
197
+ encryption: ENCRYPTION_META,
198
+ };
199
+
200
+ const submitRes = await workerHandler.fetch(
201
+ new Request(`https://example.com/s/${created.shareId}/feedback`, {
202
+ method: "POST",
203
+ headers: { "Content-Type": "application/json" },
204
+ body: JSON.stringify(encryptedFeedback),
205
+ }),
206
+ env,
207
+ );
208
+ expect(submitRes.status).toBe(200);
209
+
210
+ // ----- 8. Confirm KV-stored feedback has no plaintext leakage --------
211
+ const kvList = await (env.FEEDBACK as any).list({ prefix: `fb:${created.shareId}:` });
212
+ expect(kvList.keys.length).toBe(1);
213
+ const kvValueRaw = await env.FEEDBACK.get(kvList.keys[0].name);
214
+ expect(kvValueRaw).not.toBeNull();
215
+ expect(kvValueRaw!).not.toContain(SECRET_NAME);
216
+ expect(kvValueRaw!).not.toContain(SECRET_NOTE);
217
+ expect(kvValueRaw!).not.toContain(SECRET_GENERAL);
218
+ expect(kvValueRaw!).not.toContain("plan.jsx");
219
+
220
+ // ----- 9. Daemon polls /feedback and decrypts ------------------------
221
+ const pollRes = await workerHandler.fetch(
222
+ new Request(`https://example.com/shares/${created.shareId}/feedback`),
223
+ env,
224
+ );
225
+ expect(pollRes.status).toBe(200);
226
+ const polled = await pollRes.json() as { entries: any[] };
227
+ expect(polled.entries.length).toBe(1);
228
+ const wireEntry = polled.entries[0];
229
+ expect(wireEntry.encryption).toEqual({ alg: "AES-GCM", v: 1 });
230
+
231
+ // Daemon-side key (same encodedKey, freshly imported as the poller does)
232
+ const daemonKey = await importShareKey(encodedKey);
233
+ const decryptedName = await decryptString(daemonKey, wireEntry.author.name);
234
+ const decryptedSnippet = await decryptString(daemonKey, wireEntry.annotations[0].snippet);
235
+ const decryptedNote = await decryptString(daemonKey, wireEntry.annotations[0].note);
236
+ const decryptedCanvasFile = await decryptString(daemonKey, wireEntry.annotations[0].canvasFile);
237
+ const decryptedGeneral = await decryptString(daemonKey, wireEntry.generalNote);
238
+
239
+ expect(decryptedName).toBe(SECRET_NAME);
240
+ expect(decryptedSnippet).toBe("[Section] Top secret");
241
+ expect(decryptedNote).toBe(SECRET_NOTE);
242
+ expect(decryptedCanvasFile).toBe("plan.jsx");
243
+ expect(decryptedGeneral).toBe(SECRET_GENERAL);
244
+
245
+ // ----- 10. Legacy unencrypted share still works ----------------------
246
+ const legacyEnv = makeEnv();
247
+ const legacyCreate = await workerHandler.fetch(
248
+ new Request("https://example.com/shares", {
249
+ method: "POST",
250
+ headers: { "Content-Type": "application/json" },
251
+ body: JSON.stringify(plaintext),
252
+ }),
253
+ legacyEnv,
254
+ );
255
+ expect(legacyCreate.status).toBe(200);
256
+ const legacy = await legacyCreate.json() as { shareId: string };
257
+
258
+ const legacyMeta = await (await workerHandler.fetch(
259
+ new Request(`https://example.com/s/${legacy.shareId}/meta`),
260
+ legacyEnv,
261
+ )).json() as any;
262
+ expect(legacyMeta.encryption).toBeUndefined();
263
+ expect(legacyMeta.origin.sessionId).toBe(sid);
264
+ expect(legacyMeta.origin.label).toBe(SECRET_LABEL);
265
+
266
+ const legacyCanvas = await (await workerHandler.fetch(
267
+ new Request(`https://example.com/s/${legacy.shareId}/canvas/plan.js`),
268
+ legacyEnv,
269
+ )).text();
270
+ expect(legacyCanvas).toBe(SECRET_COMPILED);
271
+
272
+ sm.remove(sid);
273
+ } finally {
274
+ try { rmSync(testDir, { recursive: true, force: true }); } catch {}
275
+ }
276
+ });
277
+ });
@@ -9,7 +9,8 @@ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "fs";
9
9
  import { join } from "path";
10
10
  import { tmpdir } from "os";
11
11
  import { SessionManager } from "./session";
12
- import { buildSharePayload, pushShareToWorker, shareRevision, loadShareConfig } from "./share";
12
+ import { buildSharePayload, pushShareToWorker, shareRevision, loadShareConfig, encryptSharePayload } from "./share";
13
+ import { generateShareKey, importShareKey, decryptString } from "../client/shareCrypto";
13
14
 
14
15
  let testDir: string;
15
16
  let originalSessionsDir: string | undefined;
@@ -195,12 +196,16 @@ describe("shareRevision", () => {
195
196
  const entry = await shareRevision(sm, sid, 1, {
196
197
  endpoint: "https://example.com",
197
198
  componentsVersion: "1.0.0",
199
+ encryption: false,
198
200
  });
199
201
 
200
202
  expect(entry.shareId).toBe("abc123def456");
201
203
  expect(entry.revision).toBe(1);
202
204
  expect(entry.ownerToken).toBe("owner");
203
205
  expect(entry.expiresAt).toBe("2026-12-31T00:00:00Z");
206
+ // Legacy unencrypted share: no key recorded, no #k= fragment.
207
+ expect(entry.encryptionKey).toBeUndefined();
208
+ expect(entry.url).toBe("https://example.com/s/abc123def456");
204
209
 
205
210
  const shares = sm.getShares(sid);
206
211
  expect(shares).toHaveLength(1);
@@ -208,6 +213,76 @@ describe("shareRevision", () => {
208
213
 
209
214
  sm.remove(sid);
210
215
  });
216
+
217
+ test("encrypts payload by default and appends #k= fragment to URL", async () => {
218
+ let captured: any = null;
219
+ (globalThis as any).fetch = async (_url: any, init: any) => {
220
+ captured = JSON.parse(init.body);
221
+ return new Response(JSON.stringify({
222
+ shareId: "encshareid01",
223
+ url: "https://example.com/s/encshareid01",
224
+ ownerToken: "owner",
225
+ }), { status: 200 });
226
+ };
227
+
228
+ const sm = new SessionManager(testDir);
229
+ const sid = `enc-${Date.now()}`;
230
+ sm.upsert(sid, new Map([["plan.jsx", "<Section>hi</Section>"]]), "/tmp", "Secret Label");
231
+ sm.saveCompiled(sid, "plan.jsx", "/* SECRET-COMPILED */ export default ()=>null");
232
+
233
+ const entry = await shareRevision(sm, sid, 1, {
234
+ endpoint: "https://example.com",
235
+ componentsVersion: "1.0.0",
236
+ encryption: true,
237
+ });
238
+
239
+ // Payload going to the worker must be ciphertext, not plaintext.
240
+ expect(captured.encryption).toEqual({ alg: "AES-GCM", v: 1 });
241
+ expect(captured.origin.sessionId).not.toBe(sid);
242
+ expect(captured.origin.label).not.toBe("Secret Label");
243
+ expect(captured.canvasFiles[0].compiledJs).not.toContain("SECRET-COMPILED");
244
+ expect(captured.origin.revision).toBe(1); // revision stays plaintext (routing)
245
+ expect(captured.canvasFiles[0].filename).toBe("plan.jsx"); // filename stays plaintext
246
+
247
+ // The URL handed to the user includes the key as a fragment, and the
248
+ // ShareEntry retains a local copy so the daemon can decrypt feedback.
249
+ expect(entry.url.startsWith("https://example.com/s/encshareid01#k=")).toBe(true);
250
+ expect(typeof entry.encryptionKey).toBe("string");
251
+
252
+ // Round-trip: import the stored key and confirm it decrypts the
253
+ // ciphertext that was actually sent to the worker.
254
+ const key = await importShareKey(entry.encryptionKey!);
255
+ expect(await decryptString(key, captured.origin.sessionId)).toBe(sid);
256
+ expect(await decryptString(key, captured.origin.label)).toBe("Secret Label");
257
+ expect(await decryptString(key, captured.canvasFiles[0].compiledJs)).toContain("SECRET-COMPILED");
258
+
259
+ sm.remove(sid);
260
+ });
261
+ });
262
+
263
+ describe("encryptSharePayload", () => {
264
+ test("leaves routing fields plaintext, encrypts secrets", async () => {
265
+ const key = await generateShareKey();
266
+ const result = await encryptSharePayload(
267
+ {
268
+ version: 1,
269
+ origin: { sessionId: "sess", revision: 7, label: "lbl", createdAt: "2026-04-09T00:00:00.000Z" },
270
+ canvasFiles: [
271
+ { filename: "plan.jsx", compiledJs: "JS-BODY", sourceJsx: "JSX-BODY" },
272
+ ],
273
+ runtime: { componentsVersion: "9.9.9" },
274
+ },
275
+ key,
276
+ );
277
+ expect(result.encryption).toEqual({ alg: "AES-GCM", v: 1 });
278
+ expect(result.origin.revision).toBe(7);
279
+ expect(result.origin.createdAt).toBe("2026-04-09T00:00:00.000Z");
280
+ expect(result.runtime.componentsVersion).toBe("9.9.9");
281
+ expect(result.canvasFiles[0].filename).toBe("plan.jsx");
282
+ expect(result.origin.sessionId).not.toBe("sess");
283
+ expect(result.canvasFiles[0].compiledJs).not.toBe("JS-BODY");
284
+ expect(result.canvasFiles[0].sourceJsx).not.toBe("JSX-BODY");
285
+ });
211
286
  });
212
287
 
213
288
  void mkdirSync;
@@ -1,4 +1,11 @@
1
1
  import type { SessionManager, ShareEntry } from "./session";
2
+ import {
3
+ generateShareKey,
4
+ exportShareKey,
5
+ encryptString,
6
+ ENCRYPTION_META,
7
+ type EncryptionMeta,
8
+ } from "../client/shareCrypto";
2
9
 
3
10
  /**
4
11
  * Canvas sharing: packages a specific revision into a self-contained
@@ -10,10 +17,25 @@ import type { SessionManager, ShareEntry } from "./session";
10
17
  * payload — the worker serves matching runtime from its own deploy, keyed
11
18
  * off `runtime.componentsVersion` in the payload. This keeps shares small
12
19
  * while still guaranteeing version compatibility.
20
+ *
21
+ * End-to-end encryption (default on)
22
+ * ----------------------------------
23
+ * Unless `CANVAS_SHARE_ENCRYPTION=off`, each share is encrypted with a
24
+ * fresh AES-GCM-256 key generated client-side here. The key is embedded
25
+ * in the returned URL as a `#k=<base64url>` fragment, which browsers do
26
+ * not transmit to the worker. The worker stores opaque ciphertext and
27
+ * routing metadata only. The daemon retains the key on `ShareEntry` so it
28
+ * can also decrypt feedback polled from the worker.
29
+ *
30
+ * Legacy unencrypted shares remain supported: the worker accepts both
31
+ * formats, and the browser detects which one to use based on the URL
32
+ * fragment.
13
33
  */
14
34
 
15
35
  export interface SharePayload {
16
36
  version: 1;
37
+ /** Present iff payload fields below are ciphertext rather than plaintext. */
38
+ encryption?: EncryptionMeta;
17
39
  origin: {
18
40
  sessionId: string;
19
41
  revision: number;
@@ -45,6 +67,8 @@ export interface ShareConfig {
45
67
  /** Optional bearer token. If the worker has SHARE_AUTH_TOKEN set, this
46
68
  * must match. Loaded from CANVAS_SHARE_AUTH_TOKEN env var on the daemon. */
47
69
  authToken?: string;
70
+ /** End-to-end encryption setting. Defaults to true. */
71
+ encryption?: boolean;
48
72
  }
49
73
 
50
74
  export function loadShareConfig(version: string): ShareConfig | null {
@@ -53,10 +77,16 @@ export function loadShareConfig(version: string): ShareConfig | null {
53
77
  return {
54
78
  endpoint: endpoint.replace(/\/+$/, ""),
55
79
  componentsVersion: version,
80
+ encryption: !isEncryptionDisabled(),
56
81
  ...(process.env.CANVAS_SHARE_AUTH_TOKEN ? { authToken: process.env.CANVAS_SHARE_AUTH_TOKEN } : {}),
57
82
  };
58
83
  }
59
84
 
85
+ function isEncryptionDisabled(): boolean {
86
+ const v = (process.env.CANVAS_SHARE_ENCRYPTION ?? "").trim().toLowerCase();
87
+ return v === "0" || v === "off" || v === "false" || v === "no";
88
+ }
89
+
60
90
  /**
61
91
  * Build a SharePayload for the given session/revision. Reads the already
62
92
  * compiled JS from the session's revision directory. Also includes the
@@ -101,6 +131,42 @@ export function buildSharePayload(
101
131
  };
102
132
  }
103
133
 
134
+ /**
135
+ * Encrypt a plaintext SharePayload in-place semantics, returning a new
136
+ * payload whose secret fields are ciphertext + the `encryption` marker.
137
+ * Fields kept plaintext (and why):
138
+ * - `version`, `runtime.componentsVersion`: routing
139
+ * - `origin.revision`, `origin.createdAt`: indexing/display
140
+ * - `canvasFiles[].filename`: used as the route key by the worker
141
+ * Everything else (sessionId, label, compiledJs, sourceJsx) is encrypted.
142
+ */
143
+ export async function encryptSharePayload(
144
+ payload: SharePayload,
145
+ key: CryptoKey,
146
+ ): Promise<SharePayload> {
147
+ const canvasFiles = await Promise.all(
148
+ payload.canvasFiles.map(async (cf) => ({
149
+ filename: cf.filename,
150
+ compiledJs: await encryptString(key, cf.compiledJs),
151
+ ...(cf.sourceJsx ? { sourceJsx: await encryptString(key, cf.sourceJsx) } : {}),
152
+ })),
153
+ );
154
+ return {
155
+ version: payload.version,
156
+ encryption: ENCRYPTION_META,
157
+ origin: {
158
+ sessionId: await encryptString(key, payload.origin.sessionId),
159
+ revision: payload.origin.revision,
160
+ ...(payload.origin.label
161
+ ? { label: await encryptString(key, payload.origin.label) }
162
+ : {}),
163
+ createdAt: payload.origin.createdAt,
164
+ },
165
+ canvasFiles,
166
+ runtime: payload.runtime,
167
+ };
168
+ }
169
+
104
170
  /**
105
171
  * POST the payload to the CF Worker. Returns the `shareId` + public URL
106
172
  * assigned by the worker. Throws on any HTTP error — the caller is expected
@@ -136,8 +202,10 @@ export async function pushShareToWorker(
136
202
  }
137
203
 
138
204
  /**
139
- * High-level: build payload, push to worker, record the share on the
140
- * session. Returns the ShareEntry that was recorded.
205
+ * High-level: build payload, optionally encrypt it, push to worker, record
206
+ * the share on the session. The URL stored on the ShareEntry includes the
207
+ * `#k=...` fragment when encryption is on so the user can simply copy &
208
+ * paste it to reviewers.
141
209
  */
142
210
  export async function shareRevision(
143
211
  sessionManager: SessionManager,
@@ -145,15 +213,28 @@ export async function shareRevision(
145
213
  revision: number,
146
214
  config: ShareConfig,
147
215
  ): Promise<ShareEntry> {
148
- const payload = buildSharePayload(sessionManager, sessionId, revision, config.componentsVersion);
216
+ const plaintext = buildSharePayload(sessionManager, sessionId, revision, config.componentsVersion);
217
+
218
+ const useEncryption = config.encryption !== false;
219
+ let payload = plaintext;
220
+ let encodedKey: string | undefined;
221
+ if (useEncryption) {
222
+ const key = await generateShareKey();
223
+ encodedKey = await exportShareKey(key);
224
+ payload = await encryptSharePayload(plaintext, key);
225
+ }
226
+
149
227
  const response = await pushShareToWorker(payload, config);
228
+ const url = encodedKey ? `${response.url}#k=${encodedKey}` : response.url;
229
+
150
230
  const entry: ShareEntry = {
151
231
  shareId: response.shareId,
152
- url: response.url,
232
+ url,
153
233
  revision,
154
234
  createdAt: new Date().toISOString(),
155
235
  ...(response.ownerToken ? { ownerToken: response.ownerToken } : {}),
156
236
  ...(response.expiresAt ? { expiresAt: response.expiresAt } : {}),
237
+ ...(encodedKey ? { encryptionKey: encodedKey } : {}),
157
238
  };
158
239
  sessionManager.addShare(sessionId, entry);
159
240
  return entry;
@@ -184,4 +265,3 @@ export async function revokeShare(
184
265
  }
185
266
  sessionManager.removeShare(sessionId, shareId);
186
267
  }
187
-