agent-canvas 0.2.1 → 0.6.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 +42 -17
- package/bin/agent-canvas.js +7 -7
- package/bin/agent-canvas.ts +48 -37
- package/bin/lib/commands/daemon.ts +46 -43
- package/bin/lib/commands/exec.ts +100 -100
- package/bin/lib/commands/fetch.ts +15 -15
- package/bin/lib/commands/install.ts +36 -36
- package/bin/lib/commands/instructions.ts +51 -51
- package/bin/lib/commands/push.ts +115 -97
- package/bin/lib/commands/review.ts +190 -0
- package/bin/lib/commands/watch.ts +24 -24
- package/bin/lib/config.ts +12 -12
- package/bin/lib/daemon-lifecycle.ts +47 -51
- package/bin/lib/helpers.ts +82 -76
- package/bin/lib/instructions/app-review.md +88 -0
- package/bin/lib/instructions/component-checklist.md +7 -5
- package/bin/lib/instructions/component-custom.md +20 -14
- package/bin/lib/instructions/component-diff.md +4 -3
- package/bin/lib/instructions/component-item.md +18 -8
- package/bin/lib/instructions/component-markdown.md +4 -2
- package/bin/lib/instructions/component-mermaid.md +12 -6
- package/bin/lib/instructions/component-multichoice.md +6 -2
- package/bin/lib/instructions/component-note.md +1 -1
- package/bin/lib/instructions/component-priority.md +1 -1
- package/bin/lib/instructions/component-rangeinput.md +9 -2
- package/bin/lib/instructions/component-secretinput.md +12 -11
- package/bin/lib/instructions/component-svg.md +62 -16
- package/bin/lib/instructions/component-table.md +6 -6
- package/bin/lib/instructions/component-usefeedback.md +8 -8
- package/bin/lib/instructions/component-userinput.md +6 -2
- package/bin/lib/instructions/flow-feature.md +22 -5
- package/bin/lib/instructions/flow-plan.md +5 -5
- package/bin/lib/instructions/flow-runbook.md +28 -25
- package/bin/lib/instructions/overview.md +27 -18
- package/bin/lib/instructions/theming.md +5 -0
- package/daemon/build.ts +171 -152
- package/daemon/client/App.tsx +1205 -1077
- package/daemon/client/CompareView.tsx +478 -458
- package/daemon/client/FileBrowser.tsx +241 -203
- package/daemon/client/FileViewer.tsx +220 -211
- package/daemon/client/OverviewView.tsx +283 -267
- package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
- package/daemon/client/RevisionSelect.tsx +111 -95
- package/daemon/client/SessionSwitcher.tsx +81 -81
- package/daemon/client/ShareDialog.tsx +211 -212
- package/daemon/client/apiSchemas.ts +78 -0
- package/daemon/client/canvas-runtime.ts +1 -1
- package/daemon/client/clientApi.ts +194 -206
- package/daemon/client/components/SecretInput.tsx +138 -138
- package/daemon/client/components/index.ts +2 -2
- package/daemon/client/domDiff.ts +341 -333
- package/daemon/client/errorMessage.ts +3 -0
- package/daemon/client/exportMarkdown.ts +222 -201
- package/daemon/client/lcs.ts +19 -14
- package/daemon/client/shareCrypto.ts +57 -57
- package/daemon/client/unifiedDiff.ts +660 -649
- package/daemon/dist/annotations-runtime.js +1 -1
- package/daemon/dist/client.css +2 -2
- package/daemon/dist/client.js +77 -80
- package/daemon/dist/components.js +59 -59
- package/daemon/dist/preact-compat.js +1 -1
- package/daemon/dist/review.js +361 -0
- package/daemon/dist/runtime.js +1 -1
- package/daemon/langMap.ts +24 -6
- package/daemon/review-widget/anchor.ts +187 -0
- package/daemon/review-widget/client.ts +95 -0
- package/daemon/review-widget/index.tsx +36 -0
- package/daemon/review-widget/markup.ts +71 -0
- package/daemon/review-widget/screenshot.ts +91 -0
- package/daemon/review-widget/source.ts +103 -0
- package/daemon/review-widget/styles.css +359 -0
- package/daemon/review-widget/text-modules.d.ts +4 -0
- package/daemon/review-widget/tsconfig.json +16 -0
- package/daemon/review-widget/widget.tsx +436 -0
- package/daemon/src/components.ts +3 -3
- package/daemon/src/handlers/api.ts +330 -328
- package/daemon/src/handlers/files.ts +112 -106
- package/daemon/src/handlers/static.ts +47 -46
- package/daemon/src/handlers/uploads.ts +61 -56
- package/daemon/src/paths.ts +13 -10
- package/daemon/src/remote-feedback.ts +192 -192
- package/daemon/src/review/markdown.ts +52 -0
- package/daemon/src/review/model.ts +132 -0
- package/daemon/src/review/proxy.ts +177 -0
- package/daemon/src/review/server.ts +199 -0
- package/daemon/src/review/store.ts +151 -0
- package/daemon/src/server.ts +127 -86
- package/daemon/src/share.ts +163 -169
- package/daemon/src/stubFetch.ts +7 -0
- package/daemon/tsconfig.json +19 -19
- package/package.json +47 -47
- package/skills/canvas/SKILL.md +5 -1
- package/bin/lib/commands/exec.test.ts +0 -27
- package/bin/lib/helpers.test.ts +0 -79
- package/daemon/src/files-api.test.ts +0 -78
- package/daemon/src/remote-feedback.test.ts +0 -165
- package/daemon/src/secrets-api.test.ts +0 -123
- package/daemon/src/share-e2e.test.ts +0 -277
- package/daemon/src/share.test.ts +0 -289
|
@@ -1,277 +0,0 @@
|
|
|
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 "@fabrika/canvas-kernel/server";
|
|
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
DELETED
|
@@ -1,289 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for the share packager. Uses a temp directory to back a real
|
|
3
|
-
* SessionManager so we exercise the actual disk persistence path. The
|
|
4
|
-
* worker push itself is mocked via fetch override.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|
8
|
-
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "fs";
|
|
9
|
-
import { join } from "path";
|
|
10
|
-
import { tmpdir } from "os";
|
|
11
|
-
import { SessionManager } from "@fabrika/canvas-kernel/server";
|
|
12
|
-
import { buildSharePayload, pushShareToWorker, shareRevision, loadShareConfig, encryptSharePayload } from "./share";
|
|
13
|
-
import { generateShareKey, importShareKey, decryptString } from "../client/shareCrypto";
|
|
14
|
-
|
|
15
|
-
let testDir: string;
|
|
16
|
-
let originalSessionsDir: string | undefined;
|
|
17
|
-
|
|
18
|
-
beforeEach(() => {
|
|
19
|
-
testDir = mkdtempSync(join(tmpdir(), "canvas-share-test-"));
|
|
20
|
-
// Override SESSIONS_DIR via env (paths.ts reads at module load — for
|
|
21
|
-
// these tests we instead seed a SessionManager and inject revisions
|
|
22
|
-
// through its public API rather than touching its private dirs).
|
|
23
|
-
originalSessionsDir = process.env.CANVAS_TEST_SESSIONS_DIR;
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
afterEach(() => {
|
|
27
|
-
try { rmSync(testDir, { recursive: true, force: true }); } catch {}
|
|
28
|
-
if (originalSessionsDir !== undefined) {
|
|
29
|
-
process.env.CANVAS_TEST_SESSIONS_DIR = originalSessionsDir;
|
|
30
|
-
} else {
|
|
31
|
-
delete process.env.CANVAS_TEST_SESSIONS_DIR;
|
|
32
|
-
}
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
describe("loadShareConfig", () => {
|
|
36
|
-
test("strips trailing slashes", () => {
|
|
37
|
-
process.env.CANVAS_SHARE_ENDPOINT = "https://example.com/";
|
|
38
|
-
const cfg = loadShareConfig("0.0.0");
|
|
39
|
-
expect(cfg?.endpoint).toBe("https://example.com");
|
|
40
|
-
delete process.env.CANVAS_SHARE_ENDPOINT;
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("includes auth token when set", () => {
|
|
44
|
-
process.env.CANVAS_SHARE_ENDPOINT = "https://example.com";
|
|
45
|
-
process.env.CANVAS_SHARE_AUTH_TOKEN = "secret";
|
|
46
|
-
const cfg = loadShareConfig("0.0.0");
|
|
47
|
-
expect(cfg?.authToken).toBe("secret");
|
|
48
|
-
delete process.env.CANVAS_SHARE_ENDPOINT;
|
|
49
|
-
delete process.env.CANVAS_SHARE_AUTH_TOKEN;
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
describe("buildSharePayload", () => {
|
|
54
|
-
test("packages compiled JS + JSX source", () => {
|
|
55
|
-
const sm = new SessionManager(testDir);
|
|
56
|
-
const sid = `test-${Date.now()}`;
|
|
57
|
-
const files = new Map([["plan.jsx", "<Section>hi</Section>"]]);
|
|
58
|
-
sm.upsert(sid, files, "/tmp", "Test label");
|
|
59
|
-
sm.saveCompiled(sid, "plan.jsx", "/* compiled */ export default ()=>null");
|
|
60
|
-
|
|
61
|
-
const payload = buildSharePayload(sm, sid, 1, "0.1.27");
|
|
62
|
-
expect(payload.version).toBe(1);
|
|
63
|
-
expect(payload.origin.sessionId).toBe(sid);
|
|
64
|
-
expect(payload.origin.revision).toBe(1);
|
|
65
|
-
expect(payload.origin.label).toBe("Test label");
|
|
66
|
-
expect(payload.canvasFiles).toHaveLength(1);
|
|
67
|
-
expect(payload.canvasFiles[0].filename).toBe("plan.jsx");
|
|
68
|
-
expect(payload.canvasFiles[0].compiledJs).toContain("/* compiled */");
|
|
69
|
-
expect(payload.canvasFiles[0].sourceJsx).toContain("<Section>hi</Section>");
|
|
70
|
-
expect(payload.runtime.componentsVersion).toBe("0.1.27");
|
|
71
|
-
|
|
72
|
-
sm.remove(sid);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("throws when revision missing", () => {
|
|
76
|
-
const sm = new SessionManager(testDir);
|
|
77
|
-
const sid = `test-${Date.now()}`;
|
|
78
|
-
sm.upsert(sid, new Map([["plan.jsx", "x"]]), "/tmp");
|
|
79
|
-
sm.saveCompiled(sid, "plan.jsx", "x");
|
|
80
|
-
expect(() => buildSharePayload(sm, sid, 99, "0.0.0")).toThrow();
|
|
81
|
-
sm.remove(sid);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test("throws when no compiled JS", () => {
|
|
85
|
-
const sm = new SessionManager(testDir);
|
|
86
|
-
const sid = `test-${Date.now()}`;
|
|
87
|
-
sm.upsert(sid, new Map([["plan.jsx", "x"]]), "/tmp");
|
|
88
|
-
// Intentionally skip saveCompiled
|
|
89
|
-
expect(() => buildSharePayload(sm, sid, 1, "0.0.0")).toThrow();
|
|
90
|
-
sm.remove(sid);
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
describe("pushShareToWorker", () => {
|
|
95
|
-
const originalFetch = globalThis.fetch;
|
|
96
|
-
afterEach(() => { globalThis.fetch = originalFetch; });
|
|
97
|
-
|
|
98
|
-
test("posts JSON and unwraps the response", async () => {
|
|
99
|
-
let captured: { url: string; init: RequestInit } | null = null;
|
|
100
|
-
(globalThis as any).fetch = async (url: any, init: any) => {
|
|
101
|
-
captured = { url: String(url), init };
|
|
102
|
-
return new Response(JSON.stringify({
|
|
103
|
-
shareId: "deadbeef",
|
|
104
|
-
url: "https://example.com/s/deadbeef",
|
|
105
|
-
ownerToken: "ot",
|
|
106
|
-
expiresAt: "2026-12-31T00:00:00Z",
|
|
107
|
-
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
const result = await pushShareToWorker(
|
|
111
|
-
{
|
|
112
|
-
version: 1,
|
|
113
|
-
origin: { sessionId: "x", revision: 1, createdAt: "2026-01-01T00:00:00Z" },
|
|
114
|
-
canvasFiles: [{ filename: "p.jsx", compiledJs: "x" }],
|
|
115
|
-
runtime: { componentsVersion: "1.0.0" },
|
|
116
|
-
},
|
|
117
|
-
{ endpoint: "https://example.com", componentsVersion: "1.0.0" },
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
expect(result.shareId).toBe("deadbeef");
|
|
121
|
-
expect(result.url).toBe("https://example.com/s/deadbeef");
|
|
122
|
-
expect(result.ownerToken).toBe("ot");
|
|
123
|
-
expect(captured!.url).toBe("https://example.com/shares");
|
|
124
|
-
expect(captured!.init.method).toBe("POST");
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test("includes Authorization header when authToken set", async () => {
|
|
128
|
-
let captured: any = null;
|
|
129
|
-
(globalThis as any).fetch = async (_url: any, init: any) => {
|
|
130
|
-
captured = init;
|
|
131
|
-
return new Response(JSON.stringify({ shareId: "x", url: "x" }), { status: 200 });
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
await pushShareToWorker(
|
|
135
|
-
{
|
|
136
|
-
version: 1,
|
|
137
|
-
origin: { sessionId: "x", revision: 1, createdAt: "2026-01-01T00:00:00Z" },
|
|
138
|
-
canvasFiles: [{ filename: "p.jsx", compiledJs: "x" }],
|
|
139
|
-
runtime: { componentsVersion: "1.0.0" },
|
|
140
|
-
},
|
|
141
|
-
{ endpoint: "https://example.com", componentsVersion: "1.0.0", authToken: "topsecret" },
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
expect(captured.headers["Authorization"]).toBe("Bearer topsecret");
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test("throws on non-2xx", async () => {
|
|
148
|
-
(globalThis as any).fetch = async () => new Response("nope", { status: 500 });
|
|
149
|
-
await expect(
|
|
150
|
-
pushShareToWorker(
|
|
151
|
-
{
|
|
152
|
-
version: 1,
|
|
153
|
-
origin: { sessionId: "x", revision: 1, createdAt: "2026-01-01T00:00:00Z" },
|
|
154
|
-
canvasFiles: [{ filename: "p.jsx", compiledJs: "x" }],
|
|
155
|
-
runtime: { componentsVersion: "1.0.0" },
|
|
156
|
-
},
|
|
157
|
-
{ endpoint: "https://example.com", componentsVersion: "1.0.0" },
|
|
158
|
-
),
|
|
159
|
-
).rejects.toThrow(/500/);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test("throws on malformed response", async () => {
|
|
163
|
-
(globalThis as any).fetch = async () => new Response(JSON.stringify({ shareId: "x" }), { status: 200 });
|
|
164
|
-
await expect(
|
|
165
|
-
pushShareToWorker(
|
|
166
|
-
{
|
|
167
|
-
version: 1,
|
|
168
|
-
origin: { sessionId: "x", revision: 1, createdAt: "2026-01-01T00:00:00Z" },
|
|
169
|
-
canvasFiles: [{ filename: "p.jsx", compiledJs: "x" }],
|
|
170
|
-
runtime: { componentsVersion: "1.0.0" },
|
|
171
|
-
},
|
|
172
|
-
{ endpoint: "https://example.com", componentsVersion: "1.0.0" },
|
|
173
|
-
),
|
|
174
|
-
).rejects.toThrow(/invalid response/);
|
|
175
|
-
});
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
describe("shareRevision", () => {
|
|
179
|
-
const originalFetch = globalThis.fetch;
|
|
180
|
-
afterEach(() => { globalThis.fetch = originalFetch; });
|
|
181
|
-
|
|
182
|
-
test("records share entry on the session", async () => {
|
|
183
|
-
(globalThis as any).fetch = async () =>
|
|
184
|
-
new Response(JSON.stringify({
|
|
185
|
-
shareId: "abc123def456",
|
|
186
|
-
url: "https://example.com/s/abc123def456",
|
|
187
|
-
ownerToken: "owner",
|
|
188
|
-
expiresAt: "2026-12-31T00:00:00Z",
|
|
189
|
-
}), { status: 200 });
|
|
190
|
-
|
|
191
|
-
const sm = new SessionManager(testDir);
|
|
192
|
-
const sid = `test-${Date.now()}`;
|
|
193
|
-
sm.upsert(sid, new Map([["plan.jsx", "x"]]), "/tmp");
|
|
194
|
-
sm.saveCompiled(sid, "plan.jsx", "x");
|
|
195
|
-
|
|
196
|
-
const entry = await shareRevision(sm, sid, 1, {
|
|
197
|
-
endpoint: "https://example.com",
|
|
198
|
-
componentsVersion: "1.0.0",
|
|
199
|
-
encryption: false,
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
expect(entry.shareId).toBe("abc123def456");
|
|
203
|
-
expect(entry.revision).toBe(1);
|
|
204
|
-
expect(entry.ownerToken).toBe("owner");
|
|
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");
|
|
209
|
-
|
|
210
|
-
const shares = sm.getShares(sid);
|
|
211
|
-
expect(shares).toHaveLength(1);
|
|
212
|
-
expect(shares[0].shareId).toBe("abc123def456");
|
|
213
|
-
|
|
214
|
-
sm.remove(sid);
|
|
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
|
-
});
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
void mkdirSync;
|
|
289
|
-
void writeFileSync;
|