@1agh/maude 0.42.0 → 0.44.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 (332) hide show
  1. package/apps/studio/.ai/cache/_stats.json +7 -0
  2. package/apps/studio/acp/bootstrap-brief.ts +12 -2
  3. package/apps/studio/acp/bridge.ts +44 -10
  4. package/apps/studio/acp/env.ts +23 -0
  5. package/apps/studio/acp/login-state.ts +263 -0
  6. package/apps/studio/acp/plugin-bootstrap.ts +31 -25
  7. package/apps/studio/acp/probe.ts +152 -1
  8. package/apps/studio/annotations-layer.tsx +1451 -324
  9. package/apps/studio/annotations-model.ts +58 -0
  10. package/apps/studio/api.ts +568 -24
  11. package/apps/studio/bin/_audio-search.mjs +140 -0
  12. package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
  13. package/apps/studio/bin/_import-asset.mjs +815 -0
  14. package/apps/studio/bin/_import-brand.mjs +635 -0
  15. package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
  16. package/apps/studio/bin/_import-tokens.mjs +1201 -0
  17. package/apps/studio/bin/_ingest-footage.mjs +386 -0
  18. package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
  19. package/apps/studio/bin/_transcribe.mjs +419 -0
  20. package/apps/studio/bin/_transcribe.test.mjs +80 -0
  21. package/apps/studio/bin/_video-playwright.mjs +141 -71
  22. package/apps/studio/bin/audio-search.sh +28 -0
  23. package/apps/studio/bin/generate.sh +154 -0
  24. package/apps/studio/bin/import-asset.sh +34 -0
  25. package/apps/studio/bin/import-brand.sh +29 -0
  26. package/apps/studio/bin/import-tokens.sh +33 -0
  27. package/apps/studio/bin/ingest-footage.sh +27 -0
  28. package/apps/studio/bin/photo-adjust.sh +163 -0
  29. package/apps/studio/bin/photo-bg-remove.sh +258 -0
  30. package/apps/studio/bin/probe-footage.sh +28 -0
  31. package/apps/studio/bin/read-annotations.mjs +125 -2
  32. package/apps/studio/bin/transcribe.sh +39 -0
  33. package/apps/studio/canvas-edit.ts +656 -12
  34. package/apps/studio/canvas-lib.tsx +637 -10
  35. package/apps/studio/canvas-pipeline.ts +65 -7
  36. package/apps/studio/canvas-shell.tsx +156 -16
  37. package/apps/studio/client/app.jsx +2385 -699
  38. package/apps/studio/client/export-center.jsx +35 -1
  39. package/apps/studio/client/generate-dialog.jsx +352 -0
  40. package/apps/studio/client/github.js +18 -0
  41. package/apps/studio/client/inspector-controls.jsx +781 -0
  42. package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
  43. package/apps/studio/client/panels/ChatPanel.jsx +15 -33
  44. package/apps/studio/client/panels/GitPanel.jsx +8 -0
  45. package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
  46. package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
  47. package/apps/studio/client/panels/ReadinessList.jsx +227 -7
  48. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
  49. package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
  50. package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
  51. package/apps/studio/client/panels/StickerPicker.jsx +160 -0
  52. package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
  53. package/apps/studio/client/photo-knobs.jsx +432 -0
  54. package/apps/studio/client/styles/3-shell-maude.css +376 -28
  55. package/apps/studio/client/styles/4-components.css +223 -0
  56. package/apps/studio/client/styles/6-acp-chat.css +86 -0
  57. package/apps/studio/client/tour/quick-setup-tour.js +39 -0
  58. package/apps/studio/commands/annotation-strokes-command.ts +13 -3
  59. package/apps/studio/commands/edit-source-command.ts +31 -4
  60. package/apps/studio/config.schema.json +70 -0
  61. package/apps/studio/context-menu.tsx +42 -8
  62. package/apps/studio/context.ts +16 -0
  63. package/apps/studio/design-setup-readiness.ts +115 -0
  64. package/apps/studio/dist/client.bundle.js +5572 -21
  65. package/apps/studio/dist/comment-mount.js +2 -2
  66. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  67. package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
  68. package/apps/studio/dist/runtime/pixi-js.js +519 -519
  69. package/apps/studio/dist/styles.css +1 -1
  70. package/apps/studio/dom-selection.ts +25 -0
  71. package/apps/studio/draw/palette.ts +34 -0
  72. package/apps/studio/exporters/jobs.ts +47 -5
  73. package/apps/studio/exporters/video.ts +24 -4
  74. package/apps/studio/footage/schema.test.ts +287 -0
  75. package/apps/studio/footage/schema.ts +662 -0
  76. package/apps/studio/footage-store.ts +235 -0
  77. package/apps/studio/fs-watch.ts +10 -0
  78. package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
  79. package/apps/studio/generation/adapters/gemini.ts +542 -0
  80. package/apps/studio/generation/adapters/groq.test.ts +80 -0
  81. package/apps/studio/generation/adapters/groq.ts +247 -0
  82. package/apps/studio/generation/audio-library.test.ts +95 -0
  83. package/apps/studio/generation/audio-library.ts +156 -0
  84. package/apps/studio/generation/captions.test.ts +78 -0
  85. package/apps/studio/generation/captions.ts +132 -0
  86. package/apps/studio/generation/download.ts +76 -0
  87. package/apps/studio/generation/elevenlabs.test.ts +223 -0
  88. package/apps/studio/generation/gemini.test.ts +350 -0
  89. package/apps/studio/generation/jobs.test.ts +128 -0
  90. package/apps/studio/generation/jobs.ts +243 -0
  91. package/apps/studio/generation/keys.test.ts +75 -0
  92. package/apps/studio/generation/keys.ts +162 -0
  93. package/apps/studio/generation/prefs.test.ts +95 -0
  94. package/apps/studio/generation/prefs.ts +82 -0
  95. package/apps/studio/generation/registry.ts +88 -0
  96. package/apps/studio/generation/types.test.ts +123 -0
  97. package/apps/studio/generation/types.ts +266 -0
  98. package/apps/studio/generation/whisper-models.test.ts +152 -0
  99. package/apps/studio/generation/whisper-models.ts +257 -0
  100. package/apps/studio/generation/whisper-spike-results.md +61 -0
  101. package/apps/studio/git/endpoints.ts +68 -7
  102. package/apps/studio/git/service.ts +199 -21
  103. package/apps/studio/github/service.ts +67 -0
  104. package/apps/studio/handoff.ts +3 -2
  105. package/apps/studio/http.ts +1039 -22
  106. package/apps/studio/input-router.tsx +10 -0
  107. package/apps/studio/inspect.ts +53 -1
  108. package/apps/studio/media/intro.mp4 +0 -0
  109. package/apps/studio/media-commit-chain.ts +119 -0
  110. package/apps/studio/paths.ts +20 -0
  111. package/apps/studio/photo/filters.ts +285 -0
  112. package/apps/studio/photo/pipeline.ts +607 -0
  113. package/apps/studio/photo/schema.ts +367 -0
  114. package/apps/studio/photo-store.ts +154 -0
  115. package/apps/studio/readiness.ts +108 -20
  116. package/apps/studio/runtime-bundle.ts +29 -6
  117. package/apps/studio/scaffold-design.ts +1145 -0
  118. package/apps/studio/server.ts +18 -2
  119. package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
  120. package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
  121. package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
  122. package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
  123. package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
  124. package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
  125. package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
  126. package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
  127. package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
  128. package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
  129. package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
  130. package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
  131. package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
  132. package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
  133. package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
  134. package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
  135. package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
  136. package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
  137. package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
  138. package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
  139. package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
  140. package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
  141. package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
  142. package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
  143. package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
  144. package/apps/studio/stickers/life-style/best.png +0 -0
  145. package/apps/studio/stickers/life-style/come-on.png +0 -0
  146. package/apps/studio/stickers/life-style/cut.png +0 -0
  147. package/apps/studio/stickers/life-style/detox.png +0 -0
  148. package/apps/studio/stickers/life-style/just.png +0 -0
  149. package/apps/studio/stickers/life-style/manifest.json +68 -0
  150. package/apps/studio/stickers/life-style/mirror.png +0 -0
  151. package/apps/studio/stickers/life-style/nice.png +0 -0
  152. package/apps/studio/stickers/life-style/objects.png +0 -0
  153. package/apps/studio/stickers/life-style/ok.png +0 -0
  154. package/apps/studio/stickers/life-style/queen.png +0 -0
  155. package/apps/studio/stickers/life-style/star.png +0 -0
  156. package/apps/studio/stickers/life-style/sun.png +0 -0
  157. package/apps/studio/stickers/life-style/water.png +0 -0
  158. package/apps/studio/stickers/life-style/yeah.png +0 -0
  159. package/apps/studio/stickers/life-style/you-can.png +0 -0
  160. package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
  161. package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
  162. package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
  163. package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
  164. package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
  165. package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
  166. package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
  167. package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
  168. package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
  169. package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
  170. package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
  171. package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
  172. package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
  173. package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
  174. package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
  175. package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
  176. package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
  177. package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
  178. package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
  179. package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
  180. package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
  181. package/apps/studio/stickers/project-status/group-100.png +0 -0
  182. package/apps/studio/stickers/project-status/group-101.png +0 -0
  183. package/apps/studio/stickers/project-status/group-102.png +0 -0
  184. package/apps/studio/stickers/project-status/group-103.png +0 -0
  185. package/apps/studio/stickers/project-status/group-104.png +0 -0
  186. package/apps/studio/stickers/project-status/group-105.png +0 -0
  187. package/apps/studio/stickers/project-status/group-106.png +0 -0
  188. package/apps/studio/stickers/project-status/group-107.png +0 -0
  189. package/apps/studio/stickers/project-status/group-108.png +0 -0
  190. package/apps/studio/stickers/project-status/group-109.png +0 -0
  191. package/apps/studio/stickers/project-status/group-110.png +0 -0
  192. package/apps/studio/stickers/project-status/group-111.png +0 -0
  193. package/apps/studio/stickers/project-status/group-112.png +0 -0
  194. package/apps/studio/stickers/project-status/group-113.png +0 -0
  195. package/apps/studio/stickers/project-status/group-114.png +0 -0
  196. package/apps/studio/stickers/project-status/group-115.png +0 -0
  197. package/apps/studio/stickers/project-status/group-117.png +0 -0
  198. package/apps/studio/stickers/project-status/group-118.png +0 -0
  199. package/apps/studio/stickers/project-status/group-119.png +0 -0
  200. package/apps/studio/stickers/project-status/group-120.png +0 -0
  201. package/apps/studio/stickers/project-status/group-121.png +0 -0
  202. package/apps/studio/stickers/project-status/group-122.png +0 -0
  203. package/apps/studio/stickers/project-status/group-41.png +0 -0
  204. package/apps/studio/stickers/project-status/group-42.png +0 -0
  205. package/apps/studio/stickers/project-status/group-43.png +0 -0
  206. package/apps/studio/stickers/project-status/group-44.png +0 -0
  207. package/apps/studio/stickers/project-status/group-45.png +0 -0
  208. package/apps/studio/stickers/project-status/group-46.png +0 -0
  209. package/apps/studio/stickers/project-status/group-47.png +0 -0
  210. package/apps/studio/stickers/project-status/group-48.png +0 -0
  211. package/apps/studio/stickers/project-status/group-49.png +0 -0
  212. package/apps/studio/stickers/project-status/group-50.png +0 -0
  213. package/apps/studio/stickers/project-status/group-51.png +0 -0
  214. package/apps/studio/stickers/project-status/group-52.png +0 -0
  215. package/apps/studio/stickers/project-status/group-53.png +0 -0
  216. package/apps/studio/stickers/project-status/group-54.png +0 -0
  217. package/apps/studio/stickers/project-status/group-55.png +0 -0
  218. package/apps/studio/stickers/project-status/group-56.png +0 -0
  219. package/apps/studio/stickers/project-status/group-57.png +0 -0
  220. package/apps/studio/stickers/project-status/group-58.png +0 -0
  221. package/apps/studio/stickers/project-status/group-59.png +0 -0
  222. package/apps/studio/stickers/project-status/group-60.png +0 -0
  223. package/apps/studio/stickers/project-status/group-61.png +0 -0
  224. package/apps/studio/stickers/project-status/group-62.png +0 -0
  225. package/apps/studio/stickers/project-status/group-63.png +0 -0
  226. package/apps/studio/stickers/project-status/group-64.png +0 -0
  227. package/apps/studio/stickers/project-status/group-65.png +0 -0
  228. package/apps/studio/stickers/project-status/group-66.png +0 -0
  229. package/apps/studio/stickers/project-status/group-67.png +0 -0
  230. package/apps/studio/stickers/project-status/group-68.png +0 -0
  231. package/apps/studio/stickers/project-status/group-69.png +0 -0
  232. package/apps/studio/stickers/project-status/group-70.png +0 -0
  233. package/apps/studio/stickers/project-status/group-71.png +0 -0
  234. package/apps/studio/stickers/project-status/group-72.png +0 -0
  235. package/apps/studio/stickers/project-status/group-73.png +0 -0
  236. package/apps/studio/stickers/project-status/group-74.png +0 -0
  237. package/apps/studio/stickers/project-status/group-75.png +0 -0
  238. package/apps/studio/stickers/project-status/group-76.png +0 -0
  239. package/apps/studio/stickers/project-status/group-77.png +0 -0
  240. package/apps/studio/stickers/project-status/group-78.png +0 -0
  241. package/apps/studio/stickers/project-status/group-79.png +0 -0
  242. package/apps/studio/stickers/project-status/group-80.png +0 -0
  243. package/apps/studio/stickers/project-status/group-81.png +0 -0
  244. package/apps/studio/stickers/project-status/group-82.png +0 -0
  245. package/apps/studio/stickers/project-status/group-83.png +0 -0
  246. package/apps/studio/stickers/project-status/group-84.png +0 -0
  247. package/apps/studio/stickers/project-status/group-85.png +0 -0
  248. package/apps/studio/stickers/project-status/group-86.png +0 -0
  249. package/apps/studio/stickers/project-status/group-87.png +0 -0
  250. package/apps/studio/stickers/project-status/group-88.png +0 -0
  251. package/apps/studio/stickers/project-status/group-89.png +0 -0
  252. package/apps/studio/stickers/project-status/group-90.png +0 -0
  253. package/apps/studio/stickers/project-status/group-91.png +0 -0
  254. package/apps/studio/stickers/project-status/group-92.png +0 -0
  255. package/apps/studio/stickers/project-status/group-93.png +0 -0
  256. package/apps/studio/stickers/project-status/group-94.png +0 -0
  257. package/apps/studio/stickers/project-status/group-96.png +0 -0
  258. package/apps/studio/stickers/project-status/group-97.png +0 -0
  259. package/apps/studio/stickers/project-status/group-98.png +0 -0
  260. package/apps/studio/stickers/project-status/group-99.png +0 -0
  261. package/apps/studio/stickers/project-status/manifest.json +328 -0
  262. package/apps/studio/test/_helpers.ts +15 -2
  263. package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
  264. package/apps/studio/test/acp-bridge.test.ts +34 -1
  265. package/apps/studio/test/acp-commands.test.ts +2 -1
  266. package/apps/studio/test/acp-env.test.ts +30 -0
  267. package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
  268. package/apps/studio/test/acp-session-plugins.test.ts +72 -1
  269. package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
  270. package/apps/studio/test/annotations-layer.test.ts +174 -0
  271. package/apps/studio/test/asset-api.test.ts +3 -3
  272. package/apps/studio/test/canvas-edit.test.ts +17 -6
  273. package/apps/studio/test/canvas-origin-gate.test.ts +50 -0
  274. package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
  275. package/apps/studio/test/csrf-write-guard.test.ts +23 -1
  276. package/apps/studio/test/design-setup-readiness.test.ts +155 -0
  277. package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
  278. package/apps/studio/test/edit-source-command.test.ts +23 -0
  279. package/apps/studio/test/element-structural-edit.test.ts +154 -0
  280. package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
  281. package/apps/studio/test/footage-store.test.ts +100 -0
  282. package/apps/studio/test/generate-route.test.ts +106 -0
  283. package/apps/studio/test/git-branches.test.ts +97 -0
  284. package/apps/studio/test/github-api.test.ts +56 -1
  285. package/apps/studio/test/import-asset-browser.test.ts +64 -0
  286. package/apps/studio/test/import-asset-route.test.ts +71 -0
  287. package/apps/studio/test/import-asset.test.ts +399 -0
  288. package/apps/studio/test/import-brand-browser.test.ts +88 -0
  289. package/apps/studio/test/import-brand-route.test.ts +120 -0
  290. package/apps/studio/test/import-brand.test.ts +206 -0
  291. package/apps/studio/test/import-tokens.test.ts +722 -0
  292. package/apps/studio/test/input-router.test.ts +33 -0
  293. package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
  294. package/apps/studio/test/media-commit-chain.test.ts +210 -0
  295. package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
  296. package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
  297. package/apps/studio/test/photo-edit-api.test.ts +201 -0
  298. package/apps/studio/test/photo-filters.test.ts +149 -0
  299. package/apps/studio/test/photo-pipeline.test.ts +106 -0
  300. package/apps/studio/test/photo-taxonomy.test.ts +96 -0
  301. package/apps/studio/test/read-annotations.test.ts +164 -0
  302. package/apps/studio/test/readiness.test.ts +13 -11
  303. package/apps/studio/test/scaffold-design.test.ts +122 -0
  304. package/apps/studio/test/shell-importmap.test.ts +49 -0
  305. package/apps/studio/test/text-editability-stamp.test.ts +131 -0
  306. package/apps/studio/test/timeline-parse.test.ts +57 -0
  307. package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
  308. package/apps/studio/test/tour-overlay.test.tsx +16 -0
  309. package/apps/studio/test/undo-stack.test.ts +33 -0
  310. package/apps/studio/test/video-asset.test.ts +5 -5
  311. package/apps/studio/test/video-render-bridge.test.ts +23 -1
  312. package/apps/studio/text-caret.ts +239 -0
  313. package/apps/studio/tool-palette.tsx +49 -21
  314. package/apps/studio/ui-prefs.ts +130 -0
  315. package/apps/studio/undo-stack.ts +94 -2
  316. package/apps/studio/use-annotation-resize.tsx +4 -4
  317. package/apps/studio/use-canvas-media-drop.tsx +40 -1
  318. package/apps/studio/use-chrome-visibility.tsx +8 -2
  319. package/apps/studio/use-selection-set.tsx +21 -0
  320. package/apps/studio/video-comp.tsx +48 -7
  321. package/apps/studio/whats-new.json +149 -0
  322. package/apps/studio/ws.ts +5 -0
  323. package/cli/bin/maude.mjs +6 -6
  324. package/cli/commands/design.mjs +67 -3
  325. package/cli/lib/gitignore-block.mjs +2 -0
  326. package/cli/lib/pkg-root.mjs +107 -0
  327. package/cli/lib/pkg-root.test.mjs +79 -0
  328. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  329. package/cli/lib/update-check.mjs +8 -0
  330. package/package.json +13 -11
  331. package/plugins/design/dependencies.json +18 -0
  332. package/plugins/design/templates/_shell.html +1 -0
@@ -8,26 +8,75 @@ import { createHash } from 'node:crypto';
8
8
  import { existsSync, readFileSync, watch } from 'node:fs';
9
9
  import { dirname, join, posix, relative, resolve, sep } from 'node:path';
10
10
 
11
- import { probeAcpAvailability } from './acp/probe.ts';
11
+ import {
12
+ cancelSignin,
13
+ getClaudeAuthStatus,
14
+ getInstallState,
15
+ isSigninInFlight,
16
+ startInstall,
17
+ startSignin,
18
+ } from './acp/login-state.ts';
19
+ import { probeAcpAvailabilityAuthed } from './acp/probe.ts';
12
20
  import { deleteChat, listChats, readChatMessages } from './acp/transcript.ts';
13
- import { type Api, ASSET_MAX_VIDEO_BYTES } from './api.ts';
21
+ import { type Api, ASSET_MAX_BYTES, ASSET_MAX_VIDEO_BYTES } from './api.ts';
22
+ import { ImportAssetError, importSvg, SVG_MAX_BYTES } from './bin/_import-asset.mjs';
23
+ import { ImportBrandError, importBrand } from './bin/_import-brand.mjs';
14
24
  import { buildCanvasModule } from './canvas-build.ts';
15
25
  import { canvasLibPath } from './canvas-lib-resolver.ts';
16
26
  import { TranspileError } from './canvas-pipeline.ts';
17
27
  import type { AiActivity } from './collab/ai-activity.ts';
18
28
  import type { Context } from './context.ts';
29
+ import { reloadConfig } from './context.ts';
30
+ import { probeSetupReadiness } from './design-setup-readiness.ts';
19
31
  import { type Format, isFormat, isScope, type Scope } from './exporters/index.ts';
20
32
  import { type ExportJobQueue, ExportQueueFullError } from './exporters/jobs.ts';
21
33
  import type { ActiveJsonShape } from './exporters/scope.ts';
34
+ import { generatedClipAnalysis } from './footage/schema.ts';
35
+ import { createFootageStore, FOOTAGE_MAX_BYTES } from './footage-store.ts';
36
+ import {
37
+ type AudioMatch,
38
+ type Candidate,
39
+ rankMatches,
40
+ sanitizeReuseText,
41
+ } from './generation/audio-library.ts';
42
+ import { localizeGenAsset } from './generation/download.ts';
43
+ import { type GenerationJobQueue, GenerationQueueFullError } from './generation/jobs.ts';
44
+ import {
45
+ configuredProviders,
46
+ deleteProviderKey,
47
+ getProviderKey,
48
+ isConfigured,
49
+ setProviderKey,
50
+ } from './generation/keys.ts';
51
+ import {
52
+ isTranscriptionProvider,
53
+ readTranscriptionProvider,
54
+ writeTranscriptionProvider,
55
+ } from './generation/prefs.ts';
56
+ import {
57
+ createAdapter,
58
+ getProviderDescriptor,
59
+ hasProvider,
60
+ listProviders,
61
+ } from './generation/registry.ts';
62
+ import { validateGenRequest } from './generation/types.ts';
63
+ import {
64
+ downloadWhisperModel,
65
+ getWhisperModel,
66
+ listWhisperModels,
67
+ removeWhisperModel,
68
+ } from './generation/whisper-models.ts';
22
69
  import { createGitEndpoints } from './git/endpoints.ts';
23
70
  import { gitShowFile } from './git/service.ts';
24
71
  import { createGitHubEndpoints } from './github/endpoints.ts';
25
72
  import type { Inspect } from './inspect.ts';
26
73
  import { canvasSlug, writeLocator } from './locator.ts';
27
- import { DEV_SERVER_ROOT } from './paths.ts';
74
+ import { DEV_SERVER_ROOT, MEDIA_DIR, STICKERS_DIR } from './paths.ts';
75
+ import { createPhotoStore, PHOTO_EDIT_MAX_BYTES } from './photo-store.ts';
28
76
  import { probeReadiness } from './readiness.ts';
29
77
  import { getRuntimeBundle, packageForSlug } from './runtime-bundle.ts';
30
78
  import { linkHub } from './sync/hub-link.ts';
79
+ import { readUiPrefs, type UiPrefs, writeUiPrefs } from './ui-prefs.ts';
31
80
  import { loadWhatsNew } from './whats-new.ts';
32
81
  import { isLoopbackHost } from './ws.ts';
33
82
 
@@ -104,6 +153,17 @@ function ext(p: string): string {
104
153
  * external page can no longer reframe the canvas. When `mainOrigin` is unknown
105
154
  * (tests / pre-boot) the directive is OMITTED rather than set to `'self'` —
106
155
  * `'self'` alone would forbid the legit cross-origin embed and blank the canvas.
156
+ *
157
+ * `connect-src` narrow exception (fix-photo-editor-followup-debt, Task 7):
158
+ * `@imgly/background-removal`'s model weights (~11-44 MB) fetch from IMG.LY's
159
+ * own CDN, `https://staticimgly.com`, on first client-side use — there is no
160
+ * self-hosting mechanism today (a separate, larger follow-up; see DDR-054's
161
+ * dated addendum for this decision, and `.ai/state/STATE.md` for tracking).
162
+ * The inference itself stays 100% client-side (pixels never leave the
163
+ * browser) — only the weight DOWNLOAD needs this one extra origin. This is
164
+ * the ONLY documented exception to the `connect-src 'self'` invariant above —
165
+ * do not read it as precedent for adding further hosts without the same
166
+ * scrutiny (exact hostname, no wildcard subdomain, a DDR record).
107
167
  */
108
168
  export function cspForCanvasShell(html: string, mainOrigin?: string): string {
109
169
  const hashes: string[] = [];
@@ -120,7 +180,7 @@ export function cspForCanvasShell(html: string, mainOrigin?: string): string {
120
180
  const directives = [
121
181
  "default-src 'none'",
122
182
  `script-src ${scriptSrc}`,
123
- "connect-src 'self'",
183
+ "connect-src 'self' https://staticimgly.com",
124
184
  "img-src 'self' data: blob:",
125
185
  // DDR-148 — a video-comp's <Video>/<Audio> loads media from the canvas
126
186
  // origin's own designRoot (assets/). Same inert-bytes trust as img-src
@@ -200,6 +260,24 @@ export function sameOriginWrite(req: Request): boolean {
200
260
  }
201
261
  }
202
262
 
263
+ /**
264
+ * CSRF guard for a key-bearing / side-effecting GET (Task 2.5 audio-search —
265
+ * ethical-hacker F1). A cross-site page can issue a `no-cors` GET to
266
+ * `127.0.0.1:<port>` that passes the `isLoopbackHost` DNS-rebind guard AND may
267
+ * omit `Origin` (browsers don't reliably stamp Origin on a simple cross-origin
268
+ * GET), so `sameOriginWrite` alone can't fend it off. Browsers DO always send
269
+ * the Fetch-Metadata `Sec-Fetch-Site` header, and non-browser clients (the CLI,
270
+ * curl, bun:test) never do — so reject any request whose `Sec-Fetch-Site` is
271
+ * present and is not `same-origin`/`none`. A missing header (CLI) is allowed.
272
+ * Prevents a cross-site page from driving the user's key-bearing provider
273
+ * fan-out + disk scan as a confused deputy.
274
+ */
275
+ export function sameOriginRead(req: Request): boolean {
276
+ const site = req.headers.get('sec-fetch-site');
277
+ if (!site) return true; // non-browser client (CLI / curl) → allow
278
+ return site === 'same-origin' || site === 'none';
279
+ }
280
+
203
281
  function safePathUnderRoot(reqUrl: string, repoRoot: string): string | null {
204
282
  let pathname: string;
205
283
  try {
@@ -600,8 +678,19 @@ export function createHttp(
600
678
  api: Api,
601
679
  inspect: Inspect,
602
680
  ai: AiActivity,
603
- exportJobs: ExportJobQueue
681
+ exportJobs: ExportJobQueue,
682
+ generateJobs: GenerationJobQueue
604
683
  ): Http {
684
+ // Task 2.7 (approach A) — in-flight whisper-model download state (one at a
685
+ // time), polled by the Settings "Download model" card via GET
686
+ // /_api/generate/whisper-model. Closure-scoped: one server, one download.
687
+ let whisperDownload: {
688
+ id: string;
689
+ received: number;
690
+ total: number;
691
+ error?: string;
692
+ } | null = null;
693
+
605
694
  // Cache invalidation — when canvas-lib changes, every cached canvas bundle
606
695
  // is stale because canvas-lib is inlined into each one via the resolver
607
696
  // plugin. Drop the whole cache so the next request rebuilds with the fresh
@@ -721,6 +810,12 @@ export function createHttp(
721
810
  // `routes` map (the dual-allowlist rule), so the untrusted canvas iframe origin
722
811
  // can never reach status/commit/publish/get-latest. http.ts owns the gating;
723
812
  // git/endpoints.ts owns the orchestration.
813
+ const photoStore = createPhotoStore(ctx);
814
+ // feature-footage-analysis-director — footage-analysis + EDL sidecars.
815
+ // MAIN-ORIGIN ONLY (privileged): the `/_api/footage` route is intentionally
816
+ // absent from CANVAS_SAFE_API + startCanvasServer's `routes` map, so the
817
+ // untrusted canvas iframe origin can never read/write the director's analysis.
818
+ const footageStore = createFootageStore(ctx);
724
819
  const gitApi = createGitEndpoints(ctx);
725
820
  // Phase 28 (E3) — `/_api/github/*`. Same dual-allowlist rule: main-origin only,
726
821
  // and every route is token-bearing (server-held keychain token via the loopback
@@ -777,8 +872,19 @@ export function createHttp(
777
872
  // MAIN-ORIGIN ONLY — absent from CANVAS_SAFE_API + startCanvasServer routes,
778
873
  // so the untrusted canvas iframe is 403'd. The native shell reads this to
779
874
  // decide between the enabled panel and the not-connected explainer.
780
- '/_api/acp/status': () =>
781
- Response.json(probeAcpAvailability(), { headers: { 'Cache-Control': 'no-store' } }),
875
+ // Explicit CSRF + DNS-rebind gate added alongside DDR-166: this route's
876
+ // `claudePath` field can now reveal a Maude-auto-installed binary's exact
877
+ // path, and this file's other ACP-adjacent routes all double-gate —
878
+ // security-review finding that this one had none at all, pre-existing
879
+ // but newly worth closing given what this diff makes it able to reveal.
880
+ '/_api/acp/status': async (req: Request) => {
881
+ if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
882
+ if (!isLoopbackHost(req.headers.get('host')))
883
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
884
+ return Response.json(await probeAcpAvailabilityAuthed(), {
885
+ headers: { 'Cache-Control': 'no-store' },
886
+ });
887
+ },
782
888
 
783
889
  // DDR-128 — first-open AI-editing readiness. Read-only probe of the AI-editing
784
890
  // dependency chain (claude CLI · maude CLI · maude marketplace + plugins in the
@@ -796,6 +902,100 @@ export function createHttp(
796
902
  return Response.json(await probeReadiness(), { headers: { 'Cache-Control': 'no-store' } });
797
903
  },
798
904
 
905
+ // DDR-166 plan, Phase 2 (T6) — design-setup readiness (project ✓ / design
906
+ // system ✓ / first canvas ✓ / brand assets ✓), distinct from the AI-editing
907
+ // dependency probe above. Read-only, cheap (a few existsSync/readdir calls
908
+ // scoped to designRoot) — same double gate as every other privileged
909
+ // read here even though it can't shell out, for consistency with the rest
910
+ // of this file's onboarding-surface routes. The quick-setup checklist
911
+ // (SetupChecklist.jsx) polls this.
912
+ '/_api/setup-readiness': async (req: Request) => {
913
+ if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
914
+ if (!isLoopbackHost(req.headers.get('host')))
915
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
916
+ return Response.json(await probeSetupReadiness(ctx), {
917
+ headers: { 'Cache-Control': 'no-store' },
918
+ });
919
+ },
920
+
921
+ // DDR-166 T0c (Addendum 2) — install Claude Code from Maude by running the
922
+ // exact official one-liner once, ephemerally (no persistent Maude-managed
923
+ // cache — that design was rejected on security review; see
924
+ // installClaudeCli()'s own doc comment for the fix). MAIN-ORIGIN ONLY,
925
+ // same CSRF + DNS-rebind double gate as every other privileged route.
926
+ '/_api/claude/install': (req: Request) => {
927
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
928
+ if (!sameOriginWrite(req))
929
+ return new Response('cross-origin write rejected', { status: 403 });
930
+ if (!isLoopbackHost(req.headers.get('host')))
931
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
932
+ // Fire-and-forget — the install can run past Bun's default 10s idle
933
+ // timeout on a slow network, so this returns as soon as the spawn is
934
+ // confirmed started; the panel polls /_api/claude/install-status for
935
+ // the result, the same shape /_api/claude/signin-status already uses.
936
+ const result = startInstall();
937
+ return Response.json(result, {
938
+ status: result.ok ? 200 : 409,
939
+ headers: { 'Cache-Control': 'no-store' },
940
+ });
941
+ },
942
+ // GET route with a real subprocess side effect downstream (signin-status
943
+ // spawns `claude auth status`) — sameOriginWrite alone doesn't gate a GET
944
+ // (browsers don't reliably stamp Origin on a simple cross-origin GET,
945
+ // per this file's own sameOriginRead doc comment, written for exactly
946
+ // this class of route after a prior ethical-hacker finding on
947
+ // audio-search). Security-review finding against the first cut of these
948
+ // two routes, which used the wrong guard.
949
+ '/_api/claude/install-status': (req: Request) => {
950
+ if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
951
+ if (!isLoopbackHost(req.headers.get('host')))
952
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
953
+ return Response.json(getInstallState(), { headers: { 'Cache-Control': 'no-store' } });
954
+ },
955
+
956
+ // DDR-166 T0d — sign in to Claude from Maude, driving the user's OWN `claude`
957
+ // CLI through its own `auth login`/`auth status` subcommands (never Maude's
958
+ // own OAuth, never a token Maude holds). MAIN-ORIGIN ONLY — absent from
959
+ // CANVAS_SAFE_API + startCanvasServer routes, same CSRF + DNS-rebind double
960
+ // gate as every other privileged POST route (DDR-088). Zero new Tauri
961
+ // commands (Decision 0) — this lives in the same Bun process as bridge.ts/
962
+ // env.ts, so it reuses the literal scrubAgentEnv/resolveClaudePath the chat
963
+ // spawn already uses.
964
+ '/_api/claude/signin': (req: Request) => {
965
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
966
+ if (!sameOriginWrite(req))
967
+ return new Response('cross-origin write rejected', { status: 403 });
968
+ if (!isLoopbackHost(req.headers.get('host')))
969
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
970
+ const result = startSignin();
971
+ return Response.json(result, {
972
+ status: result.ok ? 200 : 409,
973
+ headers: { 'Cache-Control': 'no-store' },
974
+ });
975
+ },
976
+ '/_api/claude/signin-cancel': (req: Request) => {
977
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
978
+ if (!sameOriginWrite(req))
979
+ return new Response('cross-origin write rejected', { status: 403 });
980
+ if (!isLoopbackHost(req.headers.get('host')))
981
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
982
+ cancelSignin();
983
+ return Response.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
984
+ },
985
+ // Read-only poll target — the panel calls this (or /_api/preflight, which
986
+ // folds the same status in) on an interval while a sign-in is in flight.
987
+ // Narrowed fields only (never email/orgId/orgName — see login-state.ts).
988
+ '/_api/claude/signin-status': async (req: Request) => {
989
+ if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
990
+ if (!isLoopbackHost(req.headers.get('host')))
991
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
992
+ const status = await getClaudeAuthStatus();
993
+ return Response.json(
994
+ { ...status, inFlight: isSigninInFlight() },
995
+ { headers: { 'Cache-Control': 'no-store' } }
996
+ );
997
+ },
998
+
799
999
  // Phase 31 (DDR-123) — `/design:chat` focus hook. `maude design chat-open`
800
1000
  // POSTs here; we emit a bus event the shell turns into "open the Assistant
801
1001
  // panel" (app.jsx, native-only). MAIN-ORIGIN ONLY (off the canvas allowlist).
@@ -836,8 +1036,8 @@ export function createHttp(
836
1036
  // expands to so Claude can Read it. MAIN-ORIGIN ONLY: sameOriginWrite CSRF gate
837
1037
  // on POST + deliberately absent from CANVAS_SAFE_API + startCanvasServer routes,
838
1038
  // so the untrusted canvas iframe is 403'd. The disk caps live in
839
- // api.saveChatAttachment (magic-byte sniff / 10 MB / content-addressed name /
840
- // session write budget).
1039
+ // api.saveChatAttachment (magic-byte sniff / ASSET_MAX_BYTES / content-
1040
+ // addressed name / session write budget).
841
1041
  //
842
1042
  // GET ?name=<sha8>.<ext> serves the pasted image back to the chat feed
843
1043
  // (thumbnail + lightbox). The name is regex-allowlisted in
@@ -864,9 +1064,12 @@ export function createHttp(
864
1064
  if (!isLoopbackHost(req.headers.get('host')))
865
1065
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
866
1066
  const declared = Number(req.headers.get('content-length') || '0');
867
- if (Number.isFinite(declared) && declared > 10 * 1024 * 1024) {
1067
+ if (Number.isFinite(declared) && declared > ASSET_MAX_BYTES) {
868
1068
  return Response.json(
869
- { ok: false, error: 'attachment exceeds the 10 MB cap' },
1069
+ {
1070
+ ok: false,
1071
+ error: `attachment exceeds the ${Math.round(ASSET_MAX_BYTES / (1024 * 1024))} MB cap`,
1072
+ },
870
1073
  { status: 413, headers: { 'Cache-Control': 'no-store' } }
871
1074
  );
872
1075
  }
@@ -969,6 +1172,87 @@ export function createHttp(
969
1172
  return new Response('Method not allowed', { status: 405 });
970
1173
  },
971
1174
 
1175
+ '/_api/photo-edit': async (req: Request) => {
1176
+ // feature-photo-editor (Stage C, Task 8) — non-destructive PhotoEdit sidecar.
1177
+ // GET ?asset=<sha8 | assets/<sha8>.<ext>> → stored PhotoEdit or {}
1178
+ // PUT/POST ?asset=<...> body { …PhotoEdit } → validated + persisted
1179
+ // CANVAS-SAFE (listed in BOTH CANVAS_SAFE_API below AND startCanvasServer's
1180
+ // `routes` map — Bun matches `routes` before `fetch`, so a one-list entry
1181
+ // 404s from the canvas iframe, the DDR-088 rollout bug). Reached from the
1182
+ // canvas origin by <PhotoLayer> (GET) and the headless bg-remove harness
1183
+ // (PUT), so NO sameOriginWrite (it would block the legit canvas origin);
1184
+ // the photo-store cap stack (sha8 validate + containment + validatePhotoEdit
1185
+ // + size cap) is the load-bearing mitigation. loopback-Host gates DNS-
1186
+ // rebinding on every method.
1187
+ if (!isLoopbackHost(req.headers.get('host')))
1188
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1189
+ const asset = new URL(req.url).searchParams.get('asset');
1190
+ if (req.method === 'GET') {
1191
+ const edit = await photoStore.getPhotoEdit(asset);
1192
+ return Response.json(edit ?? {}, { headers: { 'Cache-Control': 'no-store' } });
1193
+ }
1194
+ if (req.method === 'PUT' || req.method === 'POST') {
1195
+ const body = await readJson<unknown>(req, PHOTO_EDIT_MAX_BYTES + 1024);
1196
+ if (body == null) return new Response('body required', { status: 400 });
1197
+ const result = await photoStore.savePhotoEdit(asset, body);
1198
+ if (!result.ok) {
1199
+ return Response.json(
1200
+ { ok: false, error: result.error },
1201
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1202
+ );
1203
+ }
1204
+ return Response.json(
1205
+ { ok: true, path: result.path, edit: result.edit },
1206
+ { headers: { 'Cache-Control': 'no-store' } }
1207
+ );
1208
+ }
1209
+ return new Response('Method not allowed', { status: 405 });
1210
+ },
1211
+
1212
+ '/_api/footage': async (req: Request) => {
1213
+ // feature-footage-analysis-director (Task 2) — FootageAnalysis + EDL sidecars.
1214
+ // GET ?asset=<sha8|assets/…> → stored FootageAnalysis or {}
1215
+ // GET ?slug=<cut-slug> → stored Edl or {}
1216
+ // PUT/POST ?asset=<…> body {FootageAnalysis} → validated + persisted
1217
+ // PUT/POST ?slug=<…> body {Edl} → validated + persisted
1218
+ // MAIN-ORIGIN ONLY (privileged — NOT in CANVAS_SAFE_API / startCanvasServer
1219
+ // routes): written by the footage-analyst / footage-director agents over
1220
+ // loopback, never the untrusted canvas iframe. A GET from the canvas origin
1221
+ // 403s at the gate (canvas-origin-gate.test.ts). loopback-Host gates
1222
+ // DNS-rebinding on every method; the footage-store cap stack (sha8/slug
1223
+ // validate + containment + validate{FootageAnalysis,Edl} + size cap) is the
1224
+ // load-bearing mitigation for the caller-derived write path.
1225
+ if (!isLoopbackHost(req.headers.get('host')))
1226
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1227
+ const params = new URL(req.url).searchParams;
1228
+ const asset = params.get('asset');
1229
+ const slug = params.get('slug');
1230
+ if (!asset && !slug) return new Response('asset or slug required', { status: 400 });
1231
+ const isEdl = !asset && !!slug;
1232
+
1233
+ if (req.method === 'GET') {
1234
+ const data = isEdl
1235
+ ? await footageStore.getEdl(slug)
1236
+ : await footageStore.getAnalysis(asset);
1237
+ return Response.json(data ?? {}, { headers: { 'Cache-Control': 'no-store' } });
1238
+ }
1239
+ if (req.method === 'PUT' || req.method === 'POST') {
1240
+ const body = await readJson<unknown>(req, FOOTAGE_MAX_BYTES + 1024);
1241
+ if (body == null) return new Response('body required', { status: 400 });
1242
+ const result = isEdl
1243
+ ? await footageStore.saveEdl(slug, body)
1244
+ : await footageStore.saveAnalysis(asset, body);
1245
+ if (!result.ok) {
1246
+ return Response.json(
1247
+ { ok: false, error: result.error },
1248
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1249
+ );
1250
+ }
1251
+ return Response.json({ ok: true, ...result }, { headers: { 'Cache-Control': 'no-store' } });
1252
+ }
1253
+ return new Response('Method not allowed', { status: 405 });
1254
+ },
1255
+
972
1256
  '/_api/canvas-source': async (req: Request) => {
973
1257
  // DDR-148 — read-only raw .tsx source for the Timeline panel's sequence/
974
1258
  // keyframe parser. GET ?file=<canvas rel or slug> → { source }.
@@ -1475,10 +1759,13 @@ export function createHttp(
1475
1759
  return new Response('cross-origin write rejected', { status: 403 });
1476
1760
  if (!isLoopbackHost(req.headers.get('host')))
1477
1761
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1478
- const body = await readJson<{ canvas?: unknown; id?: unknown; text?: unknown }>(
1479
- req,
1480
- 16 * 1024
1481
- );
1762
+ const body = await readJson<{
1763
+ canvas?: unknown;
1764
+ id?: unknown;
1765
+ text?: unknown;
1766
+ occurrence?: unknown;
1767
+ before?: unknown;
1768
+ }>(req, 16 * 1024);
1482
1769
  if (!body) return new Response('body required', { status: 400 });
1483
1770
  const result = await api.editText(body);
1484
1771
  if (!result.ok) {
@@ -1852,9 +2139,11 @@ export function createHttp(
1852
2139
  },
1853
2140
 
1854
2141
  '/_api/insert-element': async (req: Request) => {
1855
- // Stage I — insert a synthesized div/text/image relative to `refId`. POST
1856
- // { canvas, refId, position, kind, src?, refIndex? } api.insertElementOp.
1857
- // Returns the new element's post-transpile id so the shell can select it.
2142
+ // Stage I — insert a synthesized div/text/image relative to `refId`, OR —
2143
+ // empty-artboard fallback as a direct child of `artboardId` (exactly one
2144
+ // of the two). POST { canvas, refId|artboardId, position, kind, src?,
2145
+ // refIndex? } → api.insertElementOp. Returns the new element's
2146
+ // post-transpile id so the shell can select it.
1858
2147
  // Whole-file undo seq. MAIN-ORIGIN ONLY (absent from both allowlists);
1859
2148
  // sameOriginWrite + loopback-Host gated. An `image` src is contained to
1860
2149
  // assets/ in the engine (no remote hotlink / ../ / scheme).
@@ -1866,6 +2155,7 @@ export function createHttp(
1866
2155
  const body = await readJson<{
1867
2156
  canvas?: unknown;
1868
2157
  refId?: unknown;
2158
+ artboardId?: unknown;
1869
2159
  position?: unknown;
1870
2160
  kind?: unknown;
1871
2161
  src?: unknown;
@@ -1961,6 +2251,24 @@ export function createHttp(
1961
2251
  );
1962
2252
  },
1963
2253
 
2254
+ '/_api/stickers': async (req: Request) => {
2255
+ // Phase 4 (feature-whiteboard-annotation-improvements) — the bundled
2256
+ // sticker catalogue for the StickerPicker. GET → { packs:[{slug, name,
2257
+ // author, attributionUrl, license, stickers:[{file,keywords,url}]}] }.
2258
+ // MAIN-ORIGIN ONLY: the picker is a shell dialog, same posture as
2259
+ // /_api/assets above — absent from CANVAS_SAFE_API + startCanvasServer
2260
+ // routes (dual-allowlist, DDR-054). Loopback-Host gated (DNS-rebinding);
2261
+ // read-only, so no sameOriginWrite (GET).
2262
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
2263
+ if (!isLoopbackHost(req.headers.get('host')))
2264
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2265
+ const r = await api.listStickers();
2266
+ return Response.json(
2267
+ { ok: true, packs: r.packs },
2268
+ { headers: { 'Cache-Control': 'no-store' } }
2269
+ );
2270
+ },
2271
+
1964
2272
  '/_api/edit-scope': async (req: Request) => {
1965
2273
  // Stage H (feature-element-editing-robustness) — the INV-3 predictability
1966
2274
  // verdict. GET ?canvas&id&rendered → { ok, scope:'local'|'shared',
@@ -2017,6 +2325,71 @@ export function createHttp(
2017
2325
  );
2018
2326
  },
2019
2327
 
2328
+ '/_api/set-artboard-hug': async (req: Request) => {
2329
+ // Artboard "hug height" default — Hug ⇄ Fixed toggle in the CSS panel.
2330
+ // POST { canvas, artboardId, fixed, freezeHeight? } → api.setArtboardHugOp
2331
+ // (writes/removes the bare `fixed` prop on <DCArtboard id="…">; freezeHeight
2332
+ // optionally pins `height` at the board's current measured size so pinning
2333
+ // to Fixed doesn't snap the box). Whole-file undo seq. MAIN-ORIGIN ONLY;
2334
+ // sameOriginWrite + loopback-Host gated, mirrors /_api/resize-artboard.
2335
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2336
+ if (!sameOriginWrite(req))
2337
+ return new Response('cross-origin write rejected', { status: 403 });
2338
+ if (!isLoopbackHost(req.headers.get('host')))
2339
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2340
+ const body = await readJson<{
2341
+ canvas?: unknown;
2342
+ artboardId?: unknown;
2343
+ fixed?: unknown;
2344
+ freezeHeight?: unknown;
2345
+ }>(req, 8 * 1024);
2346
+ if (!body) return new Response('body required', { status: 400 });
2347
+ const result = await api.setArtboardHugOp(body);
2348
+ if (!result.ok) {
2349
+ return Response.json(
2350
+ { ok: false, error: result.error },
2351
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
2352
+ );
2353
+ }
2354
+ return Response.json(
2355
+ { ok: true, seq: result.seq },
2356
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
2357
+ );
2358
+ },
2359
+
2360
+ '/_api/set-artboard-style': async (req: Request) => {
2361
+ // Artboard "more settings" — background / padding / layout / gap, applied
2362
+ // to .dc-artboard-body. POST { canvas, artboardId, background?, padding?,
2363
+ // layout?, gap? } (each key: value to set, `null` to reset, absent to leave
2364
+ // untouched) → api.setArtboardStyleOp. Whole-file undo seq. MAIN-ORIGIN
2365
+ // ONLY; sameOriginWrite + loopback-Host gated, mirrors /_api/resize-artboard.
2366
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2367
+ if (!sameOriginWrite(req))
2368
+ return new Response('cross-origin write rejected', { status: 403 });
2369
+ if (!isLoopbackHost(req.headers.get('host')))
2370
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2371
+ const body = await readJson<{
2372
+ canvas?: unknown;
2373
+ artboardId?: unknown;
2374
+ background?: unknown;
2375
+ padding?: unknown;
2376
+ layout?: unknown;
2377
+ gap?: unknown;
2378
+ }>(req, 8 * 1024);
2379
+ if (!body) return new Response('body required', { status: 400 });
2380
+ const result = await api.setArtboardStyleOp(body);
2381
+ if (!result.ok) {
2382
+ return Response.json(
2383
+ { ok: false, error: result.error },
2384
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
2385
+ );
2386
+ }
2387
+ return Response.json(
2388
+ { ok: true, seq: result.seq },
2389
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
2390
+ );
2391
+ },
2392
+
2020
2393
  '/_api/delete-artboard': async (req: Request) => {
2021
2394
  // Delete an artboard by its `id` prop (Backspace / context-menu on a frame).
2022
2395
  // POST { canvas, artboardId } → api.deleteArtboardOp (removes the
@@ -2078,6 +2451,140 @@ export function createHttp(
2078
2451
  );
2079
2452
  },
2080
2453
 
2454
+ // DDR-167 (Phase 3 / T10) — hardened LOCAL-file SVG ingestion for the
2455
+ // in-app Brand-upload panel (T12). Privileged + main-origin-only (Decision
2456
+ // 4): absent from CANVAS_SAFE_API + startCanvasServer's routes map — the
2457
+ // untrusted canvas iframe must never reach it. Raw-bytes body only, never
2458
+ // multipart (this dev server has no multipart parsing anywhere). The
2459
+ // `X-Import-Kind` header is a dispatch HINT ONLY — never trusted for the
2460
+ // security-relevant decision; the sanitize pipeline's own pre-parse
2461
+ // structural sniff decides. PDF import is wired but not yet available —
2462
+ // see the DDR's addendum on why the planned rasterization mechanism
2463
+ // (headless-Chromium navigating a file:// PDF) doesn't render content
2464
+ // under browser automation; a `kind: pdf` request 501s naming that.
2465
+ '/_api/import-asset': async (req: Request) => {
2466
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2467
+ if (!sameOriginWrite(req))
2468
+ return new Response('cross-origin write rejected', { status: 403 });
2469
+ if (!isLoopbackHost(req.headers.get('host')))
2470
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2471
+ const kind = (req.headers.get('x-import-kind') || 'svg').toLowerCase();
2472
+ if (kind !== 'svg') {
2473
+ return Response.json(
2474
+ {
2475
+ ok: false,
2476
+ error: 'PDF import is not yet available (DDR-167 addendum) — only SVG import is wired.',
2477
+ },
2478
+ { status: 501, headers: { 'Cache-Control': 'no-store' } }
2479
+ );
2480
+ }
2481
+ const declared = Number(req.headers.get('content-length') || '0');
2482
+ if (Number.isFinite(declared) && declared > SVG_MAX_BYTES) {
2483
+ return Response.json(
2484
+ { ok: false, error: `SVG exceeds the ${SVG_MAX_BYTES / (1024 * 1024)} MB cap` },
2485
+ { status: 413, headers: { 'Cache-Control': 'no-store' } }
2486
+ );
2487
+ }
2488
+ if (!req.body) return new Response('empty body', { status: 400 });
2489
+ let svgText: string;
2490
+ try {
2491
+ svgText = await new Response(req.body).text();
2492
+ } catch {
2493
+ return new Response('could not read body', { status: 400 });
2494
+ }
2495
+ try {
2496
+ const result = await importSvg(svgText, {
2497
+ root: ctx.paths.repoRoot,
2498
+ designRootRel: ctx.paths.designRel,
2499
+ });
2500
+ return Response.json(
2501
+ { ok: true, path: result.path },
2502
+ { status: 201, headers: { 'Cache-Control': 'no-store' } }
2503
+ );
2504
+ } catch (err) {
2505
+ const status =
2506
+ err instanceof ImportAssetError
2507
+ ? err.code === 3
2508
+ ? 400
2509
+ : err.code === 5
2510
+ ? 415
2511
+ : err.code === 6
2512
+ ? 500
2513
+ : 400
2514
+ : 500;
2515
+ const message = err instanceof Error ? err.message : String(err);
2516
+ return Response.json(
2517
+ { ok: false, error: message },
2518
+ { status, headers: { 'Cache-Control': 'no-store' } }
2519
+ );
2520
+ }
2521
+ },
2522
+
2523
+ // DDR-173 (Phase 3 / T12) — brand-file typed-cue extraction for the
2524
+ // in-app Brand-upload panel. Privileged + main-origin-only, same posture
2525
+ // as /_api/import-asset: absent from CANVAS_SAFE_API + startCanvasServer's
2526
+ // routes map. Takes a SERVER-GENERATED asset path from a prior
2527
+ // /_api/import-asset response — never a client-supplied filesystem path
2528
+ // (DDR-173 Decision 2: extraction operates only on DDR-167's already-
2529
+ // gated, already-sanitized output, no parallel ungated read). The path is
2530
+ // still realpath-contained + charset-asserted here, never trusted blindly
2531
+ // just because it LOOKS server-shaped.
2532
+ '/_api/import-brand': async (req: Request) => {
2533
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2534
+ if (!sameOriginWrite(req))
2535
+ return new Response('cross-origin write rejected', { status: 403 });
2536
+ if (!isLoopbackHost(req.headers.get('host')))
2537
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2538
+ let body: { assetPath?: unknown };
2539
+ try {
2540
+ body = (await req.json()) as { assetPath?: unknown };
2541
+ } catch {
2542
+ return new Response('invalid JSON body', { status: 400 });
2543
+ }
2544
+ const assetPath = body.assetPath;
2545
+ if (typeof assetPath !== 'string' || !/^assets\/[a-z0-9]{8}\.svg$/.test(assetPath)) {
2546
+ return Response.json(
2547
+ { ok: false, error: 'assetPath must be a server-generated assets/<sha8>.svg path' },
2548
+ { status: 400, headers: { 'Cache-Control': 'no-store' } }
2549
+ );
2550
+ }
2551
+ const designAbs = resolve(ctx.paths.repoRoot, ctx.paths.designRel);
2552
+ const resolvedAsset = resolve(designAbs, assetPath);
2553
+ if (!resolvedAsset.startsWith(resolve(designAbs, 'assets') + sep)) {
2554
+ return Response.json(
2555
+ { ok: false, error: 'assetPath escapes the assets directory' },
2556
+ { status: 400, headers: { 'Cache-Control': 'no-store' } }
2557
+ );
2558
+ }
2559
+ try {
2560
+ const result = await importBrand({
2561
+ sanitizedSvgPath: resolvedAsset,
2562
+ root: ctx.paths.repoRoot,
2563
+ designRootRel: ctx.paths.designRel,
2564
+ });
2565
+ return Response.json(
2566
+ { ok: true, ...result },
2567
+ { status: 201, headers: { 'Cache-Control': 'no-store' } }
2568
+ );
2569
+ } catch (err) {
2570
+ const status =
2571
+ err instanceof ImportBrandError
2572
+ ? err.code === 3
2573
+ ? 400
2574
+ : err.code === 4
2575
+ ? 404
2576
+ : err.code === 6
2577
+ ? 500
2578
+ : 400
2579
+ : 500;
2580
+ const message = err instanceof Error ? err.message : String(err);
2581
+ return Response.json(
2582
+ { ok: false, error: message },
2583
+ { status, headers: { 'Cache-Control': 'no-store' } }
2584
+ );
2585
+ }
2586
+ },
2587
+
2081
2588
  '/_api/export-history': async (req: Request) => {
2082
2589
  // Phase 6.5 T10 — read-only recent-exports feed for the dialog's
2083
2590
  // Recent tab. Writes happen as a side-effect of job completion
@@ -2212,6 +2719,433 @@ export function createHttp(
2212
2719
  });
2213
2720
  },
2214
2721
 
2722
+ // feature-ai-media-generation (DDR-16x) — the background AI-media generation
2723
+ // job queue. The privileged sibling of /_api/export-jobs: MAIN-ORIGIN ONLY
2724
+ // (absent from CANVAS_SAFE_API + startCanvasServer's routes — the untrusted
2725
+ // canvas iframe must never reach a route that resolves a provider KEY and
2726
+ // makes an outbound provider call; it sees only the produced /assets/<sha8>).
2727
+ // loopback-Host gated on every method; the mutating POST is additionally
2728
+ // sameOriginWrite CSRF-gated. See DDR-16x + the canvas-origin-gate test.
2729
+ '/_api/generate-jobs': async (req: Request) => {
2730
+ if (!isLoopbackHost(req.headers.get('host')))
2731
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2732
+ if (req.method === 'GET') {
2733
+ return Response.json(
2734
+ { jobs: generateJobs.list() },
2735
+ { headers: { 'Cache-Control': 'no-store' } }
2736
+ );
2737
+ }
2738
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2739
+ if (!sameOriginWrite(req))
2740
+ return new Response('cross-origin write rejected', { status: 403 });
2741
+ const body = await readJson<Record<string, unknown>>(req, 64 * 1024);
2742
+ if (!body) return new Response('body required', { status: 400 });
2743
+ const check = validateGenRequest(body);
2744
+ if (!check.ok) return new Response(check.errors.join('; '), { status: 400 });
2745
+ const genReq = body as unknown as import('./generation/types.ts').GenRequest;
2746
+ if (!hasProvider(genReq.provider))
2747
+ return new Response(`unknown provider: ${genReq.provider}`, { status: 400 });
2748
+ const descriptor = getProviderDescriptor(genReq.provider);
2749
+ if (descriptor && !descriptor.modalities.includes(genReq.modality))
2750
+ return new Response(`provider ${genReq.provider} cannot do ${genReq.modality}`, {
2751
+ status: 400,
2752
+ });
2753
+
2754
+ try {
2755
+ const { id } = generateJobs.enqueue({
2756
+ provider: genReq.provider,
2757
+ modality: genReq.modality,
2758
+ model: genReq.model,
2759
+ // The job's work: resolve the key AT RUN TIME (never cached), build the
2760
+ // per-request adapter context with the saveAsset-backed localizer, and
2761
+ // submit → result → localize each produced asset into assets/<sha8>.
2762
+ run: async (signal) => {
2763
+ const apiKey = await getProviderKey(genReq.provider);
2764
+ const adapter = createAdapter(genReq.provider, {
2765
+ apiKey,
2766
+ signal,
2767
+ localize: (asset) => localizeGenAsset(asset, { saveAsset: api.saveAsset }),
2768
+ // Task 1.2 — source-asset access for the maskless-edit / i2v flows
2769
+ // (Nano Banana reads the source into an inlineData part). Contained
2770
+ // to assets/ host-side; the adapter never touches the filesystem.
2771
+ readSourceAsset: (rel) => api.readAssetBytes(rel),
2772
+ });
2773
+ const job = await adapter.submit(genReq);
2774
+ const result = await job.result();
2775
+ const assets: string[] = [];
2776
+ for (const asset of result.assets) {
2777
+ // Task 2.6 — a CLOUD STT result (ElevenLabs Scribe / Groq) is
2778
+ // caption TEXT, not media: it lands as a `assets/<sha8>.srt|.vtt`
2779
+ // sidecar next to its source (the SAME path the local whisper verb
2780
+ // writes), never through the magic-byte media store. Everything
2781
+ // else (image/video/audio) localizes into the content-addressed
2782
+ // media store as before.
2783
+ if (asset.kind === 'transcription' && typeof asset.text === 'string') {
2784
+ if (!genReq.sourceAsset)
2785
+ throw new Error('transcription result has no source asset to sidecar');
2786
+ const fmt = asset.mime === 'text/vtt' ? 'vtt' : 'srt';
2787
+ const saved = await api.writeCaptionSidecar(genReq.sourceAsset, fmt, asset.text);
2788
+ if (!saved.ok || !saved.path)
2789
+ throw new Error(`caption sidecar write failed: ${saved.error ?? 'unknown'}`);
2790
+ assets.push(saved.path);
2791
+ } else {
2792
+ assets.push(await localizeGenAsset(asset, { saveAsset: api.saveAsset }));
2793
+ }
2794
+ }
2795
+ // Task 2.5 — record the AUDIO INTENT next to a generated audio asset
2796
+ // (the durable, semantic reuse index) so a later near-identical
2797
+ // request can offer the existing track instead of paying again.
2798
+ if (genReq.modality === 'audio' && assets.length > 0) {
2799
+ const kind = (genReq.params as Record<string, unknown> | undefined)?.audioKind;
2800
+ await api.writeAudioIntent(assets[0], {
2801
+ kind: typeof kind === 'string' ? kind : undefined,
2802
+ prompt: genReq.prompt,
2803
+ provider: genReq.provider,
2804
+ model: genReq.model,
2805
+ });
2806
+ }
2807
+ // Task 3.2 — a generated VIDEO clip gets a provenance FootageAnalysis
2808
+ // stub next to it (assets/<sha8>.footage.json), so it is immediately
2809
+ // KNOWN to the footage/reel pipeline: the director sees the clip is
2810
+ // synthetic (the `ai-generated` tag) and what it was made for (the
2811
+ // prompt), and the footage-analyst later fills the real shots. A
2812
+ // sidecar hiccup must NOT lose the (expensive) clip — best-effort.
2813
+ if (genReq.modality === 'video' && assets.length > 0) {
2814
+ try {
2815
+ await footageStore.saveAnalysis(
2816
+ assets[0],
2817
+ generatedClipAnalysis(assets[0], {
2818
+ provider: genReq.provider,
2819
+ model: genReq.model,
2820
+ prompt: genReq.prompt,
2821
+ })
2822
+ );
2823
+ } catch {
2824
+ /* provenance-only — the clip already landed in assets/ */
2825
+ }
2826
+ }
2827
+ return { assets, usage: result.usage };
2828
+ },
2829
+ });
2830
+ // Don't await the run — the job's own status (GET + the generate:job WS
2831
+ // push) is the completion signal. A failure is recorded on the job
2832
+ // record, so swallow the rejection here to avoid an unhandled rejection.
2833
+ return Response.json(
2834
+ { jobId: id },
2835
+ { status: 202, headers: { 'Cache-Control': 'no-store' } }
2836
+ );
2837
+ } catch (err) {
2838
+ if (err instanceof GenerationQueueFullError)
2839
+ return new Response(err.message, { status: 429 });
2840
+ throw err;
2841
+ }
2842
+ },
2843
+
2844
+ // feature-ai-media-generation (DDR-16x) — inert provider catalogue for the
2845
+ // Settings panel + generate dialog: descriptors (id/label/modalities/notes/
2846
+ // keyUrl) + presence-only `configured` flags. NO secret ever crosses here.
2847
+ // MAIN-ORIGIN ONLY, loopback-gated.
2848
+ '/_api/generate/providers': async (req: Request) => {
2849
+ if (!isLoopbackHost(req.headers.get('host')))
2850
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2851
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
2852
+ const configured = new Set(configuredProviders());
2853
+ return Response.json(
2854
+ {
2855
+ providers: listProviders().map((d) => ({ ...d, configured: configured.has(d.id) })),
2856
+ },
2857
+ { headers: { 'Cache-Control': 'no-store' } }
2858
+ );
2859
+ },
2860
+
2861
+ // feature-ai-media-generation (DDR-16x) — key management. WRITE-ONLY from the
2862
+ // main origin: POST sets a key, DELETE removes one, GET reports presence only
2863
+ // ({configured:[...]}) — a key value is NEVER echoed back (mirrors
2864
+ // github_is_signed_in). MAIN-ORIGIN ONLY, loopback + sameOriginWrite gated.
2865
+ '/_api/generate/keys': async (req: Request) => {
2866
+ if (!isLoopbackHost(req.headers.get('host')))
2867
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2868
+ if (req.method === 'GET') {
2869
+ return Response.json(
2870
+ { configured: configuredProviders() },
2871
+ { headers: { 'Cache-Control': 'no-store' } }
2872
+ );
2873
+ }
2874
+ if (!sameOriginWrite(req))
2875
+ return new Response('cross-origin write rejected', { status: 403 });
2876
+ const body = await readJson<{ provider?: unknown; key?: unknown }>(req, 16 * 1024);
2877
+ if (!body || typeof body.provider !== 'string')
2878
+ return new Response('provider required', { status: 400 });
2879
+ if (!hasProvider(body.provider))
2880
+ return new Response(`unknown provider: ${body.provider}`, { status: 400 });
2881
+ try {
2882
+ if (req.method === 'DELETE') {
2883
+ deleteProviderKey(body.provider);
2884
+ return Response.json({ configured: false }, { headers: { 'Cache-Control': 'no-store' } });
2885
+ }
2886
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2887
+ if (typeof body.key !== 'string' || !body.key.trim())
2888
+ return new Response('key required', { status: 400 });
2889
+ setProviderKey(body.provider, body.key);
2890
+ // Presence flag only — never the key. Deliberately does not read the
2891
+ // value back so a proxy/log between here and the client can't capture it.
2892
+ return Response.json(
2893
+ { configured: isConfigured(body.provider) },
2894
+ { headers: { 'Cache-Control': 'no-store' } }
2895
+ );
2896
+ } catch (err) {
2897
+ return new Response(err instanceof Error ? err.message : 'key write failed', {
2898
+ status: 400,
2899
+ });
2900
+ }
2901
+ },
2902
+
2903
+ // feature-ai-media-generation (Task 2.6, DDR-164) — NON-SECRET generation
2904
+ // preferences (the transcription-engine choice). GET returns the current
2905
+ // choice; POST persists it into `.design/config.json` and hot-reloads the
2906
+ // config so the next transcribe picks it up without a restart. MAIN-ORIGIN
2907
+ // ONLY, loopback + sameOriginWrite gated. NEVER touches a key — that's the
2908
+ // separate /_api/generate/keys route.
2909
+ '/_api/generate/prefs': async (req: Request) => {
2910
+ if (!isLoopbackHost(req.headers.get('host')))
2911
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2912
+ if (req.method === 'GET') {
2913
+ return Response.json(
2914
+ { transcriptionProvider: readTranscriptionProvider(ctx.paths.repoRoot) },
2915
+ { headers: { 'Cache-Control': 'no-store' } }
2916
+ );
2917
+ }
2918
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2919
+ if (!sameOriginWrite(req))
2920
+ return new Response('cross-origin write rejected', { status: 403 });
2921
+ const body = await readJson<{ transcriptionProvider?: unknown }>(req, 4 * 1024);
2922
+ const provider = body?.transcriptionProvider;
2923
+ if (!isTranscriptionProvider(provider))
2924
+ return new Response('transcriptionProvider must be whisper|elevenlabs|groq', {
2925
+ status: 400,
2926
+ });
2927
+ try {
2928
+ await writeTranscriptionProvider(ctx.paths.repoRoot, provider);
2929
+ reloadConfig(ctx);
2930
+ return Response.json(
2931
+ { transcriptionProvider: provider },
2932
+ { headers: { 'Cache-Control': 'no-store' } }
2933
+ );
2934
+ } catch (err) {
2935
+ return new Response(err instanceof Error ? err.message : 'prefs write failed', {
2936
+ status: 400,
2937
+ });
2938
+ }
2939
+ },
2940
+
2941
+ // feature-unified-settings-modal — NON-SECRET UI / view preferences (theme +
2942
+ // the Canvas & View toggles), persisted to `~/.config/maude/prefs.json` so
2943
+ // they survive a restart and a cleared localStorage. GET returns the merged
2944
+ // prefs; POST merge-patches only the provided keys. MAIN-ORIGIN ONLY, loopback
2945
+ // + sameOriginWrite gated (never reachable from the untrusted canvas origin).
2946
+ '/_api/ui-prefs': async (req: Request) => {
2947
+ if (!isLoopbackHost(req.headers.get('host')))
2948
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2949
+ if (req.method === 'GET') {
2950
+ return Response.json(readUiPrefs(), { headers: { 'Cache-Control': 'no-store' } });
2951
+ }
2952
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2953
+ if (!sameOriginWrite(req))
2954
+ return new Response('cross-origin write rejected', { status: 403 });
2955
+ const body = await readJson<Record<string, unknown>>(req, 4 * 1024);
2956
+ if (!body || typeof body !== 'object')
2957
+ return new Response('body must be a JSON object', { status: 400 });
2958
+ // Build a clean patch — only well-typed known keys pass through, so a bad
2959
+ // field is rejected rather than silently resetting a stored value.
2960
+ const patch: Partial<UiPrefs> = {};
2961
+ if ('theme' in body) {
2962
+ if (body.theme !== 'light' && body.theme !== 'dark')
2963
+ return new Response('theme must be light|dark', { status: 400 });
2964
+ patch.theme = body.theme;
2965
+ }
2966
+ for (const k of ['minimap', 'zoom', 'annotations', 'autoOpenInspector'] as const) {
2967
+ if (k in body) {
2968
+ if (typeof body[k] !== 'boolean')
2969
+ return new Response(`${k} must be a boolean`, { status: 400 });
2970
+ patch[k] = body[k] as boolean;
2971
+ }
2972
+ }
2973
+ if ('layersMode' in body) {
2974
+ if (body.layersMode !== 'separate' && body.layersMode !== 'in-inspector')
2975
+ return new Response('layersMode must be separate|in-inspector', { status: 400 });
2976
+ patch.layersMode = body.layersMode;
2977
+ }
2978
+ if ('panelSides' in body) {
2979
+ const ps = body.panelSides;
2980
+ if (!ps || typeof ps !== 'object' || Array.isArray(ps))
2981
+ return new Response('panelSides must be an object', { status: 400 });
2982
+ for (const v of Object.values(ps as Record<string, unknown>)) {
2983
+ if (v !== 'left' && v !== 'right')
2984
+ return new Response('panelSides values must be left|right', { status: 400 });
2985
+ }
2986
+ // writeUiPrefs → coerce keeps only known ids, so unknown keys are dropped.
2987
+ patch.panelSides = ps as UiPrefs['panelSides'];
2988
+ }
2989
+ try {
2990
+ return Response.json(writeUiPrefs(patch), { headers: { 'Cache-Control': 'no-store' } });
2991
+ } catch (err) {
2992
+ return new Response(err instanceof Error ? err.message : 'ui-prefs write failed', {
2993
+ status: 500,
2994
+ });
2995
+ }
2996
+ },
2997
+
2998
+ // feature-ai-media-generation (Task 2.5, DDR-164) — reuse-before-you-pay for
2999
+ // AUDIO. GET searches the project's OWN generated audio (intent sidecars) and,
3000
+ // when ElevenLabs is configured, the user's re-downloadable History (free —
3001
+ // already paid), returning ranked reuse candidates. MAIN-ORIGIN ONLY,
3002
+ // loopback-gated (it resolves the provider key server-side).
3003
+ '/_api/generate/audio-search': async (req: Request) => {
3004
+ if (!isLoopbackHost(req.headers.get('host')))
3005
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3006
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
3007
+ // F1 (ethical-hacker) — this GET resolves the user's key + fans out to the
3008
+ // provider, so it must NOT be cross-site-triggerable (a no-cors GET can slip
3009
+ // past the loopback guard). Fetch-Metadata gate: browser cross-site → 403;
3010
+ // the CLI (no Sec-Fetch-Site) → allowed.
3011
+ if (!sameOriginRead(req)) return new Response('cross-site read rejected', { status: 403 });
3012
+ const q = new URL(req.url).searchParams.get('q') ?? '';
3013
+ if (!q.trim()) return new Response('q query param required', { status: 400 });
3014
+ const local: AudioMatch[] = await api.searchAudioLibrary(q, 10);
3015
+ let history: AudioMatch[] = [];
3016
+ // Only reach the provider when its key is present (no key → local-only).
3017
+ if (isConfigured('elevenlabs')) {
3018
+ try {
3019
+ const apiKey = await getProviderKey('elevenlabs');
3020
+ const adapter = createAdapter('elevenlabs', {
3021
+ apiKey,
3022
+ localize: (asset) => localizeGenAsset(asset, { saveAsset: api.saveAsset }),
3023
+ });
3024
+ if (adapter.listHistory) {
3025
+ const items = await adapter.listHistory();
3026
+ const candidates: Candidate[] = items.map((it) => ({
3027
+ source: 'history' as const,
3028
+ ref: it.id,
3029
+ // History `text` is the user's own generation source, but a TTS of
3030
+ // attacker-supplied text could carry injection — sanitize before it
3031
+ // reaches the agent-facing output (F3).
3032
+ text: sanitizeReuseText(it.text),
3033
+ provider: 'elevenlabs',
3034
+ at: it.at,
3035
+ }));
3036
+ history = rankMatches(q, candidates, { limit: 10 });
3037
+ }
3038
+ } catch {
3039
+ // History is best-effort (rate limits / transient) — degrade to local.
3040
+ history = [];
3041
+ }
3042
+ }
3043
+ return Response.json({ local, history }, { headers: { 'Cache-Control': 'no-store' } });
3044
+ },
3045
+
3046
+ // feature-ai-media-generation (Task 2.5) — reuse a History item: re-download
3047
+ // its bytes (NO credit — already paid) through the host's magic-byte-sniffed
3048
+ // saveAsset, localize into assets/<sha8>, and record the audio intent so the
3049
+ // reused track is itself searchable. MAIN-ORIGIN ONLY, loopback +
3050
+ // sameOriginWrite gated.
3051
+ '/_api/generate/audio-reuse': async (req: Request) => {
3052
+ if (!isLoopbackHost(req.headers.get('host')))
3053
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3054
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3055
+ if (!sameOriginWrite(req))
3056
+ return new Response('cross-origin write rejected', { status: 403 });
3057
+ const body = await readJson<{ id?: unknown }>(req, 8 * 1024);
3058
+ const id = body?.id;
3059
+ if (typeof id !== 'string' || !id.trim())
3060
+ return new Response('history item id required', { status: 400 });
3061
+ if (!isConfigured('elevenlabs'))
3062
+ return new Response('no ElevenLabs key configured', { status: 400 });
3063
+ try {
3064
+ const apiKey = await getProviderKey('elevenlabs');
3065
+ const adapter = createAdapter('elevenlabs', {
3066
+ apiKey,
3067
+ localize: (asset) => localizeGenAsset(asset, { saveAsset: api.saveAsset }),
3068
+ });
3069
+ if (!adapter.fetchHistoryAudio)
3070
+ return new Response('provider has no history re-download', { status: 400 });
3071
+ const asset = await adapter.fetchHistoryAudio(id);
3072
+ const rel = await localizeGenAsset(asset, { saveAsset: api.saveAsset });
3073
+ await api.writeAudioIntent(rel, {
3074
+ kind: 'tts',
3075
+ prompt: `reused from ElevenLabs history ${id}`,
3076
+ provider: 'elevenlabs',
3077
+ });
3078
+ return Response.json({ asset: rel }, { headers: { 'Cache-Control': 'no-store' } });
3079
+ } catch (err) {
3080
+ return new Response(err instanceof Error ? err.message : 'reuse failed', { status: 400 });
3081
+ }
3082
+ },
3083
+
3084
+ // feature-ai-media-generation (Task 2.7 approach A, DDR-164) — managed local
3085
+ // whisper.cpp GGML models: GET lists the registry + which are downloaded +
3086
+ // any in-flight download; POST {id} downloads one (SSRF-hardened, from the
3087
+ // frozen ggerganov/whisper.cpp allowlist); DELETE {id} reclaims disk. This is
3088
+ // the "one-click local subtitles" model half — after a download,
3089
+ // `maude design transcribe --provider whisper` auto-resolves it. MAIN-ORIGIN
3090
+ // ONLY, loopback + sameOriginWrite gated.
3091
+ '/_api/generate/whisper-model': async (req: Request) => {
3092
+ if (!isLoopbackHost(req.headers.get('host')))
3093
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3094
+ if (req.method === 'GET') {
3095
+ return Response.json(
3096
+ { models: listWhisperModels(), downloading: whisperDownload },
3097
+ { headers: { 'Cache-Control': 'no-store' } }
3098
+ );
3099
+ }
3100
+ if (!sameOriginWrite(req))
3101
+ return new Response('cross-origin write rejected', { status: 403 });
3102
+ const body = await readJson<{ id?: unknown }>(req, 4 * 1024);
3103
+ const id = typeof body?.id === 'string' ? body.id : '';
3104
+ if (!getWhisperModel(id)) return new Response('unknown model id', { status: 400 });
3105
+
3106
+ if (req.method === 'DELETE') {
3107
+ const removed = await removeWhisperModel(id);
3108
+ return Response.json({ removed }, { headers: { 'Cache-Control': 'no-store' } });
3109
+ }
3110
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3111
+ if (whisperDownload && !whisperDownload.error)
3112
+ return new Response(`a download is already in progress (${whisperDownload.id})`, {
3113
+ status: 409,
3114
+ });
3115
+ // Kick off the streamed download; progress is polled via GET. Swallow the
3116
+ // rejection here (recorded on the state) to avoid an unhandled rejection.
3117
+ // A generous wall-clock timeout (ethical-hacker Finding 3) so a stalled
3118
+ // connection aborts → errors → frees the single slot instead of wedging it
3119
+ // forever (large models on a slow link can legitimately take many minutes).
3120
+ whisperDownload = { id, received: 0, total: 0 };
3121
+ void downloadWhisperModel(
3122
+ id,
3123
+ (received, total) => {
3124
+ if (whisperDownload && whisperDownload.id === id) {
3125
+ whisperDownload.received = received;
3126
+ whisperDownload.total = total;
3127
+ }
3128
+ },
3129
+ AbortSignal.timeout(45 * 60_000)
3130
+ )
3131
+ .then(() => {
3132
+ if (whisperDownload?.id === id) whisperDownload = null; // done → drops out of GET
3133
+ })
3134
+ .catch((err) => {
3135
+ if (whisperDownload?.id === id)
3136
+ whisperDownload = {
3137
+ id,
3138
+ received: whisperDownload.received,
3139
+ total: whisperDownload.total,
3140
+ error: err instanceof Error ? err.message : 'download failed',
3141
+ };
3142
+ });
3143
+ return Response.json(
3144
+ { started: id },
3145
+ { status: 202, headers: { 'Cache-Control': 'no-store' } }
3146
+ );
3147
+ },
3148
+
2215
3149
  '/_canvas-state': async (req: Request) => {
2216
3150
  const url = new URL(req.url);
2217
3151
  if (req.method === 'GET') {
@@ -2340,14 +3274,21 @@ export function createHttp(
2340
3274
  } catch {
2341
3275
  return new Response('Bad request', { status: 400 });
2342
3276
  }
2343
- if (
3277
+ // DDR-173 (Phase 3 / T12) — brand-logo assets live one level down at
3278
+ // `assets/logos/<sha8>.<ext>` (DDR-141's own convention). This is the
3279
+ // ONE legitimate subdirectory allowed here, matched by its own exact,
3280
+ // closed-charset shape — not a general "any subdirectory" opt-in,
3281
+ // which would widen this route's containment beyond what DDR-141
3282
+ // actually calls for.
3283
+ const isFlat =
2344
3284
  name &&
2345
3285
  !name.includes('/') &&
2346
3286
  !name.includes('\\') &&
2347
3287
  !name.includes('..') &&
2348
3288
  !name.startsWith('_') &&
2349
- CANVAS_ASSET_EXTS.has(ext(name))
2350
- ) {
3289
+ CANVAS_ASSET_EXTS.has(ext(name));
3290
+ const isLogoSubdir = /^logos\/[a-z0-9]{8}\.(svg|png)$/.test(name);
3291
+ if (isFlat || isLogoSubdir) {
2351
3292
  const abs = join(ctx.paths.designRoot, 'assets', name);
2352
3293
  // Range-aware for video/audio (scrubbing + WKWebView compat).
2353
3294
  if (RANGE_MEDIA_EXTS.has(ext(name))) {
@@ -2367,6 +3308,81 @@ export function createHttp(
2367
3308
  }
2368
3309
  }
2369
3310
 
3311
+ // Phase 4 (feature-whiteboard-annotation-improvements) — bundled sticker
3312
+ // PNGs, served from MAUDE's OWN STICKERS_DIR (paths.ts, DDR-045), never
3313
+ // the served project's designRoot (unlike /assets/ above). MAIN-ORIGIN
3314
+ // ONLY: absent from CANVAS_SAFE_API + startCanvasServer's own routes
3315
+ // object (server.ts) — a canvas-origin request 404s via isCanvasSafeRoute
3316
+ // before ever reaching here (dual-allowlist, DDR-054/DDR-088).
3317
+ if (pathname.startsWith('/_stickers/')) {
3318
+ const rest = pathname.slice('/_stickers/'.length);
3319
+ let decoded = '';
3320
+ try {
3321
+ decoded = decodeURIComponent(rest);
3322
+ } catch {
3323
+ return new Response('Bad request', { status: 400 });
3324
+ }
3325
+ const parts = decoded.split('/');
3326
+ const [pack, name] = parts;
3327
+ if (
3328
+ parts.length === 2 &&
3329
+ pack &&
3330
+ name &&
3331
+ /^[a-z0-9-]+$/.test(pack) &&
3332
+ !name.includes('..') &&
3333
+ !name.includes('/') &&
3334
+ !name.includes('\\') &&
3335
+ ext(name) === '.png'
3336
+ ) {
3337
+ const abs = join(STICKERS_DIR, pack, name);
3338
+ const f = Bun.file(abs);
3339
+ if (await f.exists()) {
3340
+ return new Response(f, {
3341
+ headers: {
3342
+ 'Content-Type': 'image/png',
3343
+ 'Cache-Control': 'public, max-age=31536000, immutable', // bundled with this maude version, never changes at this URL
3344
+ 'X-Content-Type-Options': 'nosniff',
3345
+ },
3346
+ });
3347
+ }
3348
+ return new Response('Not found', { status: 404 });
3349
+ }
3350
+ return new Response('Not found', { status: 404 });
3351
+ }
3352
+
3353
+ // DDR-166 Phase 1 / T2 — bundled Maude-product media (the intro
3354
+ // showreel), served from MAUDE's OWN MEDIA_DIR (paths.ts, DDR-045),
3355
+ // never the served project's designRoot — every user of the canvas
3356
+ // browser sees the same intro regardless of which project is open.
3357
+ // Same shape as /_stickers/ above: MAIN-ORIGIN ONLY (absent from
3358
+ // CANVAS_SAFE_API + startCanvasServer's routes), Range-capable via
3359
+ // serveMediaFile (the wizard/Help player needs to scrub).
3360
+ if (pathname.startsWith('/_media/')) {
3361
+ const name = pathname.slice('/_media/'.length);
3362
+ let decoded = '';
3363
+ try {
3364
+ decoded = decodeURIComponent(name);
3365
+ } catch {
3366
+ return new Response('Bad request', { status: 400 });
3367
+ }
3368
+ if (
3369
+ decoded &&
3370
+ !decoded.includes('..') &&
3371
+ !decoded.includes('/') &&
3372
+ !decoded.includes('\\') &&
3373
+ RANGE_MEDIA_EXTS.has(ext(decoded))
3374
+ ) {
3375
+ const abs = join(MEDIA_DIR, decoded);
3376
+ if (await Bun.file(abs).exists()) {
3377
+ return serveMediaFile(abs, req, {
3378
+ 'Cache-Control': 'public, max-age=31536000, immutable', // bundled with this maude version, never changes at this URL
3379
+ 'X-Content-Type-Options': 'nosniff',
3380
+ });
3381
+ }
3382
+ }
3383
+ return new Response('Not found', { status: 404 });
3384
+ }
3385
+
2370
3386
  // Fall-through: serve user repo files (designRoot + everything under repoRoot).
2371
3387
  const fp = safePathUnderRoot(req.url, ctx.paths.repoRoot);
2372
3388
  if (!fp) return new Response('Forbidden', { status: 403 });
@@ -2458,7 +3474,8 @@ export function createHttp(
2458
3474
  '/_api/git-user', // presence display name
2459
3475
  '/_api/canvas-meta', // layout/viewport sidecar (GET + PATCH)
2460
3476
  '/_api/annotations', // annotation SVG (GET + PUT) — drives the collab bridge
2461
- '/_api/asset', // Phase 23 — capped binary image upload (sniff+10MB+sha8 name+no-SVG)
3477
+ '/_api/asset', // Phase 23 — capped binary image upload (sniff+category cap+sha8 name+no-SVG)
3478
+ '/_api/photo-edit', // feature-photo-editor — PhotoEdit sidecar GET/PUT (cap-stack gated). MIRROR in server.ts routes.
2462
3479
  '/_api/git-committers', // @mention autocomplete
2463
3480
  '/_api/ai', // AI-activity banner
2464
3481
  '/_comments', // per-file comment list (renders pins)