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.
- package/daemon/client/App.tsx +2 -3
- package/daemon/client/PlanRenderer.tsx +2 -2
- package/daemon/client/ShareDialog.tsx +12 -0
- package/daemon/client/clientApi.ts +156 -3
- package/daemon/client/shareCrypto.ts +123 -0
- package/daemon/dist/client.js +68 -68
- package/daemon/dist/components.js +57 -57
- package/daemon/src/remote-feedback.ts +104 -4
- package/daemon/src/session.ts +5 -0
- package/daemon/src/share-e2e.test.ts +277 -0
- package/daemon/src/share.test.ts +76 -1
- package/daemon/src/share.ts +85 -5
- package/daemon/tsconfig.json +4 -1
- package/package.json +1 -1
- package/workers/canvas-share/public/assets/client.js +68 -68
- package/workers/canvas-share/public/assets/components.js +57 -57
- package/workers/canvas-share/src/index.test.ts +44 -0
- package/workers/canvas-share/src/index.ts +9 -1
- package/workers/canvas-share/src/types.ts +24 -2
- package/workers/canvas-share/src/validation.ts +61 -18
|
@@ -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:
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
package/daemon/src/session.ts
CHANGED
|
@@ -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
|
+
});
|
package/daemon/src/share.test.ts
CHANGED
|
@@ -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;
|
package/daemon/src/share.ts
CHANGED
|
@@ -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
|
|
140
|
-
* session.
|
|
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
|
|
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
|
|
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
|
-
|