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.
Files changed (99) hide show
  1. package/README.md +42 -17
  2. package/bin/agent-canvas.js +7 -7
  3. package/bin/agent-canvas.ts +48 -37
  4. package/bin/lib/commands/daemon.ts +46 -43
  5. package/bin/lib/commands/exec.ts +100 -100
  6. package/bin/lib/commands/fetch.ts +15 -15
  7. package/bin/lib/commands/install.ts +36 -36
  8. package/bin/lib/commands/instructions.ts +51 -51
  9. package/bin/lib/commands/push.ts +115 -97
  10. package/bin/lib/commands/review.ts +190 -0
  11. package/bin/lib/commands/watch.ts +24 -24
  12. package/bin/lib/config.ts +12 -12
  13. package/bin/lib/daemon-lifecycle.ts +47 -51
  14. package/bin/lib/helpers.ts +82 -76
  15. package/bin/lib/instructions/app-review.md +88 -0
  16. package/bin/lib/instructions/component-checklist.md +7 -5
  17. package/bin/lib/instructions/component-custom.md +20 -14
  18. package/bin/lib/instructions/component-diff.md +4 -3
  19. package/bin/lib/instructions/component-item.md +18 -8
  20. package/bin/lib/instructions/component-markdown.md +4 -2
  21. package/bin/lib/instructions/component-mermaid.md +12 -6
  22. package/bin/lib/instructions/component-multichoice.md +6 -2
  23. package/bin/lib/instructions/component-note.md +1 -1
  24. package/bin/lib/instructions/component-priority.md +1 -1
  25. package/bin/lib/instructions/component-rangeinput.md +9 -2
  26. package/bin/lib/instructions/component-secretinput.md +12 -11
  27. package/bin/lib/instructions/component-svg.md +62 -16
  28. package/bin/lib/instructions/component-table.md +6 -6
  29. package/bin/lib/instructions/component-usefeedback.md +8 -8
  30. package/bin/lib/instructions/component-userinput.md +6 -2
  31. package/bin/lib/instructions/flow-feature.md +22 -5
  32. package/bin/lib/instructions/flow-plan.md +5 -5
  33. package/bin/lib/instructions/flow-runbook.md +28 -25
  34. package/bin/lib/instructions/overview.md +27 -18
  35. package/bin/lib/instructions/theming.md +5 -0
  36. package/daemon/build.ts +171 -152
  37. package/daemon/client/App.tsx +1205 -1077
  38. package/daemon/client/CompareView.tsx +478 -458
  39. package/daemon/client/FileBrowser.tsx +241 -203
  40. package/daemon/client/FileViewer.tsx +220 -211
  41. package/daemon/client/OverviewView.tsx +283 -267
  42. package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
  43. package/daemon/client/RevisionSelect.tsx +111 -95
  44. package/daemon/client/SessionSwitcher.tsx +81 -81
  45. package/daemon/client/ShareDialog.tsx +211 -212
  46. package/daemon/client/apiSchemas.ts +78 -0
  47. package/daemon/client/canvas-runtime.ts +1 -1
  48. package/daemon/client/clientApi.ts +194 -206
  49. package/daemon/client/components/SecretInput.tsx +138 -138
  50. package/daemon/client/components/index.ts +2 -2
  51. package/daemon/client/domDiff.ts +341 -333
  52. package/daemon/client/errorMessage.ts +3 -0
  53. package/daemon/client/exportMarkdown.ts +222 -201
  54. package/daemon/client/lcs.ts +19 -14
  55. package/daemon/client/shareCrypto.ts +57 -57
  56. package/daemon/client/unifiedDiff.ts +660 -649
  57. package/daemon/dist/annotations-runtime.js +1 -1
  58. package/daemon/dist/client.css +2 -2
  59. package/daemon/dist/client.js +77 -80
  60. package/daemon/dist/components.js +59 -59
  61. package/daemon/dist/preact-compat.js +1 -1
  62. package/daemon/dist/review.js +361 -0
  63. package/daemon/dist/runtime.js +1 -1
  64. package/daemon/langMap.ts +24 -6
  65. package/daemon/review-widget/anchor.ts +187 -0
  66. package/daemon/review-widget/client.ts +95 -0
  67. package/daemon/review-widget/index.tsx +36 -0
  68. package/daemon/review-widget/markup.ts +71 -0
  69. package/daemon/review-widget/screenshot.ts +91 -0
  70. package/daemon/review-widget/source.ts +103 -0
  71. package/daemon/review-widget/styles.css +359 -0
  72. package/daemon/review-widget/text-modules.d.ts +4 -0
  73. package/daemon/review-widget/tsconfig.json +16 -0
  74. package/daemon/review-widget/widget.tsx +436 -0
  75. package/daemon/src/components.ts +3 -3
  76. package/daemon/src/handlers/api.ts +330 -328
  77. package/daemon/src/handlers/files.ts +112 -106
  78. package/daemon/src/handlers/static.ts +47 -46
  79. package/daemon/src/handlers/uploads.ts +61 -56
  80. package/daemon/src/paths.ts +13 -10
  81. package/daemon/src/remote-feedback.ts +192 -192
  82. package/daemon/src/review/markdown.ts +52 -0
  83. package/daemon/src/review/model.ts +132 -0
  84. package/daemon/src/review/proxy.ts +177 -0
  85. package/daemon/src/review/server.ts +199 -0
  86. package/daemon/src/review/store.ts +151 -0
  87. package/daemon/src/server.ts +127 -86
  88. package/daemon/src/share.ts +163 -169
  89. package/daemon/src/stubFetch.ts +7 -0
  90. package/daemon/tsconfig.json +19 -19
  91. package/package.json +47 -47
  92. package/skills/canvas/SKILL.md +5 -1
  93. package/bin/lib/commands/exec.test.ts +0 -27
  94. package/bin/lib/helpers.test.ts +0 -79
  95. package/daemon/src/files-api.test.ts +0 -78
  96. package/daemon/src/remote-feedback.test.ts +0 -165
  97. package/daemon/src/secrets-api.test.ts +0 -123
  98. package/daemon/src/share-e2e.test.ts +0 -277
  99. package/daemon/src/share.test.ts +0 -289
@@ -21,182 +21,170 @@
21
21
  * work in cleartext.
22
22
  */
23
23
 
24
- import {
25
- importShareKey,
26
- encryptString,
27
- decryptString,
28
- ENCRYPTION_META,
29
- type EncryptionMeta,
30
- } from "./shareCrypto";
31
- import type { CanvasHost } from "@fabrika/canvas-kernel/client";
24
+ import type { CanvasHost } from '@fabrika/canvas-kernel/client'
25
+ import { type SessionMeta, sessionMetaSchema } from './apiSchemas'
26
+ import { decryptString, ENCRYPTION_META, type EncryptionMeta, encryptString, importShareKey } from './shareCrypto'
32
27
 
33
28
  declare global {
34
- interface Window {
35
- __CANVAS_SHARE__?: {
36
- shareId: string;
37
- mode: "shared";
38
- };
39
- }
29
+ interface Window {
30
+ __CANVAS_SHARE__?: {
31
+ shareId: string
32
+ mode: 'shared'
33
+ }
34
+ }
40
35
  }
41
36
 
42
37
  export interface SharedModeInfo {
43
- isShared: true;
44
- shareId: string;
45
- /** Lazy-imported AES-GCM key parsed from the URL fragment, if any. */
46
- encryptionKey: Promise<CryptoKey> | null;
38
+ isShared: true
39
+ shareId: string
40
+ /** Lazy-imported AES-GCM key parsed from the URL fragment, if any. */
41
+ encryptionKey: Promise<CryptoKey> | null
47
42
  }
48
43
 
49
44
  export interface LocalModeInfo {
50
- isShared: false;
51
- sessionId: string;
45
+ isShared: false
46
+ sessionId: string
52
47
  }
53
48
 
54
- export type ModeInfo = SharedModeInfo | LocalModeInfo;
49
+ export type ModeInfo = SharedModeInfo | LocalModeInfo
55
50
 
56
51
  /** Strip the `#k=...` fragment and parse out the key (base64url). */
57
52
  function readFragmentKey(): string | null {
58
- if (typeof window === "undefined") return null;
59
- const hash = window.location.hash.replace(/^#/, "");
60
- if (!hash) return null;
61
- for (const part of hash.split("&")) {
62
- const [k, v] = part.split("=");
63
- if (k === "k" && v) return decodeURIComponent(v);
64
- }
65
- return null;
53
+ if (typeof window === 'undefined') return null
54
+ const hash = window.location.hash.replace(/^#/, '')
55
+ if (!hash) return null
56
+ for (const part of hash.split('&')) {
57
+ const [k, v] = part.split('=')
58
+ if (k === 'k' && v) return decodeURIComponent(v)
59
+ }
60
+ return null
66
61
  }
67
62
 
68
63
  /** One-time detection. In shared mode the daemon-provided sessionId from
69
64
  * the URL path is ignored in favor of the worker-injected shareId. */
70
65
  export function detectMode(): ModeInfo {
71
- if (typeof window !== "undefined" && window.__CANVAS_SHARE__?.shareId) {
72
- const encoded = readFragmentKey();
73
- return {
74
- isShared: true,
75
- shareId: window.__CANVAS_SHARE__.shareId,
76
- encryptionKey: encoded ? importShareKey(encoded) : null,
77
- };
78
- }
79
- // Local: session id is the path suffix /s/:sessionId
80
- const sessionId = typeof window !== "undefined"
81
- ? window.location.pathname.replace("/s/", "")
82
- : "";
83
- return { isShared: false, sessionId };
66
+ if (typeof window !== 'undefined' && window.__CANVAS_SHARE__?.shareId) {
67
+ const encoded = readFragmentKey()
68
+ return {
69
+ isShared: true,
70
+ shareId: window.__CANVAS_SHARE__.shareId,
71
+ encryptionKey: encoded ? importShareKey(encoded) : null,
72
+ }
73
+ }
74
+ // Local: session id is the path suffix /s/:sessionId
75
+ const sessionId = typeof window !== 'undefined'
76
+ ? window.location.pathname.replace('/s/', '')
77
+ : ''
78
+ return { isShared: false, sessionId }
84
79
  }
85
80
 
86
- export const MODE: ModeInfo = detectMode();
81
+ export const MODE: ModeInfo = detectMode()
87
82
 
88
83
  /** True iff we're in a shared canvas AND have a fragment key. */
89
84
  export function isEncryptedShare(): boolean {
90
- return MODE.isShared && MODE.encryptionKey !== null;
85
+ return MODE.isShared && MODE.encryptionKey !== null
91
86
  }
92
87
 
93
88
  /** Build a canonical id-ish string used as React keys / identifiers. */
94
89
  export function getIdentifier(): string {
95
- return MODE.isShared ? MODE.shareId : MODE.sessionId;
90
+ return MODE.isShared ? MODE.shareId : MODE.sessionId
96
91
  }
97
92
 
98
93
  // --- URL builders -----------------------------------------------------------
99
94
 
100
95
  /** Metadata endpoint (revisions, canvas files, project root). */
101
96
  export function metaUrl(): string {
102
- if (MODE.isShared) return `/s/${MODE.shareId}/meta`;
103
- return `/api/session/${MODE.sessionId}/meta`;
97
+ if (MODE.isShared) return `/s/${MODE.shareId}/meta`
98
+ return `/api/session/${MODE.sessionId}/meta`
104
99
  }
105
100
 
106
101
  /** Compiled canvas JS module for a given filename (+ optional revision). */
107
102
  export function canvasJsUrl(filename: string, revision?: number): string {
108
- const jsName = filename.replace(/\.jsx$/, ".js");
109
- if (MODE.isShared) {
110
- return `/s/${MODE.shareId}/canvas/${encodeURIComponent(jsName)}`;
111
- }
112
- const rev = revision ? `?rev=${revision}&t=${Date.now()}` : `?t=${Date.now()}`;
113
- return `/api/session/${MODE.sessionId}/canvas/${encodeURIComponent(jsName)}${rev}`;
103
+ const jsName = filename.replace(/\.jsx$/, '.js')
104
+ if (MODE.isShared) {
105
+ return `/s/${MODE.shareId}/canvas/${encodeURIComponent(jsName)}`
106
+ }
107
+ const rev = revision ? `?rev=${revision}&t=${Date.now()}` : `?t=${Date.now()}`
108
+ return `/api/session/${MODE.sessionId}/canvas/${encodeURIComponent(jsName)}${rev}`
114
109
  }
115
110
 
116
111
  /** Upload endpoint for annotation images. */
117
112
  export function uploadUrl(): string {
118
- if (MODE.isShared) return `/s/${MODE.shareId}/upload`;
119
- return `/api/session/${MODE.sessionId}/upload`;
113
+ if (MODE.isShared) return `/s/${MODE.shareId}/upload`
114
+ return `/api/session/${MODE.sessionId}/upload`
120
115
  }
121
116
 
122
- function localSecretUrl(fieldId: string, suffix: "status" | "value"): string {
123
- if (MODE.isShared) throw new Error("Secret inputs are available only on the local canvas daemon");
124
- return `/api/session/${encodeURIComponent(MODE.sessionId)}/secrets/${encodeURIComponent(fieldId)}/${suffix}`;
117
+ function localSecretUrl(fieldId: string, suffix: 'status' | 'value'): string {
118
+ if (MODE.isShared) throw new Error('Secret inputs are available only on the local canvas daemon')
119
+ return `/api/session/${encodeURIComponent(MODE.sessionId)}/secrets/${encodeURIComponent(fieldId)}/${suffix}`
125
120
  }
126
121
 
127
122
  async function secretApiError(response: Response): Promise<Error> {
128
- const raw: unknown = await response.json().catch(() => null);
129
- if (typeof raw === "object" && raw !== null && "error" in raw && typeof raw.error === "string") {
130
- return new Error(raw.error);
131
- }
132
- return new Error(`Secret API request failed with status ${response.status}`);
123
+ const raw: unknown = await response.json().catch(() => null)
124
+ if (typeof raw === 'object' && raw !== null && 'error' in raw && typeof raw.error === 'string') {
125
+ return new Error(raw.error)
126
+ }
127
+ return new Error(`Secret API request failed with status ${response.status}`)
133
128
  }
134
129
 
135
130
  /** Return only readiness metadata. The secret value is never sent back to the browser. */
136
131
  export async function getLocalSecretStatus(fieldId: string): Promise<boolean> {
137
- const response = await fetch(localSecretUrl(fieldId, "status"), { cache: "no-store" });
138
- if (!response.ok) throw await secretApiError(response);
139
- const raw: unknown = await response.json();
140
- return typeof raw === "object" && raw !== null && "ready" in raw && raw.ready === true;
132
+ const response = await fetch(localSecretUrl(fieldId, 'status'), { cache: 'no-store' })
133
+ if (!response.ok) throw await secretApiError(response)
134
+ const raw: unknown = await response.json()
135
+ return typeof raw === 'object' && raw !== null && 'ready' in raw && raw.ready === true
141
136
  }
142
137
 
143
138
  /** Deposit a secret into the daemon's in-memory vault. */
144
139
  export async function storeLocalSecret(fieldId: string, value: string): Promise<void> {
145
- const response = await fetch(localSecretUrl(fieldId, "value"), {
146
- method: "POST",
147
- headers: { "Content-Type": "application/json" },
148
- cache: "no-store",
149
- body: JSON.stringify({ value }),
150
- });
151
- if (!response.ok) throw await secretApiError(response);
140
+ const response = await fetch(localSecretUrl(fieldId, 'value'), {
141
+ method: 'POST',
142
+ headers: { 'Content-Type': 'application/json' },
143
+ cache: 'no-store',
144
+ body: JSON.stringify({ value }),
145
+ })
146
+ if (!response.ok) throw await secretApiError(response)
152
147
  }
153
148
 
154
149
  /** Remove a deposited secret without retrieving its value. */
155
150
  export async function clearLocalSecret(fieldId: string): Promise<void> {
156
- const response = await fetch(localSecretUrl(fieldId, "value"), {
157
- method: "DELETE",
158
- cache: "no-store",
159
- });
160
- if (!response.ok) throw await secretApiError(response);
151
+ const response = await fetch(localSecretUrl(fieldId, 'value'), {
152
+ method: 'DELETE',
153
+ cache: 'no-store',
154
+ })
155
+ if (!response.ok) throw await secretApiError(response)
161
156
  }
162
157
 
163
158
  // --- Meta fetch (with optional decryption) ---------------------------------
164
159
 
165
- interface RawMetaResponse {
166
- encryption?: EncryptionMeta;
167
- origin?: { sessionId?: string; label?: string; revision?: number; createdAt?: string };
168
- [key: string]: unknown;
169
- }
170
-
171
160
  /** Fetch + parse meta, decrypting origin.sessionId/label for encrypted
172
161
  * shares. The shape returned is the same as the worker/daemon emits, so
173
162
  * callers don't have to branch. */
174
- export async function fetchMeta(): Promise<any> {
175
- const res = await fetch(metaUrl());
176
- if (!res.ok) throw new Error(`meta ${res.status}`);
177
- const data = (await res.json()) as RawMetaResponse;
178
-
179
- if (data.encryption && MODE.isShared && MODE.encryptionKey) {
180
- const key = await MODE.encryptionKey;
181
- if (data.origin) {
182
- const o: any = { ...data.origin };
183
- if (o.sessionId) o.sessionId = await decryptString(key, o.sessionId);
184
- if (o.label) o.label = await decryptString(key, o.label);
185
- data.origin = o;
186
- }
187
- // The worker synthesizes a one-element revisions array from the same
188
- // ShareRecord.origin, so its `label` is also ciphertext — decrypt it
189
- // in lockstep with origin.label above.
190
- const revs = (data as any).revisions;
191
- if (Array.isArray(revs)) {
192
- for (const r of revs) {
193
- if (r.label) {
194
- try { r.label = await decryptString(key, r.label); } catch {}
195
- }
196
- }
197
- }
198
- }
199
- return data;
163
+ export async function fetchMeta(): Promise<SessionMeta> {
164
+ const res = await fetch(metaUrl())
165
+ if (!res.ok) throw new Error(`meta ${res.status}`)
166
+ const data = sessionMetaSchema.parse(await res.json())
167
+
168
+ if (data.encryption && MODE.isShared && MODE.encryptionKey) {
169
+ const key = await MODE.encryptionKey
170
+ if (data.origin) {
171
+ const o = { ...data.origin }
172
+ if (o.sessionId) o.sessionId = await decryptString(key, o.sessionId)
173
+ if (o.label) o.label = await decryptString(key, o.label)
174
+ data.origin = o
175
+ }
176
+ // The worker synthesizes a one-element revisions array from the same
177
+ // ShareRecord.origin, so its `label` is also ciphertext — decrypt it
178
+ // in lockstep with origin.label above.
179
+ for (const r of data.revisions ?? []) {
180
+ if (r.label) {
181
+ try {
182
+ r.label = await decryptString(key, r.label)
183
+ } catch {}
184
+ }
185
+ }
186
+ }
187
+ return data
200
188
  }
201
189
 
202
190
  // --- Canvas module loading -------------------------------------------------
@@ -207,24 +195,24 @@ export async function fetchMeta(): Promise<any> {
207
195
  * Import maps still apply to Blob-URL module imports, so the resulting
208
196
  * module's `#canvas/runtime` / `#canvas/components` imports resolve
209
197
  * exactly as they would for an in-place script. */
210
- export async function loadCanvasModule(filename: string, revision?: number): Promise<any> {
211
- const url = canvasJsUrl(filename, revision);
212
- if (!isEncryptedShare()) {
213
- return import(/* @vite-ignore */ url);
214
- }
215
- const res = await fetch(url);
216
- if (!res.ok) throw new Error(`canvas ${res.status}`);
217
- const ciphertext = await res.text();
218
- const key = await (MODE as SharedModeInfo).encryptionKey!;
219
- const js = await decryptString(key, ciphertext);
220
- const blob = new Blob([js], { type: "application/javascript" });
221
- const blobUrl = URL.createObjectURL(blob);
222
- try {
223
- return await import(/* @vite-ignore */ blobUrl);
224
- } finally {
225
- // Revoke after a tick so the import has fully resolved its source.
226
- setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
227
- }
198
+ export const loadCanvasModule: CanvasHost['loadCanvasModule'] = async (filename, revision) => {
199
+ const url = canvasJsUrl(filename, revision)
200
+ if (!isEncryptedShare()) {
201
+ return import(/* @vite-ignore */ url)
202
+ }
203
+ const res = await fetch(url)
204
+ if (!res.ok) throw new Error(`canvas ${res.status}`)
205
+ const ciphertext = await res.text()
206
+ const key = await (MODE as SharedModeInfo).encryptionKey!
207
+ const js = await decryptString(key, ciphertext)
208
+ const blob = new Blob([js], { type: 'application/javascript' })
209
+ const blobUrl = URL.createObjectURL(blob)
210
+ try {
211
+ return await import(/* @vite-ignore */ blobUrl)
212
+ } finally {
213
+ // Revoke after a tick so the import has fully resolved its source.
214
+ setTimeout(() => URL.revokeObjectURL(blobUrl), 0)
215
+ }
228
216
  }
229
217
 
230
218
  // --- Feedback submission ----------------------------------------------------
@@ -233,27 +221,27 @@ export async function loadCanvasModule(filename: string, revision?: number): Pro
233
221
  * Local-mode author info used when submitting feedback in shared mode.
234
222
  * Prompted on first submit and cached in localStorage.
235
223
  */
236
- const REVIEWER_KEY = "canvas-reviewer-identity";
224
+ const REVIEWER_KEY = 'canvas-reviewer-identity'
237
225
 
238
226
  export function getReviewerIdentity(): { id: string; name: string } | null {
239
- try {
240
- const raw = localStorage.getItem(REVIEWER_KEY);
241
- if (!raw) return null;
242
- return JSON.parse(raw);
243
- } catch {
244
- return null;
245
- }
227
+ try {
228
+ const raw = localStorage.getItem(REVIEWER_KEY)
229
+ if (!raw) return null
230
+ return JSON.parse(raw)
231
+ } catch {
232
+ return null
233
+ }
246
234
  }
247
235
 
248
236
  export function setReviewerIdentity(name: string): { id: string; name: string } {
249
- let existing = getReviewerIdentity();
250
- if (!existing) {
251
- existing = { id: crypto.randomUUID?.() || String(Date.now()), name };
252
- } else {
253
- existing.name = name;
254
- }
255
- localStorage.setItem(REVIEWER_KEY, JSON.stringify(existing));
256
- return existing;
237
+ let existing = getReviewerIdentity()
238
+ if (!existing) {
239
+ existing = { id: crypto.randomUUID?.() || String(Date.now()), name }
240
+ } else {
241
+ existing.name = name
242
+ }
243
+ localStorage.setItem(REVIEWER_KEY, JSON.stringify(existing))
244
+ return existing
257
245
  }
258
246
 
259
247
  /**
@@ -261,47 +249,47 @@ export function setReviewerIdentity(name: string): { id: string; name: string }
261
249
  * feedback. Matches the worker's `FeedbackPostBody`.
262
250
  */
263
251
  export interface SharedFeedbackPayload {
264
- author: { id: string; name: string };
265
- revision: number;
266
- annotations: Array<Record<string, unknown>>;
267
- generalNote?: string;
252
+ author: { id: string; name: string }
253
+ revision: number
254
+ annotations: Array<Record<string, unknown>>
255
+ generalNote?: string
268
256
  }
269
257
 
270
258
  /** Encrypt the secret fields of a feedback payload in-place semantics. */
271
259
  async function encryptFeedback(
272
- payload: SharedFeedbackPayload,
273
- key: CryptoKey,
260
+ payload: SharedFeedbackPayload,
261
+ key: CryptoKey,
274
262
  ): Promise<SharedFeedbackPayload & { encryption: EncryptionMeta }> {
275
- const annotations = await Promise.all(
276
- payload.annotations.map(async (a) => {
277
- const out: Record<string, unknown> = {
278
- id: a.id,
279
- createdAt: a.createdAt,
280
- snippet: await encryptString(key, String(a.snippet ?? "")),
281
- note: await encryptString(key, String(a.note ?? "")),
282
- };
283
- if (a.filePath) out.filePath = await encryptString(key, String(a.filePath));
284
- if (a.canvasFile) out.canvasFile = await encryptString(key, String(a.canvasFile));
285
- if (a.context !== undefined && a.context !== null) {
286
- out.context = await encryptString(key, JSON.stringify(a.context));
287
- }
288
- // Image attachments are content-addressed worker URLs — already public
289
- // by virtue of being served by the worker, so leaving them plaintext
290
- // is fine and avoids breaking client-side rendering of the URL.
291
- if (a.attachments) out.attachments = a.attachments;
292
- return out;
293
- }),
294
- );
295
- return {
296
- author: {
297
- id: payload.author.id,
298
- name: await encryptString(key, payload.author.name),
299
- },
300
- revision: payload.revision,
301
- annotations,
302
- ...(payload.generalNote ? { generalNote: await encryptString(key, payload.generalNote) } : {}),
303
- encryption: ENCRYPTION_META,
304
- };
263
+ const annotations = await Promise.all(
264
+ payload.annotations.map(async (a) => {
265
+ const out: Record<string, unknown> = {
266
+ id: a.id,
267
+ createdAt: a.createdAt,
268
+ snippet: await encryptString(key, String(a.snippet ?? '')),
269
+ note: await encryptString(key, String(a.note ?? '')),
270
+ }
271
+ if (a.filePath) out.filePath = await encryptString(key, String(a.filePath))
272
+ if (a.canvasFile) out.canvasFile = await encryptString(key, String(a.canvasFile))
273
+ if (a.context !== undefined && a.context !== null) {
274
+ out.context = await encryptString(key, JSON.stringify(a.context))
275
+ }
276
+ // Image attachments are content-addressed worker URLs — already public
277
+ // by virtue of being served by the worker, so leaving them plaintext
278
+ // is fine and avoids breaking client-side rendering of the URL.
279
+ if (a.attachments) out.attachments = a.attachments
280
+ return out
281
+ }),
282
+ )
283
+ return {
284
+ author: {
285
+ id: payload.author.id,
286
+ name: await encryptString(key, payload.author.name),
287
+ },
288
+ revision: payload.revision,
289
+ annotations,
290
+ ...(payload.generalNote ? { generalNote: await encryptString(key, payload.generalNote) } : {}),
291
+ encryption: ENCRYPTION_META,
292
+ }
305
293
  }
306
294
 
307
295
  /**
@@ -311,30 +299,30 @@ async function encryptFeedback(
311
299
  * fragment key.
312
300
  */
313
301
  export async function submitSharedFeedback(payload: SharedFeedbackPayload): Promise<void> {
314
- if (!MODE.isShared) throw new Error("submitSharedFeedback called outside shared mode");
315
- let body: unknown = payload;
316
- if (MODE.encryptionKey) {
317
- const key = await MODE.encryptionKey;
318
- body = await encryptFeedback(payload, key);
319
- }
320
- const res = await fetch(`/s/${MODE.shareId}/feedback`, {
321
- method: "POST",
322
- headers: { "Content-Type": "application/json" },
323
- body: JSON.stringify(body),
324
- });
325
- if (!res.ok) {
326
- const text = await res.text().catch(() => "");
327
- throw new Error(`Feedback submission failed: ${res.status} ${text}`);
328
- }
302
+ if (!MODE.isShared) throw new Error('submitSharedFeedback called outside shared mode')
303
+ let body: unknown = payload
304
+ if (MODE.encryptionKey) {
305
+ const key = await MODE.encryptionKey
306
+ body = await encryptFeedback(payload, key)
307
+ }
308
+ const res = await fetch(`/s/${MODE.shareId}/feedback`, {
309
+ method: 'POST',
310
+ headers: { 'Content-Type': 'application/json' },
311
+ body: JSON.stringify(body),
312
+ })
313
+ if (!res.ok) {
314
+ const text = await res.text().catch(() => '')
315
+ throw new Error(`Feedback submission failed: ${res.status} ${text}`)
316
+ }
329
317
  }
330
318
 
331
319
  // --- Feature flags ----------------------------------------------------------
332
320
 
333
321
  /** Are daemon-only filesystem endpoints (file tree, file viewer) available? */
334
- export const FS_AVAILABLE: boolean = !MODE.isShared;
322
+ export const FS_AVAILABLE: boolean = !MODE.isShared
335
323
 
336
324
  /** Is live WebSocket session updates available? */
337
- export const WS_AVAILABLE: boolean = !MODE.isShared;
325
+ export const WS_AVAILABLE: boolean = !MODE.isShared
338
326
 
339
327
  // --- Kernel host binding ----------------------------------------------------
340
328
 
@@ -344,11 +332,11 @@ export const WS_AVAILABLE: boolean = !MODE.isShared;
344
332
  * defaults: shared canvases come from the worker (and may be encrypted).
345
333
  */
346
334
  export const canvasHost: CanvasHost = {
347
- get sessionId() {
348
- return getIdentifier();
349
- },
350
- isShared: MODE.isShared,
351
- fsAvailable: FS_AVAILABLE,
352
- uploadUrl,
353
- loadCanvasModule,
354
- };
335
+ get sessionId() {
336
+ return getIdentifier()
337
+ },
338
+ isShared: MODE.isShared,
339
+ fsAvailable: FS_AVAILABLE,
340
+ uploadUrl,
341
+ loadCanvasModule,
342
+ }