gentle-pi 4.3.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/README.md +10 -7
  2. package/assets/agents/gentle-ai-worker.md +1 -1
  3. package/assets/design-canvas/canvas.css +648 -0
  4. package/assets/design-canvas/canvas.js +1497 -0
  5. package/assets/design-canvas/index.html +88 -0
  6. package/assets/design-canvas/inspector.js +290 -0
  7. package/assets/orchestrator-delegation-reference.md +2 -2
  8. package/assets/orchestrator-verification.md +1 -1
  9. package/assets/orchestrator.md +2 -2
  10. package/bin/engram-guard +1 -1
  11. package/bin/gentle-shell.mjs +138 -155
  12. package/docs/bundled-install.md +47 -42
  13. package/docs/delegated-verification.md +6 -0
  14. package/docs/gentle-agents-activity.md +5 -1
  15. package/docs/gentle-music.md +82 -0
  16. package/docs/gentle-shell.md +80 -10
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +72 -55
  20. package/docs/session-profile-format.md +26 -0
  21. package/docs/voice.md +259 -0
  22. package/docs/yolo-mode.md +17 -2
  23. package/extensions/ask-user-choice.ts +4 -1
  24. package/extensions/gentle-agents.ts +187 -40
  25. package/extensions/gentle-ai.ts +1765 -1185
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +354 -0
  28. package/extensions/gentle-shell.ts +418 -79
  29. package/extensions/gentle-voice.ts +11 -0
  30. package/extensions/session-scratch.ts +41 -22
  31. package/extensions/skill-registry.ts +63 -8
  32. package/lib/agent-profiles.ts +26 -0
  33. package/lib/agents-config.ts +35 -0
  34. package/lib/agents-protocol.ts +57 -1
  35. package/lib/agents-runner.ts +52 -3
  36. package/lib/agents-session-transport.ts +101 -6
  37. package/lib/agents-widget.ts +14 -1
  38. package/lib/auto-permission-memory.ts +48 -18
  39. package/lib/auto-permission-runtime.ts +21 -6
  40. package/lib/clipboard-image.ts +147 -0
  41. package/lib/command-palette-catalog.ts +3 -2
  42. package/lib/commit-slice-signal.ts +1 -1
  43. package/lib/context-backstop.ts +36 -0
  44. package/lib/design-canvas-server.ts +947 -0
  45. package/lib/design-comments.ts +437 -0
  46. package/lib/design-handoffs.ts +313 -0
  47. package/lib/design-open-browser.ts +58 -0
  48. package/lib/design-sources.ts +120 -0
  49. package/lib/external-editor.ts +31 -0
  50. package/lib/gentle-design.ts +513 -0
  51. package/lib/gentle-shell-bundled.ts +141 -57
  52. package/lib/gentle-shell-launcher.ts +11 -4
  53. package/lib/gentle-shell-startup-update.ts +61 -9
  54. package/lib/gentle-shell-uninstall.ts +33 -1
  55. package/lib/image-attachments.ts +150 -0
  56. package/lib/inprocess-reviewer.ts +33 -2
  57. package/lib/jobs-sidebar-state.ts +22 -3
  58. package/lib/jobs-widget.ts +112 -0
  59. package/lib/nan-provider.ts +3 -0
  60. package/lib/native-review-cli.ts +11 -4
  61. package/lib/notification-audio-native.ts +149 -13
  62. package/lib/notification-audio.ts +4 -0
  63. package/lib/notification-pulse-worker.ts +82 -18
  64. package/lib/notification-service.ts +5 -1
  65. package/lib/orchestrator-presence.ts +78 -8
  66. package/lib/orchestrator-scope.ts +10 -0
  67. package/lib/orchestrator-state.ts +11 -3
  68. package/lib/permission-mode-policy.ts +2 -2
  69. package/lib/review-host-relay.ts +11 -2
  70. package/lib/review-reminder-receipt.ts +24 -1
  71. package/lib/session-profile-append-controller.ts +713 -0
  72. package/lib/session-profile-disk-reader.ts +182 -0
  73. package/lib/session-profile-freeze.ts +2 -2
  74. package/lib/session-profile-persistence.ts +15 -7
  75. package/lib/session-scratch.ts +52 -3
  76. package/lib/session-worktree-registry.ts +7 -0
  77. package/lib/shell-bar.ts +6 -7
  78. package/lib/shell-changes.ts +81 -5
  79. package/lib/shell-prompt.ts +4 -4
  80. package/lib/shell-sidebar-layout.ts +27 -7
  81. package/lib/shell-todo.ts +1 -1
  82. package/lib/verifier-admission.ts +5 -4
  83. package/lib/vim-editor-adapter.ts +14 -10
  84. package/lib/voice-artifacts.ts +188 -0
  85. package/lib/voice-command.ts +576 -0
  86. package/lib/voice-install.ts +455 -0
  87. package/lib/voice-platform.ts +99 -0
  88. package/lib/voice-push-to-talk.ts +262 -0
  89. package/lib/voice-session.ts +460 -0
  90. package/lib/voice-settings.ts +50 -0
  91. package/lib/voice-ui.ts +158 -0
  92. package/lib/yolo-session-policy.ts +58 -22
  93. package/package.json +1 -1
  94. package/runtime/gentle-shell-bundled.mjs +140 -56
  95. package/runtime/gentle-shell-launcher.mjs +11 -4
  96. package/runtime/gentle-shell-startup-update.mjs +61 -9
  97. package/runtime/gentle-shell-uninstall.mjs +33 -1
  98. package/runtime/native-review-cli.mjs +11 -4
  99. package/scripts/bundled-engram-migration.mjs +0 -6
  100. package/scripts/bundled-install.mjs +209 -13
  101. package/scripts/gentle-ai-installer.mjs +10 -10
  102. package/scripts/installer-downloads.mjs +31 -7
  103. package/scripts/installer-preflight.mjs +6 -4
  104. package/scripts/installer-windows.mjs +37 -7
  105. package/scripts/main-channel.mjs +91 -17
  106. package/scripts/pi-update.mjs +37 -7
  107. package/scripts/verify-package-files.mjs +26 -2
  108. package/scripts/voice-worker.cjs +476 -0
  109. package/skills/design/SKILL.md +110 -0
  110. package/tests/agent-profiles.test.ts +24 -0
  111. package/tests/agents-config.test.ts +61 -0
  112. package/tests/agents-fake-child.ts +8 -1
  113. package/tests/agents-protocol.test.ts +32 -0
  114. package/tests/agents-runner.test.ts +226 -5
  115. package/tests/agents-session-transport.test.ts +53 -3
  116. package/tests/ask-user-choice.test.ts +30 -0
  117. package/tests/auto-mode.test.ts +17 -0
  118. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  119. package/tests/auto-permission-runtime.test.ts +85 -1
  120. package/tests/autonomous-guard.test.ts +20 -0
  121. package/tests/bundled-install.test.ts +276 -5
  122. package/tests/clipboard-image.test.ts +114 -0
  123. package/tests/command-palette.test.ts +4 -4
  124. package/tests/context-backstop.test.ts +70 -4
  125. package/tests/delegate-for-reason-contract.test.ts +2 -1
  126. package/tests/design-canvas-assets.test.ts +710 -0
  127. package/tests/design-canvas-server.test.ts +1096 -0
  128. package/tests/design-comments.test.ts +201 -0
  129. package/tests/design-handoffs.test.ts +282 -0
  130. package/tests/design-open-browser.test.ts +70 -0
  131. package/tests/design-sources.test.ts +132 -0
  132. package/tests/engram-safety.test.ts +132 -57
  133. package/tests/external-editor.test.ts +84 -0
  134. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  135. package/tests/gentle-agents.test.ts +369 -24
  136. package/tests/gentle-ai-binary.test.ts +1 -1
  137. package/tests/gentle-ai-installer.test.ts +51 -51
  138. package/tests/gentle-ai.test.ts +928 -448
  139. package/tests/gentle-design.test.ts +1313 -0
  140. package/tests/gentle-music.test.ts +452 -0
  141. package/tests/gentle-shell-bundled.test.ts +321 -248
  142. package/tests/gentle-shell-launcher.test.ts +29 -2
  143. package/tests/gentle-shell-startup-update.test.ts +77 -1
  144. package/tests/gentle-shell-uninstall.test.ts +59 -0
  145. package/tests/gentle-shell.test.ts +227 -27
  146. package/tests/gentle-todo.test.ts +11 -0
  147. package/tests/image-attachments.test.ts +119 -0
  148. package/tests/image-paste-chips.test.ts +236 -0
  149. package/tests/inprocess-reviewer.test.ts +55 -2
  150. package/tests/installer-bundles.test.ts +12 -6
  151. package/tests/installer-downloads.test.ts +32 -0
  152. package/tests/installer-posix-bootstrap.test.ts +8 -8
  153. package/tests/installer-preflight.test.ts +16 -0
  154. package/tests/installer-server.test.ts +2 -2
  155. package/tests/installer-windows-bootstrap.test.ts +61 -3
  156. package/tests/installer-windows-diagnostics.test.ts +111 -0
  157. package/tests/jobs-widget.test.ts +138 -0
  158. package/tests/main-channel-upgrade.test.ts +232 -2
  159. package/tests/main-channel.test.ts +140 -21
  160. package/tests/model-profiles-preservation.test.ts +2 -2
  161. package/tests/nan-provider.test.ts +36 -0
  162. package/tests/native-review-capability-contract.test.ts +11 -4
  163. package/tests/native-review-parity.test.ts +1 -1
  164. package/tests/notification-audio-native.test.ts +128 -9
  165. package/tests/notification-customize.test.ts +17 -5
  166. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  167. package/tests/orchestrator-budget.test.ts +5 -2
  168. package/tests/orchestrator-presence.test.ts +100 -2
  169. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  170. package/tests/orchestrator-scope.test.ts +14 -0
  171. package/tests/orchestrator-state.test.ts +29 -0
  172. package/tests/package-files.test.ts +34 -0
  173. package/tests/package-manifest.test.ts +6 -6
  174. package/tests/permission-mode-policy.test.ts +10 -1
  175. package/tests/pi-update.test.ts +104 -4
  176. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  177. package/tests/review-host-relay-routing.test.ts +88 -1
  178. package/tests/review-host-relay.test.ts +9 -2
  179. package/tests/review-json-arguments.test.ts +20 -3
  180. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  181. package/tests/review-relay-transport-agent.test.ts +26 -0
  182. package/tests/review-reminder-receipt.test.ts +31 -1
  183. package/tests/runtime-harness.mjs +188 -211
  184. package/tests/session-changes.test.ts +2 -1
  185. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  186. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  187. package/tests/session-profile-append-controller.test.ts +731 -0
  188. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  189. package/tests/session-profile-disk-reader.test.ts +311 -0
  190. package/tests/session-profile-freeze.test.ts +6 -4
  191. package/tests/session-scratch.test.ts +68 -2
  192. package/tests/session-worktree-registry.test.ts +13 -1
  193. package/tests/shell-bar.test.ts +18 -6
  194. package/tests/shell-changes.test.ts +148 -1
  195. package/tests/shell-prompt.test.ts +3 -3
  196. package/tests/shell-render-cache.test.ts +134 -1
  197. package/tests/shell-sidebar-layout.test.ts +67 -0
  198. package/tests/short-check-routing-contract.test.ts +1 -1
  199. package/tests/skill-collision-prefixes.test.ts +3 -1
  200. package/tests/skill-registry-watcher.test.ts +17 -5
  201. package/tests/skill-registry.test.ts +47 -3
  202. package/tests/task-size-routing-contract.test.ts +1 -1
  203. package/tests/test-strength-contract.test.ts +1 -1
  204. package/tests/verifier-admission.test.ts +18 -1
  205. package/tests/vim-editor-adapter.test.ts +2 -2
  206. package/tests/voice-command.test.ts +773 -0
  207. package/tests/voice-install.test.ts +323 -0
  208. package/tests/voice-platform.test.ts +176 -0
  209. package/tests/voice-push-to-talk.test.ts +444 -0
  210. package/tests/voice-session.test.ts +434 -0
  211. package/tests/voice-settings.test.ts +49 -0
  212. package/tests/voice-ui.test.ts +97 -0
  213. package/tests/voice-worker.test.ts +445 -0
  214. package/tests/weak-model-economy-prompt.test.ts +32 -0
  215. package/tests/windows-hidden-processes.test.ts +25 -2
  216. package/tests/yolo-customize.test.ts +5 -1
  217. package/tests/yolo-mode.test.ts +1 -1
  218. package/tests/yolo-session-policy-capture.test.ts +104 -0
@@ -0,0 +1,1096 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
3
+ import { request as httpRequest } from "node:http";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import type { TestContext } from "node:test";
7
+ import test from "node:test";
8
+ import { fileURLToPath } from "node:url";
9
+ import { designCommentBatchContent, designCommentContent, formatDesignComment, formatDesignCommentBatch, startDesignCanvasServer, type DesignCanvasServerOptions, type DesignComment } from "../lib/design-canvas-server.ts";
10
+
11
+ const TOKEN = "test-token-0123456789abcdef";
12
+ const ASSET_DIR = fileURLToPath(new URL("../assets/design-canvas/", import.meta.url));
13
+
14
+ type Reply = { status: number; headers: Record<string, string | string[] | undefined>; body: string; bytes: Buffer };
15
+ type Options = { method?: string; headers?: Record<string, string>; body?: string; host?: string | null };
16
+
17
+ function rawRequest(port: number, path: string, options: Options = {}): Promise<Reply> {
18
+ const headers: Record<string, string> = { ...(options.headers ?? {}) };
19
+ if (options.host !== null) headers.Host = options.host ?? `127.0.0.1:${port}`;
20
+ return new Promise((resolve, reject) => {
21
+ const req = httpRequest({ host: "127.0.0.1", port, path, method: options.method ?? "GET", headers, setHost: false }, (res) => {
22
+ const chunks: Buffer[] = [];
23
+ res.on("data", (chunk: Buffer) => chunks.push(chunk));
24
+ res.on("end", () => { const bytes = Buffer.concat(chunks); resolve({ status: res.statusCode ?? 0, headers: res.headers, body: bytes.toString("utf8"), bytes }); });
25
+ res.on("error", reject);
26
+ });
27
+ req.on("error", reject);
28
+ if (options.body !== undefined) req.write(options.body);
29
+ req.end();
30
+ });
31
+ }
32
+
33
+ function manifest(title: string, files: string[]) {
34
+ return JSON.stringify({
35
+ title,
36
+ brief: `${title} brief`,
37
+ artboards: files.map((file, index) => ({ file, title: `Direction ${index + 1}`, tradeoff: `Tradeoff ${index + 1}`, width: 1280, height: 800 })),
38
+ });
39
+ }
40
+
41
+ /** A `mktemp -d` style directory removed when the test ends. */
42
+ function scratch(t: TestContext, prefix: string): string {
43
+ const dir = mkdtempSync(join(tmpdir(), prefix));
44
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
45
+ return dir;
46
+ }
47
+
48
+ function fixture(t: TestContext) {
49
+ const root = scratch(t, "gentle-design-canvas-");
50
+ const outside = scratch(t, "gentle-design-outside-");
51
+ writeFileSync(join(outside, "secret.html"), "<p>secret</p>");
52
+ const alpha = join(root, "alpha");
53
+ mkdirSync(join(alpha, "sub"), { recursive: true });
54
+ writeFileSync(join(alpha, "design.json"), manifest("Alpha", ["01-hero.html", "02-bare.html"]));
55
+ writeFileSync(join(alpha, "01-hero.html"), "<!doctype html><html><body><h1>Hero</h1></body></html>");
56
+ writeFileSync(join(alpha, "02-bare.html"), "<h1>Bare</h1>");
57
+ writeFileSync(join(alpha, "styles.css"), "h1{color:red}");
58
+ writeFileSync(join(alpha, "logo.svg"), "<svg></svg>");
59
+ writeFileSync(join(alpha, "notes.txt"), "not served");
60
+ writeFileSync(join(alpha, "sub", "inner.html"), "<p>inner</p>");
61
+ symlinkSync(join(outside, "secret.html"), join(alpha, "escape.html"));
62
+ symlinkSync(outside, join(alpha, "linkdir"));
63
+ symlinkSync(join(alpha, "01-hero.html"), join(alpha, "alias.html"));
64
+ mkdirSync(join(root, "beta"));
65
+ writeFileSync(join(root, "beta", "design.json"), manifest("Beta", ["01-a.html"]));
66
+ writeFileSync(join(root, "beta", "01-a.html"), "<p>beta</p>");
67
+ mkdirSync(join(root, "broken"));
68
+ writeFileSync(join(root, "broken", "design.json"), "{ not json");
69
+ mkdirSync(join(root, "Not_A_Slug"));
70
+ writeFileSync(join(root, "Not_A_Slug", "design.json"), manifest("Ignored", ["01-a.html"]));
71
+ utimesSync(join(alpha, "design.json"), 3000, 3000);
72
+ utimesSync(join(root, "broken", "design.json"), 2000, 2000);
73
+ utimesSync(join(root, "beta", "design.json"), 1000, 1000);
74
+ return { root, outside };
75
+ }
76
+
77
+ async function start(t: TestContext, extra: { initialSlug?: string; onImplement?: DesignCanvasServerOptions["onImplement"] } = {}) {
78
+ const { root } = fixture(t);
79
+ const comments: DesignComment[] = [];
80
+ const batches: DesignComment[][] = [];
81
+ const implements_: Array<{ slug: string; file: string }> = [];
82
+ const server = await startDesignCanvasServer({
83
+ designRoot: root,
84
+ onComment: (comment) => { comments.push(comment); },
85
+ onComments: (batch) => { batches.push(batch); },
86
+ onImplement: (request) => { implements_.push(request); },
87
+ randomToken: () => TOKEN,
88
+ ...extra,
89
+ });
90
+ t.after(() => server.close());
91
+ return { server, root, comments, batches, implements: implements_, port: server.port, base: `/${TOKEN}/` };
92
+ }
93
+
94
+ test("start returns a loopback URL with a random base64url token by default", async (t) => {
95
+ const { root } = fixture(t);
96
+ const server = await startDesignCanvasServer({ designRoot: root, onComment: () => {} });
97
+ t.after(() => server.close());
98
+ assert.match(server.token, /^[A-Za-z0-9_-]{32}$/);
99
+ assert.equal(server.url, `http://127.0.0.1:${server.port}/${server.token}/`);
100
+ const withSlug = await startDesignCanvasServer({ designRoot: root, onComment: () => {}, initialSlug: "alpha" });
101
+ t.after(() => withSlug.close());
102
+ assert.notEqual(withSlug.token, server.token);
103
+ assert.equal(withSlug.url, `http://127.0.0.1:${withSlug.port}/${withSlug.token}/d/alpha/`);
104
+ await assert.rejects(startDesignCanvasServer({ designRoot: root, onComment: () => {}, initialSlug: "../x" }));
105
+ await assert.rejects(startDesignCanvasServer({ designRoot: "relative/design", onComment: () => {} }));
106
+ });
107
+
108
+ test("every route needs the token and anything else is 404", async (t) => {
109
+ const { port } = await start(t);
110
+ for (const path of ["/", "/api/designs", "/wrong-token/", `/${TOKEN.slice(0, -1)}/`, `/${TOKEN}x/api/designs`, `/${TOKEN}/unknown`]) {
111
+ assert.equal((await rawRequest(port, path)).status, 404, path);
112
+ }
113
+ assert.equal((await rawRequest(port, `/${TOKEN}/`)).status, 200);
114
+ });
115
+
116
+ test("a foreign or missing Host header is rejected", async (t) => {
117
+ const { port, base } = await start(t);
118
+ assert.equal((await rawRequest(port, base, { host: `evil.example:${port}` })).status, 421);
119
+ assert.equal((await rawRequest(port, base, { host: `127.0.0.1:${port + 1}` })).status, 421);
120
+ assert.equal((await rawRequest(port, base, { host: "127.0.0.1" })).status, 421);
121
+ assert.equal((await rawRequest(port, base, { host: null })).status, 421);
122
+ assert.equal((await rawRequest(port, base, { host: `localhost:${port}` })).status, 200);
123
+ });
124
+
125
+ test("a GET carrying a foreign Origin is rejected", async (t) => {
126
+ const { port, base } = await start(t);
127
+ assert.equal((await rawRequest(port, `${base}api/designs`, { headers: { Origin: "http://evil.example" } })).status, 403);
128
+ assert.equal((await rawRequest(port, `${base}api/designs`, { headers: { Origin: `http://127.0.0.1:${port}` } })).status, 200);
129
+ });
130
+
131
+ test("sandboxed artboards load their subresources with Origin null, but nothing else accepts it", async (t) => {
132
+ // Chrome sends `Origin: null` for script requests from an opaque-origin (sandboxed) artboard.
133
+ const { port, base } = await start(t);
134
+ const nullOrigin = { headers: { Origin: "null" } };
135
+ for (const path of ["assets/inspector.js", "files/alpha/01-hero.html", "files/alpha/styles.css", "files/alpha/logo.svg"]) {
136
+ assert.equal((await rawRequest(port, `${base}${path}`, nullOrigin)).status, 200, path);
137
+ }
138
+ for (const path of ["", "d/alpha/", "assets/canvas.js", "assets/canvas.css", "api/designs", "api/designs/alpha", "events"]) {
139
+ assert.equal((await rawRequest(port, `${base}${path}`, nullOrigin)).status, 403, path);
140
+ }
141
+ assert.equal((await rawRequest(port, `${base}assets/inspector.js`, { headers: { Origin: "http://evil.example" } })).status, 403);
142
+ assert.equal((await rawRequest(port, `${base}files/alpha/01-hero.html`, { headers: { Origin: "http://evil.example" } })).status, 403);
143
+ });
144
+
145
+ test("the canvas page carries a strict CSP and a JSON config, never inline code", async (t) => {
146
+ const { port, base } = await start(t);
147
+ const page = await rawRequest(port, base);
148
+ assert.equal(page.status, 200);
149
+ assert.match(String(page.headers["content-type"]), /^text\/html/);
150
+ assert.equal(page.headers["cache-control"], "no-store");
151
+ assert.equal(page.headers["x-content-type-options"], "nosniff");
152
+ const csp = String(page.headers["content-security-policy"]);
153
+ for (const directive of ["default-src 'none'", "script-src 'self'", "frame-ancestors 'none'", "base-uri 'none'", "form-action 'none'"]) assert.ok(csp.includes(directive), directive);
154
+ assert.ok(!csp.includes("unsafe-inline") && !csp.includes("unsafe-eval"));
155
+ const config = /<script id="gd-config" type="application\/json">([^<]*)<\/script>/.exec(page.body);
156
+ assert.ok(config, "config script present");
157
+ assert.deepEqual(JSON.parse(config[1]), { base, slug: null });
158
+ assert.ok(page.body.includes(`src="${base}assets/canvas.js"`));
159
+ const slugPage = await rawRequest(port, `${base}d/alpha/`);
160
+ assert.equal(slugPage.status, 200);
161
+ assert.deepEqual(JSON.parse(/<script id="gd-config" type="application\/json">([^<]*)<\/script>/.exec(slugPage.body)![1]), { base, slug: "alpha" });
162
+ assert.equal((await rawRequest(port, `${base}d/Bad_Slug/`)).status, 404);
163
+ assert.equal((await rawRequest(port, `${base}d/alpha/extra`)).status, 404);
164
+ assert.equal((await rawRequest(port, base, { method: "POST" })).status, 405);
165
+ });
166
+
167
+ test("only the fixed canvas assets are served", async (t) => {
168
+ const { port, base } = await start(t);
169
+ const js = await rawRequest(port, `${base}assets/canvas.js`);
170
+ assert.equal(js.status, 200);
171
+ assert.match(String(js.headers["content-type"]), /^text\/javascript/);
172
+ assert.equal(js.body, readFileSync(join(ASSET_DIR, "canvas.js"), "utf8"));
173
+ assert.match(String((await rawRequest(port, `${base}assets/canvas.css`)).headers["content-type"]), /^text\/css/);
174
+ assert.equal((await rawRequest(port, `${base}assets/inspector.js`)).status, 200);
175
+ for (const name of ["index.html", "missing.js", "..%2Fpackage.json", "%2e%2e", "canvas.js/x"]) {
176
+ assert.equal((await rawRequest(port, `${base}assets/${name}`)).status, 404, name);
177
+ }
178
+ });
179
+
180
+ test("designs are listed from design.json manifests, most recent first", async (t) => {
181
+ const { port, base } = await start(t);
182
+ const reply = await rawRequest(port, `${base}api/designs`);
183
+ assert.equal(reply.status, 200);
184
+ assert.match(String(reply.headers["content-type"]), /^application\/json/);
185
+ const designs = JSON.parse(reply.body).designs;
186
+ assert.deepEqual(designs.map((design: { slug: string }) => design.slug), ["alpha", "broken", "beta"]);
187
+ assert.deepEqual(designs[0], { slug: "alpha", title: "Alpha", mtime: 3000000, valid: true });
188
+ assert.deepEqual(designs[1], { slug: "broken", title: "broken", mtime: 2000000, valid: false });
189
+ });
190
+
191
+ test("a manifest is returned normalized, and invalid slugs or manifests are refused", async (t) => {
192
+ const { port, base, root } = await start(t);
193
+ const reply = await rawRequest(port, `${base}api/designs/alpha`);
194
+ assert.equal(reply.status, 200);
195
+ assert.deepEqual(JSON.parse(reply.body), {
196
+ slug: "alpha",
197
+ title: "Alpha",
198
+ brief: "Alpha brief",
199
+ artboards: [
200
+ { file: "01-hero.html", title: "Direction 1", tradeoff: "Tradeoff 1", width: 1280, height: 800 },
201
+ { file: "02-bare.html", title: "Direction 2", tradeoff: "Tradeoff 2", width: 1280, height: 800 },
202
+ ],
203
+ });
204
+ assert.equal((await rawRequest(port, `${base}api/designs/Alpha`)).status, 404);
205
+ assert.equal((await rawRequest(port, `${base}api/designs/missing`)).status, 404);
206
+ const broken = await rawRequest(port, `${base}api/designs/broken`);
207
+ assert.equal(broken.status, 422);
208
+ assert.match(JSON.parse(broken.body).reason, /JSON/);
209
+ mkdirSync(join(root, "gamma"));
210
+ writeFileSync(join(root, "gamma", "design.json"), JSON.stringify({ title: "G", artboards: [{ file: "../beta/01-a.html", title: "x", width: 10, height: 10 }] }));
211
+ assert.match(JSON.parse((await rawRequest(port, `${base}api/designs/gamma`)).body).reason, /file/);
212
+ writeFileSync(join(root, "gamma", "design.json"), JSON.stringify({ title: "G", artboards: [{ file: "01.html", width: -1, height: 10 }] }));
213
+ assert.equal((await rawRequest(port, `${base}api/designs/gamma`)).status, 422);
214
+ writeFileSync(join(root, "gamma", "design.json"), JSON.stringify({ title: "G", artboards: "nope" }));
215
+ assert.equal((await rawRequest(port, `${base}api/designs/gamma`)).status, 422);
216
+ });
217
+
218
+ test("artboard HTML is served sandboxed with the inspector injected once", async (t) => {
219
+ const { port, base } = await start(t);
220
+ const inspector = `<script src="${base}assets/inspector.js"></script>`;
221
+ const hero = await rawRequest(port, `${base}files/alpha/01-hero.html`);
222
+ assert.equal(hero.status, 200);
223
+ assert.match(String(hero.headers["content-type"]), /^text\/html/);
224
+ assert.equal(hero.headers["x-content-type-options"], "nosniff");
225
+ assert.equal(hero.headers["cache-control"], "no-store");
226
+ assert.match(String(hero.headers["content-security-policy"]), /^sandbox allow-scripts;/);
227
+ assert.match(String(hero.headers["content-security-policy"]), /connect-src 'none'/);
228
+ assert.equal(hero.body, `<!doctype html><html><body><h1>Hero</h1>${inspector}</body></html>`);
229
+ assert.equal((await rawRequest(port, `${base}files/alpha/02-bare.html`)).body, `<h1>Bare</h1>${inspector}`);
230
+ assert.equal((await rawRequest(port, `${base}files/alpha/sub/inner.html`)).status, 200);
231
+ assert.equal((await rawRequest(port, `${base}files/alpha/alias.html`)).status, 200, "a symlink that stays inside the design is allowed");
232
+ const css = await rawRequest(port, `${base}files/alpha/styles.css`);
233
+ assert.equal(css.body, "h1{color:red}");
234
+ assert.match(String(css.headers["content-type"]), /^text\/css/);
235
+ assert.equal((await rawRequest(port, `${base}files/alpha/logo.svg`)).headers["content-type"], "image/svg+xml");
236
+ assert.match(String((await rawRequest(port, `${base}files/alpha/design.json`)).headers["content-type"]), /^application\/json/);
237
+ });
238
+
239
+ test("traversal, symlink escape, directories and disallowed extensions are 404", async (t) => {
240
+ const { port, base } = await start(t);
241
+ const refused = [
242
+ "files/alpha/../beta/01-a.html",
243
+ "files/alpha/%2e%2e/beta/01-a.html",
244
+ "files/alpha/%2E%2E%2Fbeta%2F01-a.html",
245
+ "files/alpha/sub%2Finner.html",
246
+ "files/alpha/..%5Cbeta%5C01-a.html",
247
+ "files/../../etc/passwd",
248
+ "files/alpha/escape.html",
249
+ "files/alpha/linkdir/secret.html",
250
+ "files/alpha/notes.txt",
251
+ "files/alpha/sub",
252
+ "files/alpha/",
253
+ "files/alpha",
254
+ "files/Alpha/01-hero.html",
255
+ "files/missing/01.html",
256
+ "files/alpha/missing.html",
257
+ "files/alpha/.hidden.html",
258
+ "files/alpha/01-hero.html%00.txt",
259
+ ];
260
+ for (const path of refused) assert.equal((await rawRequest(port, `${base}${path}`)).status, 404, path);
261
+ assert.equal((await rawRequest(port, `${base}files/alpha/%E0%A4%A`)).status, 400);
262
+ });
263
+
264
+ function postComment(port: number, body: unknown, headers: Record<string, string> = {}) {
265
+ return rawRequest(port, `/${TOKEN}/api/comments`, {
266
+ method: "POST",
267
+ headers: { Origin: `http://127.0.0.1:${port}`, "Content-Type": "application/json", ...headers },
268
+ body: typeof body === "string" ? body : JSON.stringify(body),
269
+ });
270
+ }
271
+
272
+ const VALID = { slug: "alpha", file: "01-hero.html", selector: "body > h1", excerpt: "Hero", comment: "Make it bigger" };
273
+
274
+ test("comment POSTs need the exact own Origin and a JSON content type", async (t) => {
275
+ const { port, comments } = await start(t);
276
+ const noOrigin = await rawRequest(port, `/${TOKEN}/api/comments`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(VALID) });
277
+ assert.equal(noOrigin.status, 403);
278
+ assert.equal((await postComment(port, VALID, { Origin: "http://evil.example" })).status, 403);
279
+ assert.equal((await postComment(port, VALID, { Origin: "null" })).status, 403);
280
+ assert.equal((await postComment(port, VALID, { Origin: `http://localhost:${port + 1}` })).status, 403);
281
+ assert.equal((await postComment(port, VALID, { Origin: `http://localhost.evil.test:${port}` })).status, 403);
282
+ assert.equal((await postComment(port, VALID, { "Content-Type": "text/plain" })).status, 415);
283
+ assert.equal((await postComment(port, VALID, { "Content-Type": "application/x-www-form-urlencoded" })).status, 415);
284
+ assert.equal((await rawRequest(port, `/${TOKEN}/api/comments`)).status, 405);
285
+ assert.deepEqual(comments, []);
286
+ });
287
+
288
+ test("comment bodies and fields are bounded and validated", async (t) => {
289
+ const { port, comments } = await start(t);
290
+ assert.equal((await postComment(port, { ...VALID, comment: "x".repeat(17 * 1024) })).status, 413);
291
+ assert.equal((await postComment(port, "{ nope")).status, 400);
292
+ assert.equal((await postComment(port, "[]")).status, 400);
293
+ const invalid = [
294
+ { ...VALID, comment: " " },
295
+ { ...VALID, comment: "x".repeat(2001) },
296
+ { ...VALID, comment: 7 },
297
+ { ...VALID, selector: "" },
298
+ { ...VALID, selector: "a".repeat(501) },
299
+ { ...VALID, slug: "Alpha" },
300
+ { ...VALID, slug: "missing" },
301
+ { ...VALID, file: "../beta/01-a.html" },
302
+ { ...VALID, file: "missing.html" },
303
+ { ...VALID, file: "styles.css" },
304
+ { ...VALID, file: "escape.html" },
305
+ { ...VALID, excerpt: 5 },
306
+ ];
307
+ for (const body of invalid) assert.equal((await postComment(port, body)).status, 400, JSON.stringify(body).slice(0, 80));
308
+ assert.deepEqual(comments, []);
309
+ });
310
+
311
+ test("a valid comment is accepted with 202 and delivered trimmed and bounded", async (t) => {
312
+ const { port, comments } = await start(t);
313
+ const reply = await postComment(port, {
314
+ slug: "alpha",
315
+ file: "sub/inner.html",
316
+ selector: " body > main:nth-of-type(2)\n > h1 ",
317
+ excerpt: ` Hello\n\n world ${"y".repeat(400)}`,
318
+ comment: " Use the brand color\r\nand a larger title ",
319
+ }, { "Content-Type": "application/json; charset=utf-8" });
320
+ assert.equal(reply.status, 202);
321
+ assert.equal(comments.length, 1);
322
+ const { id, ...delivered } = comments[0];
323
+ assert.match(id, /^[a-z0-9]{6,12}$/);
324
+ assert.deepEqual(JSON.parse(reply.body), { ok: true, id, sent: true });
325
+ assert.deepEqual(delivered, {
326
+ slug: "alpha",
327
+ file: "sub/inner.html",
328
+ selector: "body > main:nth-of-type(2) > h1",
329
+ excerpt: `Hello world ${"y".repeat(400)}`.slice(0, 300),
330
+ comment: "Use the brand color\nand a larger title",
331
+ });
332
+ assert.equal((await postComment(port, { ...VALID, excerpt: undefined })).status, 202);
333
+ assert.equal(comments[1].excerpt, "");
334
+ });
335
+
336
+ type Stored = { id: string; file: string; selector: string; excerpt: string; comment: string; status: string; sentAt: string | null; resolvedAt: string | null };
337
+
338
+ function storedComments(root: string, slug = "alpha"): Stored[] {
339
+ return JSON.parse(readFileSync(join(root, slug, "comments.json"), "utf8")).comments;
340
+ }
341
+
342
+ test("a failing onComment yields 500, keeps the comment saved and unsent, and the server survives", async (t) => {
343
+ const { root } = fixture(t);
344
+ const server = await startDesignCanvasServer({ designRoot: root, onComment: () => { throw new Error("boom"); }, randomToken: () => TOKEN });
345
+ t.after(() => server.close());
346
+ const reply = await postComment(server.port, VALID);
347
+ assert.equal(reply.status, 500);
348
+ const [saved] = storedComments(root);
349
+ assert.deepEqual(JSON.parse(reply.body), { error: "delivery-failed", id: saved.id, saved: true, sent: false });
350
+ assert.equal(saved.sentAt, null);
351
+ assert.equal(saved.status, "open");
352
+ assert.equal((await rawRequest(server.port, `/${TOKEN}/api/designs`)).status, 200);
353
+ });
354
+
355
+ test("send mode saves the comment, delivers it with its id, then marks it sent", async (t) => {
356
+ const { port, root, comments } = await start(t);
357
+ const reply = await postComment(port, { ...VALID, mode: "send" });
358
+ assert.equal(reply.status, 202);
359
+ const [saved] = storedComments(root);
360
+ assert.deepEqual(comments, [{ ...VALID, id: saved.id }]);
361
+ assert.notEqual(saved.sentAt, null);
362
+ assert.deepEqual({ file: saved.file, selector: saved.selector, excerpt: saved.excerpt, comment: saved.comment, status: saved.status }, { file: VALID.file, selector: VALID.selector, excerpt: VALID.excerpt, comment: VALID.comment, status: "open" });
363
+ assert.equal((await postComment(port, { ...VALID, mode: "later" })).status, 400);
364
+ });
365
+
366
+ test("collect mode saves without delivering, and the comments route lists it", async (t) => {
367
+ const { port, root, comments, batches, base } = await start(t);
368
+ const reply = await postComment(port, { ...VALID, mode: "collect" });
369
+ assert.equal(reply.status, 201);
370
+ const [saved] = storedComments(root);
371
+ assert.deepEqual(JSON.parse(reply.body), { ok: true, id: saved.id, sent: false });
372
+ assert.equal(saved.sentAt, null);
373
+ assert.deepEqual(comments, []);
374
+ assert.deepEqual(batches, []);
375
+ const listed = await rawRequest(port, `${base}api/designs/alpha/comments`);
376
+ assert.equal(listed.status, 200);
377
+ assert.deepEqual(JSON.parse(listed.body), { version: 1, comments: [saved] });
378
+ assert.deepEqual(JSON.parse((await rawRequest(port, `${base}api/designs/beta/comments`)).body), { version: 1, comments: [] });
379
+ assert.equal((await rawRequest(port, `${base}api/designs/missing/comments`)).status, 404);
380
+ assert.equal((await rawRequest(port, `${base}api/designs/Alpha/comments`)).status, 404);
381
+ writeFileSync(join(root, "beta", "comments.json"), "{ broken");
382
+ const broken = await rawRequest(port, `${base}api/designs/beta/comments`);
383
+ assert.equal(broken.status, 422);
384
+ assert.equal(JSON.parse(broken.body).error, "invalid-comments");
385
+ });
386
+
387
+ function postJson(port: number, path: string, body: unknown, headers: Record<string, string> = {}) {
388
+ return rawRequest(port, `/${TOKEN}/${path}`, {
389
+ method: "POST",
390
+ headers: { Origin: `http://127.0.0.1:${port}`, "Content-Type": "application/json", ...headers },
391
+ body: JSON.stringify(body),
392
+ });
393
+ }
394
+
395
+ test("send-all delivers one batch with every open unsent comment and marks them sent", async (t) => {
396
+ const { port, root, comments, batches } = await start(t);
397
+ await postComment(port, { ...VALID, comment: "first", mode: "collect" });
398
+ await postComment(port, { ...VALID, file: "02-bare.html", comment: "second", mode: "collect" });
399
+ await postComment(port, { ...VALID, comment: "already sent", mode: "send" });
400
+ await postComment(port, { ...VALID, comment: "resolved one", mode: "collect" });
401
+ const resolvedId = storedComments(root)[3].id;
402
+ assert.equal((await postJson(port, `api/designs/alpha/comments/${resolvedId}/status`, { status: "resolved" })).status, 200);
403
+ const reply = await postJson(port, "api/designs/alpha/comments/send-all", {});
404
+ assert.equal(reply.status, 202);
405
+ assert.equal(batches.length, 1);
406
+ assert.deepEqual(batches[0].map((comment) => comment.comment), ["first", "second"]);
407
+ assert.deepEqual(batches[0].map((comment) => comment.slug), ["alpha", "alpha"]);
408
+ const stored = storedComments(root);
409
+ assert.deepEqual(JSON.parse(reply.body), { ok: true, sent: 2, ids: [stored[0].id, stored[1].id] });
410
+ assert.ok(stored.slice(0, 3).every((comment) => comment.sentAt !== null));
411
+ assert.equal(stored[3].sentAt, null, "a resolved comment is not sent");
412
+ assert.equal(comments.length, 1, "send-all never uses the single-comment callback");
413
+ const again = await postJson(port, "api/designs/alpha/comments/send-all", {});
414
+ assert.equal(again.status, 200);
415
+ assert.deepEqual(JSON.parse(again.body), { ok: true, sent: 0, ids: [] });
416
+ assert.equal(batches.length, 1, "nothing to send means no delivery");
417
+ });
418
+
419
+ test("a failing batch delivery leaves the comments unsent", async (t) => {
420
+ const { root } = fixture(t);
421
+ const server = await startDesignCanvasServer({ designRoot: root, onComment: () => {}, onComments: () => { throw new Error("boom"); }, randomToken: () => TOKEN });
422
+ t.after(() => server.close());
423
+ await postComment(server.port, { ...VALID, mode: "collect" });
424
+ const reply = await postJson(server.port, "api/designs/alpha/comments/send-all", {});
425
+ assert.equal(reply.status, 500);
426
+ assert.equal(JSON.parse(reply.body).error, "delivery-failed");
427
+ assert.equal(storedComments(root)[0].sentAt, null);
428
+ });
429
+
430
+ // Advisory 4: a canvas opened through localhost posts with its own localhost origin.
431
+ test("POSTs accept the server's own localhost origin and no look-alike", async (t) => {
432
+ const { port, root, comments } = await start(t);
433
+ const localhost = { Origin: `http://localhost:${port}`, "Content-Type": "application/json" };
434
+ const ok = await rawRequest(port, `/${TOKEN}/api/comments`, { method: "POST", headers: localhost, host: `localhost:${port}`, body: JSON.stringify(VALID) });
435
+ assert.equal(ok.status, 202);
436
+ assert.equal(comments.length, 1);
437
+ const [saved] = storedComments(root);
438
+ const status = await rawRequest(port, `/${TOKEN}/api/designs/alpha/comments/${saved.id}/status`, { method: "POST", headers: localhost, host: `localhost:${port}`, body: JSON.stringify({ status: "resolved" }) });
439
+ assert.equal(status.status, 200);
440
+ for (const origin of [`http://localhost:${port + 1}`, `http://localhost.evil.test:${port}`, "null", `http://localhost`]) {
441
+ const refused = await rawRequest(port, `/${TOKEN}/api/comments`, { method: "POST", headers: { ...localhost, Origin: origin }, host: `localhost:${port}`, body: JSON.stringify(VALID) });
442
+ assert.equal(refused.status, 403, origin);
443
+ }
444
+ assert.equal(comments.length, 1);
445
+ });
446
+
447
+ // Advisory 1: a send-now comment still in flight must not ride along in a parallel send-all.
448
+ test("a send-now comment in flight is not delivered again by a parallel send-all", async (t) => {
449
+ const { root } = fixture(t);
450
+ const singles: DesignComment[] = [];
451
+ const batches: DesignComment[][] = [];
452
+ const server = await startDesignCanvasServer({
453
+ designRoot: root,
454
+ onComment: async (comment) => { singles.push(comment); await new Promise((resolve) => setTimeout(resolve, 200)); },
455
+ onComments: (batch) => { batches.push(batch); },
456
+ randomToken: () => TOKEN,
457
+ });
458
+ t.after(() => server.close());
459
+ const sendNow = postComment(server.port, { ...VALID, mode: "send" });
460
+ // Wait until the comment is saved and its delivery has started.
461
+ while (singles.length === 0) await new Promise((resolve) => setTimeout(resolve, 5));
462
+ const sendAll = postJson(server.port, "api/designs/alpha/comments/send-all", {});
463
+ const [now, all] = await Promise.all([sendNow, sendAll]);
464
+ assert.equal(now.status, 202);
465
+ assert.equal(all.status, 200);
466
+ assert.deepEqual(JSON.parse(all.body), { ok: true, sent: 0, ids: [] });
467
+ const id = singles[0].id;
468
+ const deliveries = singles.filter((comment) => comment.id === id).length + batches.flat().filter((comment) => comment.id === id).length;
469
+ assert.equal(deliveries, 1);
470
+ assert.notEqual(storedComments(root)[0].sentAt, null);
471
+ });
472
+
473
+ // The canvas slices excerpts to 300 UTF-16 units, which can split a pair at the boundary.
474
+ test("an excerpt ending in a lone high surrogate within the limit is stored well formed", async (t) => {
475
+ const { port, root } = await start(t);
476
+ const reply = await postComment(port, { ...VALID, excerpt: `${"a".repeat(299)}\uD83D` });
477
+ assert.equal(reply.status, 202);
478
+ const [saved] = storedComments(root);
479
+ assert.equal(saved.excerpt, "a".repeat(299));
480
+ assert.ok(saved.excerpt.isWellFormed(), "no lone surrogate");
481
+ });
482
+
483
+ // Advisory 2: the server truncates the excerpt in the store's unit (UTF-16) without splitting a pair.
484
+ test("an excerpt of astral characters is truncated to the store limit without a lone surrogate", async (t) => {
485
+ const { port, root, comments } = await start(t);
486
+ const reply = await postComment(port, { ...VALID, excerpt: "😀".repeat(200) });
487
+ assert.equal(reply.status, 202);
488
+ const [saved] = storedComments(root);
489
+ assert.ok(saved.excerpt.length <= 300, `stored ${saved.excerpt.length} UTF-16 units`);
490
+ assert.equal(saved.excerpt, "😀".repeat(150));
491
+ assert.ok(saved.excerpt.isWellFormed(), "no lone surrogate");
492
+ assert.equal(comments[0].excerpt, saved.excerpt);
493
+ const odd = await postComment(port, { ...VALID, excerpt: `a${"😀".repeat(200)}` });
494
+ assert.equal(odd.status, 202);
495
+ const second = storedComments(root)[1].excerpt;
496
+ assert.equal(second, `a${"😀".repeat(149)}`, "a pair that would straddle the limit is dropped whole");
497
+ assert.ok(second.isWellFormed());
498
+ });
499
+
500
+ test("the status route resolves and reopens one comment", async (t) => {
501
+ const { port, root } = await start(t);
502
+ await postComment(port, { ...VALID, mode: "collect" });
503
+ const [saved] = storedComments(root);
504
+ const resolved = await postJson(port, `api/designs/alpha/comments/${saved.id}/status`, { status: "resolved" });
505
+ assert.equal(resolved.status, 200);
506
+ assert.equal(JSON.parse(resolved.body).comment.status, "resolved");
507
+ assert.equal(storedComments(root)[0].status, "resolved");
508
+ assert.notEqual(storedComments(root)[0].resolvedAt, null);
509
+ assert.equal((await postJson(port, `api/designs/alpha/comments/${saved.id}/status`, { status: "open" })).status, 200);
510
+ assert.equal(storedComments(root)[0].status, "open");
511
+ assert.equal((await postJson(port, `api/designs/alpha/comments/${saved.id}/status`, { status: "done" })).status, 400);
512
+ assert.equal((await postJson(port, "api/designs/alpha/comments/zzzzzz/status", { status: "resolved" })).status, 404);
513
+ assert.equal((await postJson(port, "api/designs/alpha/comments/bad%2Fid/status", { status: "resolved" })).status, 404);
514
+ });
515
+
516
+ test("the new comment routes keep the Host, token, Origin, content-type and method rules", async (t) => {
517
+ const { port, root, batches } = await start(t);
518
+ await postComment(port, { ...VALID, mode: "collect" });
519
+ const [saved] = storedComments(root);
520
+ const posts = ["api/designs/alpha/comments/send-all", `api/designs/alpha/comments/${saved.id}/status`];
521
+ const body = JSON.stringify({ status: "resolved" });
522
+ for (const path of posts) {
523
+ const own = { Origin: `http://127.0.0.1:${port}`, "Content-Type": "application/json" };
524
+ assert.equal((await rawRequest(port, `/${TOKEN}/${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body })).status, 403, `${path} without Origin`);
525
+ for (const origin of ["http://evil.example", "null", `http://localhost:${port + 1}`, `http://localhost.evil.test:${port}`, `https://127.0.0.1:${port}`]) {
526
+ assert.equal((await rawRequest(port, `/${TOKEN}/${path}`, { method: "POST", headers: { ...own, Origin: origin }, body })).status, 403, `${path} ${origin}`);
527
+ }
528
+ assert.equal((await rawRequest(port, `/${TOKEN}/${path}`, { method: "POST", headers: { ...own, "Content-Type": "text/plain" }, body })).status, 415, path);
529
+ assert.equal((await rawRequest(port, `/wrong-token-0123456789/${path}`, { method: "POST", headers: own, body })).status, 404, path);
530
+ assert.equal((await rawRequest(port, `/${TOKEN}/${path}`, { method: "POST", headers: own, body, host: `evil.example:${port}` })).status, 421, path);
531
+ assert.equal((await rawRequest(port, `/${TOKEN}/${path}`, { method: "POST", headers: own, body: JSON.stringify({ pad: "x".repeat(17 * 1024) }) })).status, 413, path);
532
+ assert.equal((await rawRequest(port, `/${TOKEN}/${path}`)).status, 405, `${path} GET`);
533
+ }
534
+ const list = `/${TOKEN}/api/designs/alpha/comments`;
535
+ assert.equal((await rawRequest(port, list, { headers: { Origin: "http://evil.example" } })).status, 403);
536
+ assert.equal((await rawRequest(port, list, { headers: { Origin: "null" } })).status, 403);
537
+ assert.equal((await rawRequest(port, list, { host: `evil.example:${port}` })).status, 421);
538
+ assert.equal((await rawRequest(port, `/wrong-token-0123456789/api/designs/alpha/comments`)).status, 404);
539
+ assert.equal((await rawRequest(port, list, { method: "POST", headers: { Origin: `http://127.0.0.1:${port}`, "Content-Type": "application/json" }, body: "{}" })).status, 405);
540
+ assert.equal(storedComments(root)[0].status, "open");
541
+ assert.equal(storedComments(root)[0].sentAt, null);
542
+ assert.deepEqual(batches, []);
543
+ });
544
+
545
+ function openEvents(port: number) {
546
+ let buffer = "";
547
+ const waiters: Array<{ pattern: RegExp; resolve: (match: RegExpExecArray) => void }> = [];
548
+ const req = httpRequest({ host: "127.0.0.1", port, path: `/${TOKEN}/events`, headers: { Host: `127.0.0.1:${port}` }, setHost: false });
549
+ const ready = new Promise<Record<string, unknown>>((resolve, reject) => {
550
+ req.on("response", (res) => {
551
+ res.setEncoding("utf8");
552
+ res.on("data", (chunk: string) => {
553
+ buffer += chunk;
554
+ for (const waiter of [...waiters]) {
555
+ const match = waiter.pattern.exec(buffer);
556
+ if (match) { waiters.splice(waiters.indexOf(waiter), 1); waiter.resolve(match); }
557
+ }
558
+ });
559
+ res.on("error", () => {});
560
+ resolve(res.headers);
561
+ });
562
+ req.on("error", reject);
563
+ });
564
+ req.end();
565
+ const waitFor = (pattern: RegExp) => new Promise<RegExpExecArray>((resolve) => {
566
+ const match = pattern.exec(buffer);
567
+ if (match) resolve(match); else waiters.push({ pattern, resolve });
568
+ });
569
+ return { ready, waitFor, req };
570
+ }
571
+
572
+ test("events stream a debounced change after an artboard write", async (t) => {
573
+ const { port, root } = await start(t);
574
+ const events = openEvents(port);
575
+ t.after(() => events.req.destroy());
576
+ const headers = await events.ready;
577
+ assert.match(String(headers["content-type"]), /^text\/event-stream/);
578
+ assert.equal(headers["cache-control"], "no-store");
579
+ await events.waitFor(/: connected\n\n/);
580
+ await new Promise((resolve) => setTimeout(resolve, 100));
581
+ writeFileSync(join(root, "alpha", "01-hero.html"), "<p>changed</p>");
582
+ const match = await events.waitFor(/event: change\ndata: (\{"slug":"alpha","file":"01-hero\.html"\})\n\n/);
583
+ assert.deepEqual(JSON.parse(match[1]), { slug: "alpha", file: "01-hero.html" });
584
+ });
585
+
586
+ async function until(condition: () => boolean, timeoutMs = 2000): Promise<void> {
587
+ const deadline = Date.now() + timeoutMs;
588
+ while (!condition()) {
589
+ if (Date.now() > deadline) throw new Error("condition not reached in time");
590
+ await new Promise((resolve) => setTimeout(resolve, 10));
591
+ }
592
+ }
593
+
594
+ test("connectedClients counts the open event streams", async (t) => {
595
+ const { server, port } = await start(t);
596
+ assert.equal(server.connectedClients(), 0);
597
+ const first = openEvents(port);
598
+ t.after(() => first.req.destroy());
599
+ await first.ready;
600
+ await first.waitFor(/: connected\n\n/);
601
+ assert.equal(server.connectedClients(), 1);
602
+ const second = openEvents(port);
603
+ t.after(() => second.req.destroy());
604
+ await second.ready;
605
+ await second.waitFor(/: connected\n\n/);
606
+ assert.equal(server.connectedClients(), 2);
607
+ first.req.destroy();
608
+ second.req.destroy();
609
+ await until(() => server.connectedClients() === 0);
610
+ });
611
+
612
+ test("show() tells every connected page which design to show, or the system-only view for null", async (t) => {
613
+ const { server, port } = await start(t);
614
+ assert.equal(server.show("alpha"), 0, "no page is connected yet");
615
+ const events = openEvents(port);
616
+ t.after(() => events.req.destroy());
617
+ await events.ready;
618
+ await events.waitFor(/: connected\n\n/);
619
+ assert.equal(server.show("beta"), 1);
620
+ const match = await events.waitFor(/event: show\ndata: (\{[^\n]*\})\n\n/);
621
+ assert.deepEqual(JSON.parse(match[1]), { slug: "beta" });
622
+ assert.equal(server.show(null), 1);
623
+ await events.waitFor(/event: show\ndata: \{"slug":null\}\n\n/);
624
+ for (const slug of ["../x", "Beta", "", "a b"]) assert.throws(() => server.show(slug), /kebab-case/, slug);
625
+ });
626
+
627
+ test("events stream a change when comments.json is written", async (t) => {
628
+ const { port } = await start(t);
629
+ const events = openEvents(port);
630
+ t.after(() => events.req.destroy());
631
+ await events.ready;
632
+ await events.waitFor(/: connected\n\n/);
633
+ await new Promise((resolve) => setTimeout(resolve, 100));
634
+ assert.equal((await postComment(port, { ...VALID, mode: "collect" })).status, 201);
635
+ await events.waitFor(/event: change\ndata: \{"slug":"alpha","file":"comments\.json"\}\n\n/);
636
+ });
637
+
638
+ const SYSTEM_HTML = "<!doctype html><html><body><h1>Tokens</h1></body></html>";
639
+
640
+ test("GET api/system reports whether design/system.html exists, at 1440 wide", async (t) => {
641
+ const { port, base, root } = await start(t);
642
+ const missing = await rawRequest(port, `${base}api/system`);
643
+ assert.equal(missing.status, 200);
644
+ const absent = JSON.parse(missing.body);
645
+ assert.equal(absent.exists, false);
646
+ assert.equal(absent.file, "system.html");
647
+ assert.equal(absent.width, 1440);
648
+ assert.ok(Number.isInteger(absent.height) && absent.height >= 600, "a sensible default height");
649
+ assert.equal((await rawRequest(port, `${base}system.html`)).status, 404);
650
+ writeFileSync(join(root, "system.html"), SYSTEM_HTML);
651
+ const present = JSON.parse((await rawRequest(port, `${base}api/system`)).body);
652
+ assert.deepEqual(present, { ...absent, exists: true });
653
+ });
654
+
655
+ test("the system board is served sandboxed with the inspector injected", async (t) => {
656
+ const { port, base, root } = await start(t);
657
+ writeFileSync(join(root, "system.html"), SYSTEM_HTML);
658
+ const board = await rawRequest(port, `${base}system.html?v=1`);
659
+ assert.equal(board.status, 200);
660
+ assert.match(String(board.headers["content-type"]), /^text\/html/);
661
+ assert.match(String(board.headers["content-security-policy"]), /^sandbox allow-scripts;/);
662
+ assert.match(String(board.headers["content-security-policy"]), /connect-src 'none'/);
663
+ assert.equal(board.headers["cross-origin-resource-policy"], undefined);
664
+ assert.equal(board.body, `<!doctype html><html><body><h1>Tokens</h1><script src="${base}assets/inspector.js"></script></body></html>`);
665
+ // Only that one file: system.md and other root names are not routes.
666
+ writeFileSync(join(root, "system.md"), "# Tokens");
667
+ for (const path of ["system.md", "system.htm", "system.html/x", "api/system/x", "files/system.html"]) {
668
+ assert.equal((await rawRequest(port, `${base}${path}`)).status, 404, path);
669
+ }
670
+ });
671
+
672
+ test("a symlinked system.html escaping the design root is 404, and a directory is not a board", async (t) => {
673
+ const { port, base, root } = await start(t);
674
+ const outside = scratch(t, "gentle-design-system-outside-");
675
+ writeFileSync(join(outside, "system.html"), "<p>secret</p>");
676
+ symlinkSync(join(outside, "system.html"), join(root, "system.html"));
677
+ assert.equal((await rawRequest(port, `${base}system.html`)).status, 404);
678
+ assert.equal(JSON.parse((await rawRequest(port, `${base}api/system`)).body).exists, false);
679
+ const dirRoot = scratch(t, "gentle-design-system-dir-");
680
+ mkdirSync(join(dirRoot, "system.html"));
681
+ const other = await startDesignCanvasServer({ designRoot: dirRoot, onComment: () => {}, randomToken: () => TOKEN });
682
+ t.after(() => other.close());
683
+ assert.equal((await rawRequest(other.port, `/${TOKEN}/system.html`)).status, 404);
684
+ assert.equal(JSON.parse((await rawRequest(other.port, `/${TOKEN}/api/system`)).body).exists, false);
685
+ });
686
+
687
+ test("the system routes keep the Host, token, Origin and method rules", async (t) => {
688
+ const { port, base, root } = await start(t);
689
+ writeFileSync(join(root, "system.html"), SYSTEM_HTML);
690
+ for (const path of ["system.html", "api/system"]) {
691
+ assert.equal((await rawRequest(port, `${base}${path}`, { host: `evil.example:${port}` })).status, 421, path);
692
+ assert.equal((await rawRequest(port, `/wrong-token-0123456789/${path}`)).status, 404, path);
693
+ assert.equal((await rawRequest(port, `${base}${path}`, { headers: { Origin: "http://evil.example" } })).status, 403, path);
694
+ assert.equal((await rawRequest(port, `${base}${path}`, { headers: { Origin: `http://localhost:${port}` } })).status, 200, path);
695
+ assert.equal((await rawRequest(port, `${base}${path}`, { method: "POST", headers: { Origin: `http://127.0.0.1:${port}`, "Content-Type": "application/json" }, body: "{}" })).status, 405, path);
696
+ }
697
+ // Like an artboard, the sandboxed system board may load with Origin null; its metadata may not.
698
+ assert.equal((await rawRequest(port, `${base}system.html`, { headers: { Origin: "null" } })).status, 200);
699
+ assert.equal((await rawRequest(port, `${base}api/system`, { headers: { Origin: "null" } })).status, 403);
700
+ });
701
+
702
+ test("events stream a change when system.html or system.md is written", async (t) => {
703
+ const { port, root } = await start(t);
704
+ const events = openEvents(port);
705
+ t.after(() => events.req.destroy());
706
+ await events.ready;
707
+ await events.waitFor(/: connected\n\n/);
708
+ await new Promise((resolve) => setTimeout(resolve, 100));
709
+ writeFileSync(join(root, "system.html"), SYSTEM_HTML);
710
+ await events.waitFor(/event: change\ndata: \{"slug":null,"file":"system\.html"\}\n\n/);
711
+ writeFileSync(join(root, "system.md"), "# Tokens");
712
+ await events.waitFor(/event: change\ndata: \{"slug":null,"file":"system\.md"\}\n\n/);
713
+ });
714
+
715
+ test("implement validates the artboard against the manifest, calls onImplement once and answers 202", async (t) => {
716
+ const { port, implements: calls } = await start(t);
717
+ const ok = await postJson(port, "api/designs/alpha/implement", { file: "01-hero.html" });
718
+ assert.equal(ok.status, 202);
719
+ assert.deepEqual(JSON.parse(ok.body), { ok: true, slug: "alpha", file: "01-hero.html", queued: false });
720
+ assert.deepEqual(calls, [{ slug: "alpha", file: "01-hero.html" }]);
721
+ // sub/inner.html exists but is not an artboard of the manifest.
722
+ for (const file of ["sub/inner.html", "missing.html", "../beta/01-a.html", "styles.css", 7, ""]) {
723
+ assert.equal((await postJson(port, "api/designs/alpha/implement", { file })).status, 400, String(file));
724
+ }
725
+ assert.equal((await postJson(port, "api/designs/missing/implement", { file: "01-hero.html" })).status, 404);
726
+ assert.equal((await postJson(port, "api/designs/broken/implement", { file: "01-a.html" })).status, 422);
727
+ assert.equal((await postJson(port, "api/designs/Alpha/implement", { file: "01-hero.html" })).status, 404);
728
+ assert.equal(calls.length, 1, "no refused request reaches onImplement");
729
+ });
730
+
731
+ test("the implement route keeps the Host, token, Origin, content-type and method rules", async (t) => {
732
+ const { port, implements: calls } = await start(t);
733
+ const path = `/${TOKEN}/api/designs/alpha/implement`;
734
+ const body = JSON.stringify({ file: "01-hero.html" });
735
+ const own = { Origin: `http://127.0.0.1:${port}`, "Content-Type": "application/json" };
736
+ assert.equal((await rawRequest(port, path, { method: "POST", headers: { "Content-Type": "application/json" }, body })).status, 403, "no Origin");
737
+ for (const origin of ["http://evil.example", "null", `http://localhost:${port + 1}`]) {
738
+ assert.equal((await rawRequest(port, path, { method: "POST", headers: { ...own, Origin: origin }, body })).status, 403, origin);
739
+ }
740
+ assert.equal((await rawRequest(port, path, { method: "POST", headers: { ...own, "Content-Type": "text/plain" }, body })).status, 415);
741
+ assert.equal((await rawRequest(port, `/wrong-token-0123456789/api/designs/alpha/implement`, { method: "POST", headers: own, body })).status, 404);
742
+ assert.equal((await rawRequest(port, path, { method: "POST", headers: own, body, host: `evil.example:${port}` })).status, 421);
743
+ assert.equal((await rawRequest(port, path, { method: "POST", headers: own, body: JSON.stringify({ pad: "x".repeat(17 * 1024) }) })).status, 413);
744
+ assert.equal((await rawRequest(port, path)).status, 405);
745
+ assert.equal((await rawRequest(port, path, { method: "POST", headers: { ...own, Origin: `http://localhost:${port}` }, body })).status, 202);
746
+ assert.equal(calls.length, 1);
747
+ });
748
+
749
+ test("implement is 501 without onImplement and 500 when it throws", async (t) => {
750
+ const { root } = fixture(t);
751
+ const bare = await startDesignCanvasServer({ designRoot: root, onComment: () => {}, randomToken: () => TOKEN });
752
+ t.after(() => bare.close());
753
+ assert.equal((await postJson(bare.port, "api/designs/alpha/implement", { file: "01-hero.html" })).status, 501);
754
+ const failing = await startDesignCanvasServer({ designRoot: root, onComment: () => {}, onImplement: () => { throw new Error("boom"); }, randomToken: () => TOKEN });
755
+ t.after(() => failing.close());
756
+ const reply = await postJson(failing.port, "api/designs/alpha/implement", { file: "01-hero.html" });
757
+ assert.equal(reply.status, 500);
758
+ assert.deepEqual(JSON.parse(reply.body), { error: "delivery-failed", slug: "alpha", file: "01-hero.html", reason: "boom" });
759
+ assert.equal((await rawRequest(failing.port, `/${TOKEN}/api/designs`)).status, 200, "the server survives");
760
+ });
761
+
762
+ // S15: the canvas Implement route records the handoff in design/<slug>/handoffs.json.
763
+
764
+ const handoffsOf = (root: string, slug = "alpha") => JSON.parse(readFileSync(join(root, slug, "handoffs.json"), "utf8"));
765
+
766
+ test("implement records the handoff as sent and chosen before delivery, with queued from onImplement", async (t) => {
767
+ const seen: unknown[] = [];
768
+ const state = { busy: true, root: "" };
769
+ const started = await start(t, {
770
+ onImplement: () => {
771
+ seen.push(handoffsOf(state.root));
772
+ return { queued: state.busy };
773
+ },
774
+ });
775
+ state.root = started.root;
776
+ const queued = await postJson(started.port, "api/designs/alpha/implement", { file: "01-hero.html" });
777
+ assert.equal(queued.status, 202);
778
+ assert.deepEqual(JSON.parse(queued.body), { ok: true, slug: "alpha", file: "01-hero.html", queued: true });
779
+ assert.equal(seen.length, 1);
780
+ const before = seen[0] as { chosen: string; handoffs: Record<string, { status: string }> };
781
+ assert.equal(before.chosen, "01-hero.html", "recorded before delivery");
782
+ assert.equal(before.handoffs["01-hero.html"].status, "sent");
783
+ const afterQueued = handoffsOf(state.root);
784
+ assert.equal(afterQueued.chosen, "01-hero.html");
785
+ assert.deepEqual({ status: afterQueued.handoffs["01-hero.html"].status, queued: afterQueued.handoffs["01-hero.html"].queued }, { status: "sent", queued: true });
786
+ assert.match(afterQueued.handoffs["01-hero.html"].sentAt, /^\d{4}-\d\d-\d\dT/);
787
+
788
+ state.busy = false;
789
+ const immediate = await postJson(started.port, "api/designs/alpha/implement", { file: "02-bare.html" });
790
+ assert.deepEqual(JSON.parse(immediate.body), { ok: true, slug: "alpha", file: "02-bare.html", queued: false });
791
+ const moved = handoffsOf(state.root);
792
+ assert.equal(moved.chosen, "02-bare.html", "handing off another artboard moves chosen");
793
+ assert.equal(moved.handoffs["02-bare.html"].queued, false);
794
+ assert.equal(moved.handoffs["01-hero.html"].queued, true, "the earlier handoff keeps its state");
795
+ });
796
+
797
+ test("implement links the handoff to the design's feature document", async (t) => {
798
+ const { port, root } = await start(t);
799
+ writeFileSync(join(root, "alpha", "design.json"), JSON.stringify({ title: "Alpha", feature: "odd/tasks/login.md", artboards: [{ file: "01-hero.html", title: "H", width: 10, height: 10 }] }));
800
+ assert.equal((await postJson(port, "api/designs/alpha/implement", { file: "01-hero.html" })).status, 202);
801
+ assert.equal(handoffsOf(root).handoffs["01-hero.html"].feature, "odd/tasks/login.md");
802
+ });
803
+
804
+ test("a failed delivery takes the handoff back, and an invalid handoffs.json refuses the handoff untouched", async (t) => {
805
+ const { root } = fixture(t);
806
+ const failing = await startDesignCanvasServer({ designRoot: root, onComment: () => {}, onImplement: () => { throw new Error("boom"); }, randomToken: () => TOKEN });
807
+ t.after(() => failing.close());
808
+ assert.equal((await postJson(failing.port, "api/designs/alpha/implement", { file: "01-hero.html" })).status, 500);
809
+ assert.deepEqual(handoffsOf(root), { version: 1, chosen: null, handoffs: {} }, "no handoff is left behind");
810
+
811
+ const calls: unknown[] = [];
812
+ const server = await startDesignCanvasServer({ designRoot: root, onComment: () => {}, onImplement: (request) => { calls.push(request); }, randomToken: () => "other-token-0123456789abcdef" });
813
+ t.after(() => server.close());
814
+ const path = join(root, "alpha", "handoffs.json");
815
+ writeFileSync(path, "{ broken");
816
+ const reply = await rawRequest(server.port, "/other-token-0123456789abcdef/api/designs/alpha/implement", { method: "POST", headers: { Origin: `http://127.0.0.1:${server.port}`, "Content-Type": "application/json" }, body: JSON.stringify({ file: "01-hero.html" }) });
817
+ assert.equal(reply.status, 422);
818
+ assert.equal(JSON.parse(reply.body).error, "invalid-handoffs");
819
+ assert.equal(readFileSync(path, "utf8"), "{ broken", "never overwritten");
820
+ assert.deepEqual(calls, [], "nothing is delivered");
821
+ assert.equal((await rawRequest(server.port, "/other-token-0123456789abcdef/api/designs/alpha/handoffs")).status, 422);
822
+ });
823
+
824
+ test("GET handoffs returns the design's handoffs.json, or an empty document", async (t) => {
825
+ const { port, base, root } = await start(t);
826
+ assert.deepEqual(JSON.parse((await rawRequest(port, `${base}api/designs/alpha/handoffs`)).body), { version: 1, chosen: null, handoffs: {} });
827
+ await postJson(port, "api/designs/alpha/implement", { file: "01-hero.html" });
828
+ const reply = await rawRequest(port, `${base}api/designs/alpha/handoffs`);
829
+ assert.equal(reply.status, 200);
830
+ assert.match(String(reply.headers["content-type"]), /^application\/json/);
831
+ assert.deepEqual(JSON.parse(reply.body), handoffsOf(root));
832
+ assert.equal((await rawRequest(port, `${base}api/designs/missing/handoffs`)).status, 404);
833
+ assert.equal((await rawRequest(port, `${base}api/designs/Bad_Slug/handoffs`)).status, 404);
834
+ assert.equal((await rawRequest(port, `${base}api/designs/alpha/handoffs/extra`)).status, 404);
835
+ });
836
+
837
+ test("the handoffs route keeps the Host, token, Origin and method rules", async (t) => {
838
+ const { port } = await start(t);
839
+ const path = `/${TOKEN}/api/designs/alpha/handoffs`;
840
+ assert.equal((await rawRequest(port, path)).status, 200);
841
+ assert.equal((await rawRequest(port, path, { headers: { Origin: `http://localhost:${port}` } })).status, 200);
842
+ for (const origin of ["http://evil.example", "null", `http://localhost:${port + 1}`, `http://localhost.evil.test:${port}`]) {
843
+ assert.equal((await rawRequest(port, path, { headers: { Origin: origin } })).status, 403, origin);
844
+ }
845
+ assert.equal((await rawRequest(port, path, { host: `evil.example:${port}` })).status, 421);
846
+ assert.equal((await rawRequest(port, `/wrong-token-0123456789/api/designs/alpha/handoffs`)).status, 404);
847
+ assert.equal((await rawRequest(port, path, { method: "POST", headers: { Origin: `http://127.0.0.1:${port}`, "Content-Type": "application/json" }, body: "{}" })).status, 405);
848
+ });
849
+
850
+ test("a refused implement request records no handoff", async (t) => {
851
+ const { port, root } = await start(t);
852
+ const path = `/${TOKEN}/api/designs/alpha/implement`;
853
+ const body = JSON.stringify({ file: "01-hero.html" });
854
+ const own = { Origin: `http://127.0.0.1:${port}`, "Content-Type": "application/json" };
855
+ await rawRequest(port, path, { method: "POST", headers: { ...own, Origin: "null" }, body });
856
+ await rawRequest(port, path, { method: "POST", headers: { ...own, Origin: "http://evil.example" }, body });
857
+ await rawRequest(port, path, { method: "POST", headers: own, body, host: `evil.example:${port}` });
858
+ await rawRequest(port, `/wrong-token-0123456789/api/designs/alpha/implement`, { method: "POST", headers: own, body });
859
+ await postJson(port, "api/designs/alpha/implement", { file: "sub/inner.html" });
860
+ assert.throws(() => readFileSync(join(root, "alpha", "handoffs.json")), /ENOENT/);
861
+ });
862
+
863
+ test("events stream a change when handoffs.json is written", async (t) => {
864
+ const { port } = await start(t);
865
+ const events = openEvents(port);
866
+ t.after(() => events.req.destroy());
867
+ await events.ready;
868
+ await events.waitFor(/: connected\n\n/);
869
+ await new Promise((resolve) => setTimeout(resolve, 100));
870
+ assert.equal((await postJson(port, "api/designs/alpha/implement", { file: "01-hero.html" })).status, 202);
871
+ await events.waitFor(/event: change\ndata: \{"slug":"alpha","file":"handoffs\.json"\}\n\n/);
872
+ });
873
+
874
+ test("the canvas CSP frames only loopback previews, and the artboard CSP is unchanged", async (t) => {
875
+ const { port, base } = await start(t);
876
+ assert.equal(String((await rawRequest(port, base)).headers["content-security-policy"]), [
877
+ "default-src 'none'",
878
+ "script-src 'self'",
879
+ "style-src 'self'",
880
+ "img-src 'self' data:",
881
+ "connect-src 'self'",
882
+ "frame-src 'self' http://localhost:* http://127.0.0.1:* http://[::1]:* https://localhost:* https://127.0.0.1:* https://[::1]:*",
883
+ "base-uri 'none'",
884
+ "form-action 'none'",
885
+ "frame-ancestors 'none'",
886
+ ].join("; "));
887
+ assert.equal(String((await rawRequest(port, `${base}files/alpha/01-hero.html`)).headers["content-security-policy"]), [
888
+ "sandbox allow-scripts",
889
+ `default-src 'self' http://127.0.0.1:${port} 'unsafe-inline' data: blob:`,
890
+ "connect-src 'none'",
891
+ "base-uri 'none'",
892
+ "form-action 'none'",
893
+ `frame-ancestors http://127.0.0.1:${port} http://localhost:${port}`,
894
+ ].join("; "));
895
+ });
896
+
897
+ test("a valid manifest feature link is kept; an invalid one is dropped and reported", async (t) => {
898
+ const { port, base, root } = await start(t);
899
+ const withFeature = (feature: unknown) => JSON.stringify({ title: "Alpha", artboards: [{ file: "01-hero.html", title: "H", width: 10, height: 10 }], feature });
900
+ writeFileSync(join(root, "alpha", "design.json"), withFeature("odd/tasks/pricing-page.md"));
901
+ const kept = JSON.parse((await rawRequest(port, `${base}api/designs/alpha`)).body);
902
+ assert.equal(kept.feature, "odd/tasks/pricing-page.md");
903
+ assert.equal(kept.warnings, undefined);
904
+ for (const feature of ["../x.md", "/abs.md", "odd/tasks/x.txt", "odd/other/x.md", "odd/tasks/../x.md", "odd/tasks/.x.md", "odd/tasks/a/b.md", " odd/tasks/x.md", 7]) {
905
+ writeFileSync(join(root, "alpha", "design.json"), withFeature(feature));
906
+ const reply = await rawRequest(port, `${base}api/designs/alpha`);
907
+ assert.equal(reply.status, 200, String(feature));
908
+ const manifest = JSON.parse(reply.body);
909
+ assert.equal("feature" in manifest, false, String(feature));
910
+ assert.equal(manifest.warnings.length, 1, String(feature));
911
+ assert.match(manifest.warnings[0], /feature/);
912
+ }
913
+ });
914
+
915
+ test("close() is idempotent, ends event streams and frees the port", async (t) => {
916
+ const { root } = fixture(t);
917
+ const server = await startDesignCanvasServer({ designRoot: root, onComment: () => {}, randomToken: () => TOKEN });
918
+ const events = openEvents(server.port);
919
+ await events.ready;
920
+ await events.waitFor(/: connected\n\n/);
921
+ await Promise.all([server.close(), server.close()]);
922
+ await server.close();
923
+ await assert.rejects(rawRequest(server.port, `/${TOKEN}/`), /ECONNREFUSED/);
924
+ });
925
+
926
+ test("a missing design root lists nothing and still serves the canvas", async (t) => {
927
+ const parent = scratch(t, "gentle-design-empty-");
928
+ const server = await startDesignCanvasServer({ designRoot: join(parent, "design"), onComment: () => {}, randomToken: () => TOKEN });
929
+ t.after(() => server.close());
930
+ assert.deepEqual(JSON.parse((await rawRequest(server.port, `/${TOKEN}/api/designs`)).body), { designs: [] });
931
+ assert.equal((await rawRequest(server.port, `/${TOKEN}/`)).status, 200);
932
+ assert.equal((await rawRequest(server.port, `/${TOKEN}/files/alpha/01.html`)).status, 404);
933
+ });
934
+
935
+ test("formatDesignComment builds the exact session message with the comment id", () => {
936
+ assert.equal(formatDesignComment({ id: "k3x9p2", slug: "landing", file: "02-bold.html", selector: "body > header > h1", excerpt: "Ship faster", comment: "Make the headline shorter." }), [
937
+ "Design comment for design/landing/02-bold.html",
938
+ "",
939
+ "Design: landing",
940
+ "Artboard: design/landing/02-bold.html",
941
+ "Comment id: k3x9p2",
942
+ "Element: body > header > h1",
943
+ "Excerpt: \"Ship faster\"",
944
+ "",
945
+ "Comment:",
946
+ "Make the headline shorter.",
947
+ "",
948
+ "Edit only the artboard design/landing/02-bold.html, following the gentle-design skill.",
949
+ "When it is applied, mark it resolved with the gentle_design_comments tool (action \"resolve\", slug \"landing\", ids [\"k3x9p2\"]), or set its status to \"resolved\" in design/landing/comments.json.",
950
+ ].join("\n"));
951
+ assert.match(formatDesignComment({ ...VALID, id: "a1", excerpt: "" }), /\nExcerpt: \(none\)\n/);
952
+ });
953
+
954
+ test("formatDesignCommentBatch builds one exact message listing every comment", () => {
955
+ const batch: DesignComment[] = [
956
+ { id: "aaa111", slug: "landing", file: "01-calm.html", selector: "#cta", excerpt: "Start", comment: "Make it green." },
957
+ { id: "bbb222", slug: "landing", file: "02-bold.html", selector: "body > h1", excerpt: "", comment: "Shorter.\nAnd bolder." },
958
+ ];
959
+ assert.equal(formatDesignCommentBatch(batch), [
960
+ "Design comments for design/landing (2)",
961
+ "",
962
+ "1. Comment id: aaa111",
963
+ " Artboard: design/landing/01-calm.html",
964
+ " Element: #cta",
965
+ " Excerpt: \"Start\"",
966
+ " Comment:",
967
+ " Make it green.",
968
+ "",
969
+ "2. Comment id: bbb222",
970
+ " Artboard: design/landing/02-bold.html",
971
+ " Element: body > h1",
972
+ " Excerpt: (none)",
973
+ " Comment:",
974
+ " Shorter.",
975
+ " And bolder.",
976
+ "",
977
+ "Apply each comment following the gentle-design skill, editing only the artboard it names.",
978
+ "When a comment is applied, mark it resolved with the gentle_design_comments tool (action \"resolve\", slug \"landing\", ids [\"aaa111\", \"bbb222\"]), or set its status to \"resolved\" in design/landing/comments.json.",
979
+ ].join("\n"));
980
+ });
981
+
982
+ test("canvas assets sandbox artboards without allow-same-origin and reference no remote URL", () => {
983
+ const canvas = readFileSync(join(ASSET_DIR, "canvas.js"), "utf8");
984
+ assert.match(canvas, /setAttribute\("sandbox", "allow-scripts"\)/);
985
+ // The one allow-same-origin is the Compare preview of the running app, another loopback origin (S15).
986
+ assert.deepEqual(canvas.match(/"[^"\n]*allow-same-origin[^"\n]*"/g), ['"allow-scripts allow-same-origin allow-forms allow-popups"']);
987
+ for (const name of ["index.html", "canvas.css", "canvas.js", "inspector.js"]) {
988
+ const source = readFileSync(join(ASSET_DIR, name), "utf8");
989
+ if (name !== "canvas.js") assert.ok(!source.includes("allow-same-origin"), `${name} must not grant allow-same-origin`);
990
+ assert.ok(!/https?:\/\//i.test(source), `${name} must not reference remote URLs`);
991
+ }
992
+ const inspector = readFileSync(join(ASSET_DIR, "inspector.js"), "utf8");
993
+ assert.match(inspector, /gd:pick/);
994
+ assert.match(canvas, /event\.source/);
995
+ });
996
+
997
+ const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(64 * 1024, 7)]);
998
+ const SHOT = { mimeType: "image/png", data: PNG.toString("base64") };
999
+
1000
+ test("a comment with a screenshot is stored, delivered with its image, and served by the screenshot route", async (t) => {
1001
+ const { port, root, comments, base } = await start(t);
1002
+ const reply = await postComment(port, { ...VALID, screenshot: SHOT });
1003
+ assert.equal(reply.status, 202, reply.body);
1004
+ const [saved] = storedComments(root);
1005
+ const path = `design/alpha/.screenshots/${saved.id}.png`;
1006
+ assert.deepEqual(comments, [{ ...VALID, id: saved.id, screenshot: { path, mimeType: "image/png", data: SHOT.data } }]);
1007
+ assert.deepEqual(readFileSync(join(root, "alpha", ".screenshots", `${saved.id}.png`)), PNG);
1008
+ const image = await rawRequest(port, `${base}api/designs/alpha/comments/${saved.id}/screenshot`);
1009
+ assert.equal(image.status, 200);
1010
+ assert.equal(image.headers["content-type"], "image/png");
1011
+ assert.deepEqual(image.bytes, PNG);
1012
+ assert.equal((await rawRequest(port, `${base}api/designs/alpha/comments/${saved.id}/screenshot`, { headers: { Origin: "http://evil.example" } })).status, 403);
1013
+ assert.equal((await postComment(port, { ...VALID, mode: "collect" })).status, 201);
1014
+ const plain = storedComments(root)[1];
1015
+ assert.equal("screenshot" in plain, false);
1016
+ assert.equal((await rawRequest(port, `${base}api/designs/alpha/comments/${plain.id}/screenshot`)).status, 404);
1017
+ assert.equal((await rawRequest(port, `${base}api/designs/alpha/comments/nope/screenshot`)).status, 404);
1018
+ assert.equal((await rawRequest(port, `${base}api/designs/alpha/comments/${saved.id}/screenshot`, { method: "POST" })).status, 405);
1019
+ });
1020
+
1021
+ test("an invalid screenshot is refused with 400 and stores nothing", async (t) => {
1022
+ const { port, root, comments } = await start(t);
1023
+ const invalid = [
1024
+ { mimeType: "image/png", data: "not base64!" },
1025
+ { mimeType: "image/png", data: "" },
1026
+ { mimeType: "image/svg+xml", data: SHOT.data },
1027
+ { mimeType: "image/jpeg", data: SHOT.data },
1028
+ { mimeType: "image/png" },
1029
+ "image",
1030
+ ];
1031
+ for (const screenshot of invalid) {
1032
+ const reply = await postComment(port, { ...VALID, screenshot });
1033
+ assert.equal(reply.status, 400, JSON.stringify(screenshot).slice(0, 80));
1034
+ assert.equal(JSON.parse(reply.body).error, "invalid-screenshot");
1035
+ }
1036
+ assert.deepEqual(comments, []);
1037
+ assert.equal(existsSync(join(root, "alpha", "comments.json")), false);
1038
+ assert.equal(existsSync(join(root, "alpha", ".screenshots")), false);
1039
+ assert.equal((await postComment(port, { ...VALID, comment: "x".repeat(17 * 1024), screenshot: SHOT })).status, 413, "the comment fields keep their own body limit");
1040
+ });
1041
+
1042
+ test("send-all delivers the screenshots of collected comments in the batch", async (t) => {
1043
+ const { port, root, batches } = await start(t);
1044
+ await postComment(port, { ...VALID, comment: "first", mode: "collect", screenshot: SHOT });
1045
+ await postComment(port, { ...VALID, comment: "second", mode: "collect" });
1046
+ assert.equal((await postJson(port, "api/designs/alpha/comments/send-all", {})).status, 202);
1047
+ const [first, second] = storedComments(root);
1048
+ assert.deepEqual(batches, [[
1049
+ { ...VALID, comment: "first", id: first.id, screenshot: { path: `design/alpha/.screenshots/${first.id}.png`, mimeType: "image/png", data: SHOT.data } },
1050
+ { ...VALID, comment: "second", id: second.id },
1051
+ ]]);
1052
+ });
1053
+
1054
+ test("formatDesignComment names the screenshot, and the session content attaches it", () => {
1055
+ const comment: DesignComment = { id: "k3x9p2", slug: "landing", file: "02-bold.html", selector: "#cta", excerpt: "Go", comment: "The button jumps mid-animation.", screenshot: { path: "design/landing/.screenshots/k3x9p2.png", mimeType: "image/png", data: "iVBORw0KGgo=" } };
1056
+ const text = [
1057
+ "Design comment for design/landing/02-bold.html",
1058
+ "",
1059
+ "Design: landing",
1060
+ "Artboard: design/landing/02-bold.html",
1061
+ "Comment id: k3x9p2",
1062
+ "Element: #cta",
1063
+ "Excerpt: \"Go\"",
1064
+ "Screenshot: design/landing/.screenshots/k3x9p2.png (attached; open it with the read tool if you cannot see it)",
1065
+ "",
1066
+ "Comment:",
1067
+ "The button jumps mid-animation.",
1068
+ "",
1069
+ "Edit only the artboard design/landing/02-bold.html, following the gentle-design skill.",
1070
+ "When it is applied, mark it resolved with the gentle_design_comments tool (action \"resolve\", slug \"landing\", ids [\"k3x9p2\"]), or set its status to \"resolved\" in design/landing/comments.json.",
1071
+ ].join("\n");
1072
+ assert.equal(formatDesignComment(comment), text);
1073
+ assert.deepEqual(designCommentContent(comment), [{ type: "text", text }, { type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" }]);
1074
+ const { screenshot: _drop, ...plain } = comment;
1075
+ assert.equal(designCommentContent(plain), formatDesignComment(plain));
1076
+ const unreadable = { ...comment, screenshot: { path: comment.screenshot!.path, mimeType: "image/png" } };
1077
+ assert.match(formatDesignComment(unreadable), /\nScreenshot: design\/landing\/\.screenshots\/k3x9p2\.png \(open it with the read tool\)\n/);
1078
+ assert.equal(designCommentContent(unreadable), formatDesignComment(unreadable));
1079
+ });
1080
+
1081
+ test("a batch attaches at most six screenshots, in order, and names the rest for the read tool", () => {
1082
+ const batch: DesignComment[] = Array.from({ length: 8 }, (_, index) => ({
1083
+ id: `c${index}`, slug: "landing", file: "01-calm.html", selector: "#cta", excerpt: "", comment: `note ${index}`,
1084
+ ...(index === 1 ? {} : { screenshot: { path: `design/landing/.screenshots/c${index}.png`, mimeType: "image/png", data: `data${index}` } }),
1085
+ }));
1086
+ const text = formatDesignCommentBatch(batch);
1087
+ assert.match(text, /\n Excerpt: \(none\)\n Screenshot: design\/landing\/\.screenshots\/c0\.png \(attached; open it with the read tool if you cannot see it\)\n Comment:\n/);
1088
+ assert.ok(!text.includes("c1.png"));
1089
+ assert.match(text, /c6\.png \(attached; open it with the read tool if you cannot see it\)/);
1090
+ assert.match(text, /c7\.png \(open it with the read tool\)\n/);
1091
+ const content = designCommentBatchContent(batch);
1092
+ assert.ok(Array.isArray(content));
1093
+ assert.deepEqual(content, [{ type: "text", text }, ...[0, 2, 3, 4, 5, 6].map((index) => ({ type: "image", data: `data${index}`, mimeType: "image/png" }))]);
1094
+ const plain = batch.map(({ screenshot: _drop, ...rest }) => rest);
1095
+ assert.equal(designCommentBatchContent(plain), formatDesignCommentBatch(plain));
1096
+ });