@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
@@ -0,0 +1,399 @@
1
+ // Local-file / SVG / PDF ingestion (DDR-167). Pure-function coverage first —
2
+ // the SVG allowlist sanitizer, pre-parse rejects, write-path containment, and
3
+ // the worker-isolated PDF page-count discovery. The browser-driven pieces
4
+ // (execution canary, PDF rasterization) are covered in
5
+ // import-asset-browser.test.ts (slower, spawns real agent-browser sessions).
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { PDFDocument } from 'pdf-lib';
12
+
13
+ import {
14
+ assertPdfPageCap,
15
+ assetName,
16
+ containedAssetPath,
17
+ getPdfPageCountIsolated,
18
+ importRaster,
19
+ readPdfCapped,
20
+ readRasterCapped,
21
+ sanitizeSvgAllowlist,
22
+ sniffRasterKind,
23
+ svgPreParseReject,
24
+ writeContainedAsset,
25
+ } from '../bin/_import-asset.mjs';
26
+
27
+ function tmpDesignRoot() {
28
+ const root = mkdtempSync(join(tmpdir(), 'maude-import-asset-'));
29
+ mkdirSync(join(root, '.design'), { recursive: true });
30
+ return root;
31
+ }
32
+
33
+ describe('svgPreParseReject', () => {
34
+ test('accepts a plain, well-formed SVG', () => {
35
+ expect(() =>
36
+ svgPreParseReject('<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>')
37
+ ).not.toThrow();
38
+ });
39
+
40
+ test('rejects oversized input', () => {
41
+ const huge = `<svg xmlns="http://www.w3.org/2000/svg">${'x'.repeat(6 * 1024 * 1024)}</svg>`;
42
+ expect(() => svgPreParseReject(huge)).toThrow(/cap/);
43
+ });
44
+
45
+ test('rejects DOCTYPE (XXE class)', () => {
46
+ const xxe =
47
+ '<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY x "y">]><svg xmlns="http://www.w3.org/2000/svg"/>';
48
+ expect(() => svgPreParseReject(xxe)).toThrow(/DOCTYPE/);
49
+ });
50
+
51
+ test('rejects a bare ENTITY declaration even without DOCTYPE wrapping', () => {
52
+ expect(() =>
53
+ svgPreParseReject('<!ENTITY x "y"><svg xmlns="http://www.w3.org/2000/svg"/>')
54
+ ).toThrow(/DOCTYPE\/ENTITY/);
55
+ });
56
+
57
+ test('rejects a non-declaration processing instruction (xml-stylesheet)', () => {
58
+ const xslt =
59
+ '<?xml version="1.0"?><?xml-stylesheet type="text/xsl" href="https://evil.example/x.xsl"?><svg xmlns="http://www.w3.org/2000/svg"/>';
60
+ expect(() => svgPreParseReject(xslt)).toThrow(/processing instruction/);
61
+ });
62
+
63
+ test('allows the single leading XML declaration itself', () => {
64
+ expect(() =>
65
+ svgPreParseReject(
66
+ '<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg"/>'
67
+ )
68
+ ).not.toThrow();
69
+ });
70
+
71
+ test('rejects a non-UTF-8 declared encoding', () => {
72
+ expect(() =>
73
+ svgPreParseReject(
74
+ '<?xml version="1.0" encoding="UTF-16"?><svg xmlns="http://www.w3.org/2000/svg"/>'
75
+ )
76
+ ).toThrow(/encoding/);
77
+ });
78
+
79
+ test('rejects content that does not look like SVG/XML', () => {
80
+ expect(() => svgPreParseReject('<html><body>not svg</body></html>')).toThrow(/magic-byte/);
81
+ });
82
+ });
83
+
84
+ describe('sanitizeSvgAllowlist', () => {
85
+ test('strips <script> entirely', () => {
86
+ const out = sanitizeSvgAllowlist(
87
+ '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><rect width="1" height="1"/></svg>'
88
+ );
89
+ expect(out).not.toContain('script');
90
+ });
91
+
92
+ test('strips foreignObject and its HTML subtree', () => {
93
+ const out = sanitizeSvgAllowlist(
94
+ '<svg xmlns="http://www.w3.org/2000/svg"><foreignObject><body xmlns="http://www.w3.org/1999/xhtml"><script>alert(1)</script></body></foreignObject></svg>'
95
+ );
96
+ expect(out).not.toContain('foreignObject');
97
+ expect(out).not.toContain('script');
98
+ });
99
+
100
+ test('strips every on* event-handler attribute', () => {
101
+ const out = sanitizeSvgAllowlist(
102
+ '<svg xmlns="http://www.w3.org/2000/svg"><rect onload="alert(1)" onclick="alert(2)" width="1" height="1"/></svg>'
103
+ );
104
+ expect(out).not.toContain('onload');
105
+ expect(out).not.toContain('onclick');
106
+ });
107
+
108
+ test('drops an http(s) href on <use> but keeps the element', () => {
109
+ const out = sanitizeSvgAllowlist(
110
+ '<svg xmlns="http://www.w3.org/2000/svg"><use href="https://evil.example/x.svg#y"/></svg>'
111
+ );
112
+ expect(out).toContain('<use');
113
+ expect(out).not.toContain('evil.example');
114
+ });
115
+
116
+ test('drops a javascript: href', () => {
117
+ const out = sanitizeSvgAllowlist(
118
+ '<svg xmlns="http://www.w3.org/2000/svg"><use href="javascript:alert(1)"/></svg>'
119
+ );
120
+ expect(out).not.toContain('javascript:');
121
+ });
122
+
123
+ test('drops xlink:href the same way as href (namespace-aware)', () => {
124
+ const out = sanitizeSvgAllowlist(
125
+ '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><use xlink:href="javascript:alert(1)"/></svg>'
126
+ );
127
+ expect(out).not.toContain('javascript:');
128
+ });
129
+
130
+ test('keeps a #fragment href', () => {
131
+ const out = sanitizeSvgAllowlist(
132
+ '<svg xmlns="http://www.w3.org/2000/svg"><defs><rect id="a"/></defs><use href="#a"/></svg>'
133
+ );
134
+ expect(out).toContain('href="#a"');
135
+ });
136
+
137
+ test('drops data:image/svg+xml href (nested-SVG reintroduction)', () => {
138
+ const out = sanitizeSvgAllowlist(
139
+ '<svg xmlns="http://www.w3.org/2000/svg"><image href="data:image/svg+xml;base64,PHN2Zy8+"/></svg>'
140
+ );
141
+ expect(out).not.toContain('data:image/svg+xml');
142
+ });
143
+
144
+ test('keeps a raster data: href on <image>', () => {
145
+ const out = sanitizeSvgAllowlist(
146
+ '<svg xmlns="http://www.w3.org/2000/svg"><image href="data:image/png;base64,AAAA"/></svg>'
147
+ );
148
+ expect(out).toContain('data:image/png;base64,AAAA');
149
+ });
150
+
151
+ test('drops an http(s) FuncIRI on filter (not just href)', () => {
152
+ const out = sanitizeSvgAllowlist(
153
+ '<svg xmlns="http://www.w3.org/2000/svg"><rect filter="url(https://evil.example/x.svg#f)" width="1" height="1"/></svg>'
154
+ );
155
+ expect(out).not.toContain('evil.example');
156
+ });
157
+
158
+ test('keeps a #fragment FuncIRI on clip-path/mask/filter/marker-*', () => {
159
+ // SVGO minifies ids and may fold <rect> into <path> — assert the FuncIRI
160
+ // survives as a same-document fragment reference, not the exact bytes.
161
+ const out = sanitizeSvgAllowlist(
162
+ '<svg xmlns="http://www.w3.org/2000/svg"><defs><clipPath id="c"/></defs><rect clip-path="url(#c)" width="1" height="1"/></svg>'
163
+ );
164
+ expect(out).toMatch(/clip-path="url\(#[^"]+\)"/);
165
+ expect(out).not.toContain('evil');
166
+ });
167
+
168
+ test('drops <style> elements and style= attributes entirely', () => {
169
+ const out = sanitizeSvgAllowlist(
170
+ '<svg xmlns="http://www.w3.org/2000/svg"><style>@import url(https://evil.example/x.css);</style><rect style="fill:url(https://evil.example/x.svg)" width="1" height="1"/></svg>'
171
+ );
172
+ expect(out).not.toContain('style');
173
+ expect(out).not.toContain('evil.example');
174
+ });
175
+
176
+ test('SMIL elements survive as tags but their functional attributes are stripped', () => {
177
+ const out = sanitizeSvgAllowlist(
178
+ '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><use id="u" href="#a"><animate attributeName="xlink:href" values="javascript:alert(1)" begin="0s" dur="1s"/></use></svg>'
179
+ );
180
+ expect(out).not.toContain('attributeName');
181
+ expect(out).not.toContain('javascript:');
182
+ expect(out).not.toContain('begin=');
183
+ });
184
+
185
+ test('drops an unrecognized/foreign-namespace element at any depth', () => {
186
+ const out = sanitizeSvgAllowlist(
187
+ '<svg xmlns="http://www.w3.org/2000/svg"><g><g><bogus xmlns="urn:evil">payload</bogus></g></g></svg>'
188
+ );
189
+ expect(out).not.toContain('bogus');
190
+ expect(out).not.toContain('payload');
191
+ });
192
+
193
+ test('metadata/title/desc keep text but drop element children', () => {
194
+ const out = sanitizeSvgAllowlist(
195
+ '<svg xmlns="http://www.w3.org/2000/svg"><title>Acme<script>alert(1)</script></title></svg>'
196
+ );
197
+ expect(out).toContain('Acme');
198
+ expect(out).not.toContain('script');
199
+ });
200
+
201
+ test('rejects malformed XML rather than passing through a parsererror element', () => {
202
+ expect(() => sanitizeSvgAllowlist('<svg><rect')).toThrow(/parse/);
203
+ });
204
+
205
+ test('rejects a non-svg root element', () => {
206
+ expect(() => sanitizeSvgAllowlist('<notsvg xmlns="http://www.w3.org/2000/svg"/>')).toThrow();
207
+ });
208
+
209
+ test('a real, clean logo-shaped SVG round-trips with its visible geometry intact', () => {
210
+ const out = sanitizeSvgAllowlist(
211
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M16 5l2.8 8.2L27 16l-8.2 2.8L16 27l-2.8-8.2L5 16l8.2-2.8z" fill="#4f46e5"/></svg>'
212
+ );
213
+ expect(out).toContain('path');
214
+ expect(out).toContain('#4f46e5');
215
+ });
216
+ });
217
+
218
+ describe('write-path containment (Decision 3)', () => {
219
+ test('assetName is content-addressed and charset-contract-clean', () => {
220
+ const name = assetName(Buffer.from('hello'), 'svg');
221
+ expect(name).toMatch(/^[a-z0-9]{8}\.svg$/);
222
+ });
223
+
224
+ test('containedAssetPath resolves inside <root>/<designRoot>/assets', () => {
225
+ const root = tmpDesignRoot();
226
+ const { fileAbs } = containedAssetPath(root, '.design', 'aaaaaaaa.svg');
227
+ expect(fileAbs.startsWith(join(root, '.design', 'assets'))).toBe(true);
228
+ });
229
+
230
+ test('writeContainedAsset writes and dedupes identical content', () => {
231
+ const root = tmpDesignRoot();
232
+ const bytes = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>', 'utf8');
233
+ const r1 = writeContainedAsset(root, '.design', bytes, 'svg');
234
+ const r2 = writeContainedAsset(root, '.design', bytes, 'svg');
235
+ expect(r1.name).toBe(r2.name);
236
+ expect(existsSync(join(root, '.design', 'assets', r1.name))).toBe(true);
237
+ });
238
+ });
239
+
240
+ describe('PDF: readPdfCapped (Decision 2, step 1)', () => {
241
+ test('reads a real file within the cap', () => {
242
+ const dir = mkdtempSync(join(tmpdir(), 'maude-import-pdf-fixture-'));
243
+ const p = join(dir, 'x.pdf');
244
+ writeFileSync(p, Buffer.from('%PDF-1.4 fixture'));
245
+ const buf = readPdfCapped(p);
246
+ expect(buf.length).toBeGreaterThan(0);
247
+ });
248
+
249
+ test('rejects a file over the byte cap', () => {
250
+ const dir = mkdtempSync(join(tmpdir(), 'maude-import-pdf-fixture-'));
251
+ const p = join(dir, 'big.pdf');
252
+ writeFileSync(p, Buffer.alloc(1024));
253
+ expect(() => readPdfCapped(p, 100)).toThrow(/cap/);
254
+ });
255
+
256
+ test('rejects a nonexistent path', () => {
257
+ expect(() => readPdfCapped('/no/such/file.pdf')).toThrow();
258
+ });
259
+ });
260
+
261
+ describe('PDF: getPdfPageCountIsolated (Decision 2, step 2)', () => {
262
+ test('returns the correct page count for a real multi-page PDF', async () => {
263
+ const doc = await PDFDocument.create();
264
+ doc.addPage([100, 100]);
265
+ doc.addPage([100, 100]);
266
+ doc.addPage([100, 100]);
267
+ const bytes = await doc.save();
268
+ const count = await getPdfPageCountIsolated(Buffer.from(bytes));
269
+ expect(count).toBe(3);
270
+ });
271
+
272
+ test('rejects malformed PDF bytes as a clean error, not a hang', async () => {
273
+ await expect(getPdfPageCountIsolated(Buffer.from('not a pdf at all'))).rejects.toThrow(
274
+ /unparseable|malformed/
275
+ );
276
+ });
277
+
278
+ test('a real 1-page PDF passes the page cap', async () => {
279
+ const doc = await PDFDocument.create();
280
+ doc.addPage([100, 100]);
281
+ const bytes = await doc.save();
282
+ const count = await getPdfPageCountIsolated(Buffer.from(bytes));
283
+ expect(() => assertPdfPageCap(count)).not.toThrow();
284
+ });
285
+ });
286
+
287
+ describe('assertPdfPageCap', () => {
288
+ test('rejects a page count over the cap, naming the cap', () => {
289
+ expect(() => assertPdfPageCap(21)).toThrow(/20/);
290
+ });
291
+ test('allows exactly the cap', () => {
292
+ expect(() => assertPdfPageCap(20)).not.toThrow();
293
+ });
294
+ });
295
+
296
+ // DDR-174 (T15) — local raster (PNG/JPEG) ingestion, the vision-reconstruction
297
+ // source-image intake path. No sanitize step (raster carries no markup
298
+ // surface) — containment + magic-byte re-sniff is the whole control.
299
+ describe('sniffRasterKind', () => {
300
+ const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0]);
301
+ const JPEG_SIG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0]);
302
+
303
+ test('recognizes a real PNG signature', () => {
304
+ expect(sniffRasterKind(PNG_SIG)).toBe('png');
305
+ });
306
+ test('recognizes a real JPEG signature', () => {
307
+ expect(sniffRasterKind(JPEG_SIG)).toBe('jpg');
308
+ });
309
+ test('rejects non-raster bytes regardless of extension', () => {
310
+ expect(sniffRasterKind(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>'))).toBe(null);
311
+ });
312
+ test('rejects a PDF signature (not a raster format)', () => {
313
+ expect(sniffRasterKind(Buffer.from('%PDF-1.4'))).toBe(null);
314
+ });
315
+ });
316
+
317
+ describe('readRasterCapped (DDR-174 Decision 4)', () => {
318
+ test('reads a real file within the cap', () => {
319
+ const dir = mkdtempSync(join(tmpdir(), 'maude-import-raster-fixture-'));
320
+ const p = join(dir, 'x.png');
321
+ writeFileSync(p, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0]));
322
+ const buf = readRasterCapped(p);
323
+ expect(buf.length).toBeGreaterThan(0);
324
+ });
325
+
326
+ test('rejects a file over the byte cap', () => {
327
+ const dir = mkdtempSync(join(tmpdir(), 'maude-import-raster-fixture-'));
328
+ const p = join(dir, 'big.png');
329
+ writeFileSync(p, Buffer.alloc(1024));
330
+ expect(() => readRasterCapped(p, 100)).toThrow(/cap/);
331
+ });
332
+
333
+ test('rejects a nonexistent path', () => {
334
+ expect(() => readRasterCapped('/no/such/file.png')).toThrow();
335
+ });
336
+
337
+ test('rejects an empty file', () => {
338
+ const dir = mkdtempSync(join(tmpdir(), 'maude-import-raster-fixture-'));
339
+ const p = join(dir, 'empty.png');
340
+ writeFileSync(p, Buffer.alloc(0));
341
+ expect(() => readRasterCapped(p)).toThrow(/empty/);
342
+ });
343
+ });
344
+
345
+ describe('importRaster (full pipeline)', () => {
346
+ test('content-addresses a real PNG and writes it into assets/', () => {
347
+ const root = tmpDesignRoot();
348
+ const dir = mkdtempSync(join(tmpdir(), 'maude-import-raster-fixture-'));
349
+ const p = join(dir, 'frame.png');
350
+ const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
351
+ writeFileSync(p, bytes);
352
+ const result = importRaster(p, { root, designRootRel: '.design' });
353
+ expect(result.ext).toBe('png');
354
+ expect(result.ref).toMatch(/^\/assets\/[a-f0-9]{8}\.png$/);
355
+ expect(existsSync(join(root, '.design', 'assets', result.name))).toBe(true);
356
+ });
357
+
358
+ test('sniffs the REAL format even when the filename claims otherwise', () => {
359
+ const root = tmpDesignRoot();
360
+ const dir = mkdtempSync(join(tmpdir(), 'maude-import-raster-fixture-'));
361
+ // A file named .png that is actually a JPEG — the write path must trust
362
+ // the magic bytes, never the extension (mirrors the PDF-page re-sniff
363
+ // discipline already used in importPdf above).
364
+ const p = join(dir, 'lying.png');
365
+ writeFileSync(p, Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]));
366
+ const result = importRaster(p, { root, designRootRel: '.design' });
367
+ expect(result.ext).toBe('jpg');
368
+ });
369
+
370
+ test('rejects a file with no recognized raster signature', () => {
371
+ const root = tmpDesignRoot();
372
+ const dir = mkdtempSync(join(tmpdir(), 'maude-import-raster-fixture-'));
373
+ const p = join(dir, 'not-an-image.png');
374
+ writeFileSync(p, Buffer.from('this is plain text, not an image'));
375
+ expect(() => importRaster(p, { root, designRootRel: '.design' })).toThrow(
376
+ /recognized raster image/
377
+ );
378
+ });
379
+
380
+ test('dedupes identical raster content across two imports', () => {
381
+ const root = tmpDesignRoot();
382
+ const dir = mkdtempSync(join(tmpdir(), 'maude-import-raster-fixture-'));
383
+ const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 9, 9]);
384
+ const p1 = join(dir, 'a.png');
385
+ const p2 = join(dir, 'b.png');
386
+ writeFileSync(p1, bytes);
387
+ writeFileSync(p2, bytes);
388
+ const r1 = importRaster(p1, { root, designRootRel: '.design' });
389
+ const r2 = importRaster(p2, { root, designRootRel: '.design' });
390
+ expect(r1.name).toBe(r2.name);
391
+ });
392
+ });
393
+
394
+ describe('assetName accepts jpg alongside svg/png (DDR-174)', () => {
395
+ test('a jpg-extension name passes the charset contract', () => {
396
+ const name = assetName(Buffer.from('x'), 'jpg');
397
+ expect(name).toMatch(/^[a-z0-9]{8}\.jpg$/);
398
+ });
399
+ });
@@ -0,0 +1,88 @@
1
+ // Browser-driven piece of DDR-173's brand-logo hardening — the raster
2
+ // fallback for a logo whose wordmark was live `<text>` (Decision 6). Spawns
3
+ // a real agent-browser session, so this is slower than import-brand.test.ts
4
+ // and is skipped (not failed) when agent-browser can't be resolved,
5
+ // mirroring import-asset-browser.test.ts's treatment of browser-dependent
6
+ // verification as a real-environment concern rather than a hard CI
7
+ // requirement.
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+ import { execFileSync } from 'node:child_process';
11
+ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+
15
+ import { importBrand } from '../bin/_import-brand.mjs';
16
+
17
+ function agentBrowserAvailable(): boolean {
18
+ try {
19
+ execFileSync(process.env.MAUDE_AGENT_BROWSER_BIN || 'agent-browser', ['--version'], {
20
+ stdio: 'ignore',
21
+ timeout: 5000,
22
+ });
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ const HAS_AGENT_BROWSER = agentBrowserAvailable();
30
+ const d = HAS_AGENT_BROWSER ? describe : describe.skip;
31
+
32
+ function tmpDesignRoot() {
33
+ const root = mkdtempSync(join(tmpdir(), 'maude-import-brand-'));
34
+ return root;
35
+ }
36
+
37
+ d('importBrand — end-to-end orchestration (Decision 6 raster fallback)', () => {
38
+ test('a logo with a live wordmark: extracts palette, writes a hardened vector asset, and rasterizes a PNG fallback', async () => {
39
+ const root = tmpDesignRoot();
40
+ const svgPath = join(root, 'logo.svg');
41
+ writeFileSync(
42
+ svgPath,
43
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 96">
44
+ <rect width="240" height="96" fill="#0d0d0f"/>
45
+ <circle cx="40" cy="48" r="20" fill="#6b6bf0"/>
46
+ <text x="70" y="56" font-family="Inter, sans-serif" font-size="24" fill="#ffffff">Acme</text>
47
+ </svg>`
48
+ );
49
+
50
+ const result = await importBrand({ sanitizedSvgPath: svgPath, root, designRootRel: '.design' });
51
+
52
+ expect(result.hadWordmarkText).toBe(true);
53
+ expect(result.palette).toContain('#0d0d0f');
54
+ expect(result.palette).toContain('#6b6bf0');
55
+ expect(result.logoRasterRef).not.toBeNull();
56
+ const logoRasterRef = result.logoRasterRef;
57
+ if (!logoRasterRef) throw new Error('expected logoRasterRef to be set');
58
+
59
+ const vectorPath = join(root, '.design', result.logoRef);
60
+ expect(existsSync(vectorPath)).toBe(true);
61
+ const vectorText = readFileSync(vectorPath, 'utf8');
62
+ expect(vectorText).not.toContain('Acme'); // wordmark text stripped from the vector
63
+
64
+ const rasterPath = join(root, '.design', logoRasterRef);
65
+ expect(existsSync(rasterPath)).toBe(true);
66
+ const rasterBytes = readFileSync(rasterPath);
67
+ // Real PNG magic bytes — confirms the sandboxed-render screenshot actually produced an image.
68
+ expect(rasterBytes[0]).toBe(0x89);
69
+ expect(rasterBytes[1]).toBe(0x50);
70
+ expect(rasterBytes[2]).toBe(0x4e);
71
+ expect(rasterBytes[3]).toBe(0x47);
72
+ }, 45_000);
73
+
74
+ test('a logo with NO live text: no raster fallback is produced (nothing lost by the strip)', async () => {
75
+ const root = tmpDesignRoot();
76
+ const svgPath = join(root, 'icon.svg');
77
+ writeFileSync(
78
+ svgPath,
79
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M16 5l2.8 8.2L27 16l-8.2 2.8L16 27l-2.8-8.2L5 16l8.2-2.8z" fill="#4f46e5"/></svg>`
80
+ );
81
+
82
+ const result = await importBrand({ sanitizedSvgPath: svgPath, root, designRootRel: '.design' });
83
+
84
+ expect(result.hadWordmarkText).toBe(false);
85
+ expect(result.logoRasterRef).toBeNull();
86
+ expect(result.palette).toEqual(['#4f46e5']);
87
+ }, 30_000);
88
+ });
@@ -0,0 +1,120 @@
1
+ // POST /_api/import-brand — DDR-173 (Phase 3 / T12). Full HTTP round-trip
2
+ // against a real booted server, chained after /_api/import-asset the same
3
+ // way the in-app Brand-upload panel does (privilege/CSRF boundary is covered
4
+ // generically by canvas-origin-gate.test.ts — this file exercises the
5
+ // route's own success/error shapes, incl. the assetPath containment check).
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+
9
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
10
+
11
+ const LOGO_SVG =
12
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 96">' +
13
+ '<rect width="240" height="96" fill="#0d0d0f"/>' +
14
+ '<circle cx="40" cy="48" r="20" fill="#6b6bf0"/>' +
15
+ '<text x="70" y="56" font-family="Inter, sans-serif" font-size="24" fill="#ffffff">Acme</text>' +
16
+ '</svg>';
17
+
18
+ describe('POST /_api/import-brand', () => {
19
+ test('chained after /_api/import-asset: extracts palette/fonts, rasterizes the wordmark fallback', async () => {
20
+ const { root } = makeSandbox();
21
+ const port = nextPort();
22
+ const proc = await bootServer(root, port);
23
+ try {
24
+ const base = `http://localhost:${port}`;
25
+
26
+ const importAsset = await fetch(`${base}/_api/import-asset`, {
27
+ method: 'POST',
28
+ headers: { 'X-Import-Kind': 'svg', Origin: base },
29
+ body: LOGO_SVG,
30
+ });
31
+ expect(importAsset.status).toBe(201);
32
+ const assetBody = (await importAsset.json()) as { ok: boolean; path: string };
33
+ expect(assetBody.ok).toBe(true);
34
+
35
+ const importBrand = await fetch(`${base}/_api/import-brand`, {
36
+ method: 'POST',
37
+ headers: { 'Content-Type': 'application/json', Origin: base },
38
+ body: JSON.stringify({ assetPath: assetBody.path }),
39
+ });
40
+ expect(importBrand.status).toBe(201);
41
+ const brandBody = (await importBrand.json()) as {
42
+ ok: boolean;
43
+ palette: string[];
44
+ fonts: string[];
45
+ logoRef: string;
46
+ logoRasterRef: string | null;
47
+ hadWordmarkText: boolean;
48
+ };
49
+ expect(brandBody.ok).toBe(true);
50
+ expect(brandBody.palette).toContain('#0d0d0f');
51
+ expect(brandBody.palette).toContain('#6b6bf0');
52
+ expect(brandBody.hadWordmarkText).toBe(true);
53
+ expect(brandBody.logoRasterRef).toMatch(/^assets\/logos\/[a-z0-9]{8}\.png$/);
54
+ expect(brandBody.logoRef).toMatch(/^assets\/logos\/[a-z0-9]{8}\.svg$/);
55
+ } finally {
56
+ await killProc(proc);
57
+ }
58
+ }, 45_000);
59
+
60
+ test('rejects a client-supplied path that is not a server-generated assets/<sha8>.svg shape', async () => {
61
+ const { root } = makeSandbox();
62
+ const port = nextPort();
63
+ const proc = await bootServer(root, port);
64
+ try {
65
+ const base = `http://localhost:${port}`;
66
+ const attempts = [
67
+ '../../../etc/passwd',
68
+ 'assets/../../../etc/passwd',
69
+ '/etc/passwd',
70
+ 'assets/not-a-valid-hash.svg',
71
+ 'assets/12345678.png', // wrong extension
72
+ '',
73
+ ];
74
+ for (const assetPath of attempts) {
75
+ const res = await fetch(`${base}/_api/import-brand`, {
76
+ method: 'POST',
77
+ headers: { 'Content-Type': 'application/json', Origin: base },
78
+ body: JSON.stringify({ assetPath }),
79
+ });
80
+ expect(res.status).toBe(400);
81
+ const body = (await res.json()) as { ok: boolean };
82
+ expect(body.ok).toBe(false);
83
+ }
84
+ } finally {
85
+ await killProc(proc);
86
+ }
87
+ }, 30_000);
88
+
89
+ test('a well-formed but nonexistent asset path is a clean 404, not a 500 crash', async () => {
90
+ const { root } = makeSandbox();
91
+ const port = nextPort();
92
+ const proc = await bootServer(root, port);
93
+ try {
94
+ const base = `http://localhost:${port}`;
95
+ const res = await fetch(`${base}/_api/import-brand`, {
96
+ method: 'POST',
97
+ headers: { 'Content-Type': 'application/json', Origin: base },
98
+ body: JSON.stringify({ assetPath: 'assets/deadbeef.svg' }),
99
+ });
100
+ expect(res.status).toBe(404);
101
+ const body = (await res.json()) as { ok: boolean };
102
+ expect(body.ok).toBe(false);
103
+ } finally {
104
+ await killProc(proc);
105
+ }
106
+ }, 30_000);
107
+
108
+ test('rejects GET', async () => {
109
+ const { root } = makeSandbox();
110
+ const port = nextPort();
111
+ const proc = await bootServer(root, port);
112
+ try {
113
+ const base = `http://localhost:${port}`;
114
+ const res = await fetch(`${base}/_api/import-brand`, { method: 'GET' });
115
+ expect(res.status).toBe(405);
116
+ } finally {
117
+ await killProc(proc);
118
+ }
119
+ }, 30_000);
120
+ });