@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,815 @@
1
+ #!/usr/bin/env bun
2
+ // _import-asset.mjs — hardened local-file / SVG / PDF ingestion (DDR-167).
3
+ // Reached via `maude design import-asset` (DDR-062), never a raw bin path.
4
+ //
5
+ // Pure functions here are shared by BOTH the CLI verb (import-asset.sh) and
6
+ // the privileged `/_api/import-asset` HTTP route (T10) — imported directly by
7
+ // the route handler, not shelled out to, mirroring `_fetch-asset.mjs`'s own
8
+ // module/CLI split. Runs under Bun (not the node/bun fallback `fetch-asset.sh`
9
+ // uses) because it imports the `.ts` SVGO engine module directly, same
10
+ // constraint as `_svg-optimize.mjs`.
11
+ //
12
+ // See DDR-167 for the full threat model. Every numbered "Decision N, step M"
13
+ // comment below points at the exact DDR clause it implements.
14
+
15
+ import { execFileSync } from 'node:child_process';
16
+ import { createHash } from 'node:crypto';
17
+ import {
18
+ existsSync,
19
+ lstatSync,
20
+ mkdirSync,
21
+ mkdtempSync,
22
+ readFileSync,
23
+ realpathSync,
24
+ renameSync,
25
+ rmSync,
26
+ writeFileSync,
27
+ } from 'node:fs';
28
+ import { tmpdir } from 'node:os';
29
+ import { join, resolve, sep } from 'node:path';
30
+ import { pathToFileURL } from 'node:url';
31
+ import { Worker } from 'node:worker_threads';
32
+ import { Window } from 'happy-dom';
33
+ import { isValidSvg, optimizeSvg } from '../draw/optimize.ts';
34
+
35
+ export class ImportAssetError extends Error {
36
+ constructor(code, message) {
37
+ super(message);
38
+ this.code = code;
39
+ }
40
+ }
41
+
42
+ // Exit codes (mirrors _fetch-asset.mjs's convention): 0 ok · 2 usage ·
43
+ // 3 validation/sanitize reject · 4 read/parse error · 5 unsupported media
44
+ // type · 6 write/containment error · 1 other.
45
+
46
+ // ============================================================================
47
+ // Decision 3 — write-path containment (shared by SVG + PDF-page output).
48
+ // Mirrors _fetch-asset.mjs's containedAssetPath/assetName exactly.
49
+ // ============================================================================
50
+
51
+ /** Content-addressed `<sha8>.<ext>` name. `ext` in {svg, png, jpg}. */
52
+ export function assetName(bytes, ext) {
53
+ const sha8 = createHash('sha256').update(bytes).digest('hex').slice(0, 8);
54
+ const name = `${sha8}.${ext}`;
55
+ if (!/^[a-z0-9]{8}\.(svg|png|jpg)$/.test(name)) {
56
+ throw new ImportAssetError(6, `generated name failed the charset contract: ${name}`);
57
+ }
58
+ return name;
59
+ }
60
+
61
+ /** Resolve `<root>/<designRootRel>/assets/<name>`, asserting realpath containment. */
62
+ export function containedAssetPath(root, designRootRel, name) {
63
+ const rootAbs = resolve(root);
64
+ const assetsDir = resolve(rootAbs, designRootRel, 'assets');
65
+ if (assetsDir !== rootAbs && !assetsDir.startsWith(rootAbs + sep)) {
66
+ throw new ImportAssetError(6, `assets dir escapes root: ${assetsDir}`);
67
+ }
68
+ const fileAbs = resolve(assetsDir, name);
69
+ if (fileAbs !== join(assetsDir, name) || !fileAbs.startsWith(assetsDir + sep)) {
70
+ throw new ImportAssetError(6, `resolved asset path escapes assets dir: ${fileAbs}`);
71
+ }
72
+ return { assetsDir, fileAbs };
73
+ }
74
+
75
+ /** Write `bytes` to the contained, content-addressed path. Dedupes identical content. */
76
+ export function writeContainedAsset(root, designRootRel, bytes, ext) {
77
+ const name = assetName(bytes, ext);
78
+ const { assetsDir, fileAbs } = containedAssetPath(root, designRootRel, name);
79
+ mkdirSync(assetsDir, { recursive: true });
80
+ if (!existsSync(fileAbs)) {
81
+ const tmp = join(
82
+ assetsDir,
83
+ `.tmp-${createHash('sha256').update(name).digest('hex').slice(0, 12)}`
84
+ );
85
+ writeFileSync(tmp, bytes);
86
+ renameSync(tmp, fileAbs);
87
+ }
88
+ return { ref: `/assets/${name}`, path: `assets/${name}`, name, bytes: bytes.length, ext };
89
+ }
90
+
91
+ // ============================================================================
92
+ // Decision 1 — SVG: allowlist DOM-sanitize (happy-dom) → SVGO validity/minify
93
+ // → real-browser execution canary (canary lives in the "sandboxed render"
94
+ // section below, shared with Decision 2's PDF rasterizer).
95
+ // ============================================================================
96
+
97
+ export const SVG_MAX_BYTES = 5 * 1024 * 1024;
98
+ const SVG_NS = 'http://www.w3.org/2000/svg';
99
+
100
+ // Decision 1, step 3 — allowlisted elements. SMIL elements are TAGS ONLY; see
101
+ // SMIL_FUNCTIONAL_ATTRS below for why their functional attributes are
102
+ // permanently excluded, not just absent from this list.
103
+ const ALLOWED_ELEMENTS = new Set([
104
+ 'svg',
105
+ 'g',
106
+ 'path',
107
+ 'rect',
108
+ 'circle',
109
+ 'ellipse',
110
+ 'line',
111
+ 'polyline',
112
+ 'polygon',
113
+ 'defs',
114
+ 'clipPath',
115
+ 'mask',
116
+ 'linearGradient',
117
+ 'radialGradient',
118
+ 'stop',
119
+ 'pattern',
120
+ 'symbol',
121
+ 'use',
122
+ 'image',
123
+ 'text',
124
+ 'tspan',
125
+ 'textPath',
126
+ 'title',
127
+ 'desc',
128
+ 'metadata',
129
+ 'marker',
130
+ 'filter',
131
+ 'feGaussianBlur',
132
+ 'feOffset',
133
+ 'feMerge',
134
+ 'feMergeNode',
135
+ 'feColorMatrix',
136
+ 'feComposite',
137
+ 'feFlood',
138
+ 'feBlend',
139
+ 'animate',
140
+ 'animateTransform',
141
+ 'animateMotion',
142
+ 'set',
143
+ ]);
144
+
145
+ // Text-only containers — any ELEMENT child inside these is dropped (their
146
+ // content is inert copy, never markup).
147
+ const TEXT_ONLY_ELEMENTS = new Set(['metadata', 'title', 'desc']);
148
+
149
+ const SMIL_ELEMENTS = new Set(['animate', 'animateTransform', 'animateMotion', 'set']);
150
+ // PERMANENTLY excluded from SMIL elements (DDR-167 Decision 1, step 3,
151
+ // Round-2 finding) — attributeName/values retargeting a href to javascript:
152
+ // at render time is a real, actively-exploited bypass class (CVE-2025-66412,
153
+ // CVE-2025-68461, PortSwigger "SVG animate XSS vector"). Do NOT add these to
154
+ // an allowlist without a DDR amendment re-deriving the threat model.
155
+ const SMIL_FUNCTIONAL_ATTRS = new Set([
156
+ 'attributeName',
157
+ 'attributeType',
158
+ 'to',
159
+ 'from',
160
+ 'values',
161
+ 'by',
162
+ 'begin',
163
+ 'end',
164
+ 'dur',
165
+ 'repeatCount',
166
+ 'repeatDur',
167
+ 'keyTimes',
168
+ 'keySplines',
169
+ ]);
170
+
171
+ // Decision 1, step 3 — plain presentation-attribute allowlist. clip-path,
172
+ // mask, filter, marker-start/mid/end are DELIBERATELY excluded here (they
173
+ // take FuncIRI url(...) values) — see FUNCIRI_ATTRS below.
174
+ const ALLOWED_PLAIN_ATTRS = new Set([
175
+ 'fill',
176
+ 'stroke',
177
+ 'stroke-width',
178
+ 'd',
179
+ 'x',
180
+ 'y',
181
+ 'width',
182
+ 'height',
183
+ 'cx',
184
+ 'cy',
185
+ 'r',
186
+ 'rx',
187
+ 'ry',
188
+ 'points',
189
+ 'transform',
190
+ 'viewBox',
191
+ 'preserveAspectRatio',
192
+ 'id',
193
+ 'opacity',
194
+ 'fill-opacity',
195
+ 'stroke-opacity',
196
+ 'font-family',
197
+ 'font-size',
198
+ 'font-weight',
199
+ 'text-anchor',
200
+ 'gradientUnits',
201
+ 'gradientTransform',
202
+ 'offset',
203
+ 'stop-color',
204
+ 'stop-opacity',
205
+ 'patternUnits',
206
+ 'patternTransform',
207
+ 'version',
208
+ ]);
209
+
210
+ // href/xlink:href (matched by localName 'href') — allowed as #fragment or a
211
+ // raster data: URI only. Never data:image/svg+xml (closes nested-SVG reintro).
212
+ const HREF_ALLOWED_DATA_RE = /^data:image\/(?:png|jpeg|gif|webp);base64,[a-zA-Z0-9+/=]+$/;
213
+
214
+ // clip-path/mask/filter/marker-* — FuncIRI-valued (url(...) syntax). Same
215
+ // #fragment-only constraint as href, applied after extracting the inner ref.
216
+ const FUNCIRI_ATTR_NAMES = new Set([
217
+ 'clip-path',
218
+ 'mask',
219
+ 'filter',
220
+ 'marker-start',
221
+ 'marker-mid',
222
+ 'marker-end',
223
+ ]);
224
+ const FUNCIRI_VALUE_RE = /^url\(\s*(['"]?)([^'")]*)\1\s*\)$/i;
225
+
226
+ function isSafeFragmentOrRasterData(value) {
227
+ if (typeof value !== 'string') return false;
228
+ if (value.startsWith('#')) return true;
229
+ return HREF_ALLOWED_DATA_RE.test(value);
230
+ }
231
+
232
+ function isSafeFuncIriValue(rawValue) {
233
+ const m = FUNCIRI_VALUE_RE.exec(String(rawValue ?? '').trim());
234
+ if (!m) return false;
235
+ return m[2].startsWith('#');
236
+ }
237
+
238
+ /** Decision 1, step 1 — pre-parse hard rejects. Throws ImportAssetError, never returns false. */
239
+ export function svgPreParseReject(text) {
240
+ if (typeof text !== 'string') throw new ImportAssetError(3, 'not a string');
241
+ const byteLen = Buffer.byteLength(text, 'utf8');
242
+ if (byteLen > SVG_MAX_BYTES) {
243
+ throw new ImportAssetError(3, `SVG exceeds the ${SVG_MAX_BYTES / (1024 * 1024)} MB cap`);
244
+ }
245
+ // Canonical UTF-8 — reject a declared non-UTF-8/non-ASCII encoding so the
246
+ // string checks below and the later DOM parse never disagree about bytes.
247
+ const encMatch = /<\?xml[^>]*encoding=["']([^"']+)["']/i.exec(text);
248
+ if (encMatch && !/^(utf-8|us-ascii|ascii)$/i.test(encMatch[1])) {
249
+ throw new ImportAssetError(3, `unsupported declared encoding: ${encMatch[1]}`);
250
+ }
251
+ if (/<!DOCTYPE/i.test(text) || /<!ENTITY/i.test(text)) {
252
+ throw new ImportAssetError(
253
+ 3,
254
+ 'DOCTYPE/ENTITY declarations are rejected (XXE/entity-expansion class)'
255
+ );
256
+ }
257
+ // Any processing instruction other than the single leading XML declaration.
258
+ const piRe = /<\?([^?]*)\?>/g;
259
+ let seenLeadingDecl = false;
260
+ const trimmed = text.trimStart();
261
+ for (const m of text.matchAll(piRe)) {
262
+ const isLeadingXmlDecl =
263
+ !seenLeadingDecl && trimmed.startsWith(m[0]) && /^xml\s/i.test(m[1].trim() + ' ');
264
+ if (isLeadingXmlDecl) {
265
+ seenLeadingDecl = true;
266
+ continue;
267
+ }
268
+ throw new ImportAssetError(
269
+ 3,
270
+ 'processing instructions other than the XML declaration are rejected'
271
+ );
272
+ }
273
+ if (!/^\s*(<\?xml|<svg|<!--)/i.test(text)) {
274
+ throw new ImportAssetError(5, 'not an SVG file (magic-byte/content sniff failed)');
275
+ }
276
+ }
277
+
278
+ /** Removes `el` and everything not in ALLOWED_ELEMENTS, at any depth. */
279
+ function walkAllowlist(root) {
280
+ // querySelectorAll('*') returns every descendant regardless of depth —
281
+ // exactly the "not root-only" guarantee DDR-167 requires.
282
+ const all = [...root.querySelectorAll('*'), root];
283
+ for (const el of all) {
284
+ if (!el.isConnected && el !== root) continue; // already removed via an ancestor
285
+ const tag = el.tagName;
286
+ const allowed = ALLOWED_ELEMENTS.has(tag) && el.namespaceURI === SVG_NS;
287
+ if (!allowed) {
288
+ el.remove();
289
+ continue;
290
+ }
291
+ if (TEXT_ONLY_ELEMENTS.has(tag)) {
292
+ for (const child of [...el.children]) child.remove();
293
+ continue;
294
+ }
295
+ const isSmil = SMIL_ELEMENTS.has(tag);
296
+ for (const attr of [...el.attributes]) {
297
+ const local = attr.localName;
298
+ if (isSmil && SMIL_FUNCTIONAL_ATTRS.has(local)) {
299
+ el.removeAttribute(attr.name);
300
+ continue;
301
+ }
302
+ if (/^on/i.test(local)) {
303
+ el.removeAttribute(attr.name);
304
+ continue;
305
+ }
306
+ if (local === 'href') {
307
+ if (!isSafeFragmentOrRasterData(attr.value)) el.removeAttribute(attr.name);
308
+ continue;
309
+ }
310
+ if (FUNCIRI_ATTR_NAMES.has(local)) {
311
+ if (!isSafeFuncIriValue(attr.value)) el.removeAttribute(attr.name);
312
+ continue;
313
+ }
314
+ if (local === 'xmlns' || attr.name === 'xmlns:xlink' || local === 'xlink') {
315
+ continue; // namespace declarations
316
+ }
317
+ if (ALLOWED_PLAIN_ATTRS.has(local)) continue;
318
+ el.removeAttribute(attr.name);
319
+ }
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Decision 1, steps 2-5: parse → allowlist walk → re-serialize → SVGO
325
+ * validity/minify gate. Does NOT run the execution canary (step 6) — that's
326
+ * browser-driven, see runSvgExecutionCanary below. Throws on any hard reject.
327
+ */
328
+ export function sanitizeSvgAllowlist(svgText) {
329
+ svgPreParseReject(svgText);
330
+ const window = new Window();
331
+ const doc = new window.DOMParser().parseFromString(svgText, 'image/svg+xml');
332
+ const root = doc.documentElement;
333
+ if (
334
+ !root ||
335
+ root.tagName !== 'svg' ||
336
+ root.namespaceURI !== SVG_NS ||
337
+ doc.querySelector('parsererror')
338
+ ) {
339
+ throw new ImportAssetError(3, 'SVG failed to parse');
340
+ }
341
+ walkAllowlist(root);
342
+ if (!root.isConnected && root.tagName !== 'svg') {
343
+ throw new ImportAssetError(3, 'root element was not a valid <svg> after sanitizing');
344
+ }
345
+ const serialized = root.outerHTML;
346
+ if (!isValidSvg(serialized)) {
347
+ throw new ImportAssetError(3, 'sanitized output failed the SVGO validity gate');
348
+ }
349
+ return optimizeSvg(serialized, { multipass: true });
350
+ }
351
+
352
+ // ============================================================================
353
+ // Decision 2 — PDF: single capped read → worker-isolated page-count → rasterize
354
+ // (rasterize lives in the sandboxed-render section, shared with the SVG canary).
355
+ // ============================================================================
356
+
357
+ export const PDF_MAX_BYTES = 50 * 1024 * 1024;
358
+ export const PDF_MAX_PAGES = 20;
359
+ const PDF_LIB_TIMEOUT_MS = 15_000;
360
+
361
+ /**
362
+ * Decision 2, step 1 — realpath-then-open, single capped read. Rejects if any
363
+ * path component is a symlink (shrinks the TOCTOU window to its structural
364
+ * minimum — see DDR-167's named residual for what remains).
365
+ */
366
+ export function readPdfCapped(inputPath, maxBytes = PDF_MAX_BYTES) {
367
+ let real;
368
+ try {
369
+ real = realpathSync(inputPath);
370
+ } catch (err) {
371
+ throw new ImportAssetError(4, `could not resolve path: ${err?.message ?? err}`);
372
+ }
373
+ // Walk every path component and reject a symlink anywhere in the chain.
374
+ let cur = real[0] === sep ? sep : '';
375
+ for (const part of real.split(sep).filter(Boolean)) {
376
+ cur = cur === sep ? `${sep}${part}` : `${cur}${sep}${part}`;
377
+ let st;
378
+ try {
379
+ st = lstatSync(cur);
380
+ } catch {
381
+ continue;
382
+ }
383
+ if (st.isSymbolicLink()) {
384
+ throw new ImportAssetError(3, `refusing a path containing a symlink component: ${cur}`);
385
+ }
386
+ }
387
+ let buf;
388
+ try {
389
+ buf = readFileSync(real);
390
+ } catch (err) {
391
+ throw new ImportAssetError(4, `could not read file: ${err?.message ?? err}`);
392
+ }
393
+ if (buf.length === 0) throw new ImportAssetError(4, 'file is empty');
394
+ if (buf.length > maxBytes) {
395
+ throw new ImportAssetError(4, `PDF exceeds the ${Math.round(maxBytes / (1024 * 1024))} MB cap`);
396
+ }
397
+ return buf;
398
+ }
399
+
400
+ const PDF_WORKER_URL = new URL('./_import-asset-pdf-worker.mjs', import.meta.url);
401
+
402
+ /**
403
+ * Decision 2, step 2 — pdf-lib page-count discovery, isolated in a worker
404
+ * thread with hard `.terminate()` on timeout (NOT a bare `Promise.race`,
405
+ * which cannot interrupt a synchronous CPU-bound hang on this runtime).
406
+ */
407
+ export async function getPdfPageCountIsolated(buffer, timeoutMs = PDF_LIB_TIMEOUT_MS) {
408
+ return new Promise((resolvePromise, reject) => {
409
+ const worker = new Worker(PDF_WORKER_URL, {
410
+ workerData: {
411
+ buffer: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
412
+ },
413
+ transferList: [],
414
+ });
415
+ let settled = false;
416
+ const timer = setTimeout(() => {
417
+ if (settled) return;
418
+ settled = true;
419
+ worker.terminate();
420
+ reject(
421
+ new ImportAssetError(4, `PDF structural parse timed out after ${timeoutMs}ms (hard reject)`)
422
+ );
423
+ }, timeoutMs);
424
+ worker.once('message', (msg) => {
425
+ if (settled) return;
426
+ settled = true;
427
+ clearTimeout(timer);
428
+ worker.terminate();
429
+ if (msg.ok) resolvePromise(msg.pageCount);
430
+ else reject(new ImportAssetError(4, `unparseable/malformed PDF: ${msg.error}`));
431
+ });
432
+ worker.once('error', (err) => {
433
+ if (settled) return;
434
+ settled = true;
435
+ clearTimeout(timer);
436
+ reject(new ImportAssetError(4, `PDF worker crashed: ${err?.message ?? err}`));
437
+ });
438
+ });
439
+ }
440
+
441
+ export function assertPdfPageCap(pageCount, maxPages = PDF_MAX_PAGES) {
442
+ if (pageCount > maxPages) {
443
+ throw new ImportAssetError(3, `PDF has ${pageCount} pages, exceeding the ${maxPages}-page cap`);
444
+ }
445
+ }
446
+
447
+ // ============================================================================
448
+ // Shared hardened "sandboxed-render" browser helper (Decision 1 step 6's SVG
449
+ // execution canary + Decision 2 step 4's PDF page rasterizer). Built on
450
+ // agent-browser (already the bundled screenshot engine, DDR-088) rather than
451
+ // a hand-rolled CDP client — `network route '*' --abort` gives URL-pattern
452
+ // request interception that covers sub-resource loads (not just top-level
453
+ // navigation) and is not fooled by an IP-literal target the way a bare
454
+ // --host-resolver-rules hostname mapping would be.
455
+ // ============================================================================
456
+
457
+ function agentBrowserBin() {
458
+ return process.env.MAUDE_AGENT_BROWSER_BIN || 'agent-browser';
459
+ }
460
+
461
+ function runAgentBrowser(sessionName, args, { timeoutMs = 20_000 } = {}) {
462
+ return execFileSync(agentBrowserBin(), ['--session', sessionName, ...args], {
463
+ encoding: 'utf8',
464
+ timeout: timeoutMs,
465
+ stdio: ['ignore', 'pipe', 'pipe'],
466
+ });
467
+ }
468
+
469
+ /**
470
+ * Launch an isolated, network-denied agent-browser session, run `fn(session)`,
471
+ * and unconditionally close the session afterward. `fn` receives a small
472
+ * helper object, not the raw CLI, to keep every caller's hardening identical.
473
+ * Exported for reuse by DDR-173 Decision 6 (T12's logo-wordmark raster
474
+ * fallback) — same hardened session, no new browser automation surface.
475
+ */
476
+ export async function withSandboxedRender(fn, { timeoutMs = 20_000, initScriptPath = null } = {}) {
477
+ const sessionName = `maude-import-${createHash('sha256')
478
+ .update(String(Math.random()) + Date.now())
479
+ .digest('hex')
480
+ .slice(0, 12)}`;
481
+ try {
482
+ const openArgs = ['open'];
483
+ // The probe (if any) MUST be registered on the session-creating `open`
484
+ // call — it runs before ANY page script on every subsequent navigation
485
+ // in this session, which is what lets it observe a script tag executing
486
+ // inside a directly-navigated SVG document (see runSvgExecutionCanary).
487
+ if (initScriptPath) openArgs.push('--init-script', initScriptPath);
488
+ runAgentBrowser(sessionName, openArgs, { timeoutMs });
489
+ // Decision 2 — hard network denial via URL-pattern request interception
490
+ // (covers every resource type, not just top-level navigation, and is not
491
+ // fooled by an IP-literal target the way a bare --host-resolver-rules
492
+ // hostname mapping would be). Scoped to http(s):// specifically — a bare
493
+ // '*' route also aborts the `file://` navigation this helper itself needs
494
+ // to load the candidate SVG/PDF, which would break the mechanism
495
+ // entirely (verified live: a wildcard route 403s local file:// loads
496
+ // too). Never `--no-sandbox` — the OS sandbox stays on by default; this
497
+ // helper never passes it.
498
+ runAgentBrowser(sessionName, ['network', 'route', 'http://**', '--abort'], { timeoutMs });
499
+ runAgentBrowser(sessionName, ['network', 'route', 'https://**', '--abort'], { timeoutMs });
500
+ return await fn({
501
+ eval: (js) => runAgentBrowser(sessionName, ['eval', js], { timeoutMs }),
502
+ open: (url) => runAgentBrowser(sessionName, ['open', url], { timeoutMs }),
503
+ screenshot: (outPath) => runAgentBrowser(sessionName, ['screenshot', outPath], { timeoutMs }),
504
+ });
505
+ } finally {
506
+ try {
507
+ execFileSync(agentBrowserBin(), ['close', '--session', sessionName], {
508
+ stdio: 'ignore',
509
+ timeout: 10_000,
510
+ });
511
+ } catch {
512
+ /* best-effort cleanup */
513
+ }
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Decision 1, step 6 — real-browser execution canary. Navigates DIRECTLY to
519
+ * the SVGO-optimized, allowlist-sanitized file as the top-level document —
520
+ * NOT wrapped in an `<img src>` host page, which is a script-disabled
521
+ * context in every browser and would never observe an embedded `<script>`
522
+ * executing (verified live: an `<img>`-embedded malicious SVG never trips a
523
+ * probe regardless of content; direct navigation to the SVG file does). The
524
+ * probe is installed via an init script that runs before any page script on
525
+ * every navigation in this session, so a script tag inside the candidate
526
+ * document — if one survived sanitizing — sets the flag itself. Hard rejects
527
+ * (throws) on any signal. 15-second timeout (shares the helper's own budget).
528
+ */
529
+ export async function runSvgExecutionCanary(sanitizedSvg, { timeoutMs = 15_000 } = {}) {
530
+ const dir = mkdtempSync(join(tmpdir(), 'maude-import-svg-'));
531
+ const svgPath = join(dir, 'candidate.svg');
532
+ const probePath = join(dir, 'probe.js');
533
+ try {
534
+ writeFileSync(svgPath, sanitizedSvg);
535
+ writeFileSync(probePath, 'window.__MAUDE_IMPORT_CANARY__ = false;\n');
536
+ await withSandboxedRender(
537
+ async (session) => {
538
+ session.open(pathToFileURL(svgPath).href);
539
+ const tripped = session.eval('window.__MAUDE_IMPORT_CANARY__ === true').trim();
540
+ if (tripped === 'true') {
541
+ throw new ImportAssetError(3, 'this file could not be safely imported (canary tripped)');
542
+ }
543
+ },
544
+ { timeoutMs, initScriptPath: probePath }
545
+ );
546
+ } finally {
547
+ rmSync(dir, { recursive: true, force: true });
548
+ }
549
+ return sanitizedSvg;
550
+ }
551
+
552
+ /**
553
+ * Decision 2, step 4 (SUSPENDED — see DDR-167 addendum "PDF rasterization
554
+ * mechanism not viable as specified"). Live spike-verification at T10
555
+ * implementation time (per this DDR's own required contingency) found that
556
+ * navigating agent-browser's automation-driven Chromium directly to a
557
+ * `file://*.pdf` URL does NOT render page content — confirmed blank output
558
+ * across `chrome-headless-shell` AND full Google Chrome, both headed and
559
+ * headless, with a 2s settle wait. Automation-driven Chromium builds
560
+ * conventionally disable the PDF-viewer plugin/extension so headless tooling
561
+ * gets raw bytes instead of an interactive viewer — exactly the class of
562
+ * "doesn't behave as expected" this DDR anticipated and pre-authorized an
563
+ * honest failure for, rather than shipping code that would silently write
564
+ * blank/wrong PNGs into a user's asset store. This function intentionally
565
+ * throws — DO NOT implement a silent fallback (e.g. shelling out to
566
+ * `pdftoppm` if present) without a new DDR addendum re-deriving the
567
+ * PDF-rendering decision from scratch; DDR-167 explicitly rejected exactly
568
+ * that path for making behavior installation-dependent and untested.
569
+ */
570
+ export async function rasterizePdfPage(_pdfPath, _pageNumber, _opts = {}) {
571
+ throw new ImportAssetError(
572
+ 1,
573
+ 'PDF import is not yet available — the planned rasterization mechanism (headless-Chromium ' +
574
+ 'navigating a file:// PDF URL) does not render page content under agent-browser automation ' +
575
+ '(verified: blank output on both chrome-headless-shell and full Chrome). See DDR-167 for the ' +
576
+ 'finding and what a follow-up mechanism needs to satisfy before this ships.'
577
+ );
578
+ }
579
+
580
+ // ============================================================================
581
+ // Orchestration
582
+ // ============================================================================
583
+
584
+ /** Full SVG import: sanitize → canary → content-address → contained write. */
585
+ export async function importSvg(svgText, { root, designRootRel = '.design' }) {
586
+ const sanitized = sanitizeSvgAllowlist(svgText);
587
+ await runSvgExecutionCanary(sanitized);
588
+ return writeContainedAsset(root, designRootRel, Buffer.from(sanitized, 'utf8'), 'svg');
589
+ }
590
+
591
+ /**
592
+ * Full PDF import: single capped read → worker-isolated page-count → per-page
593
+ * rasterize via a contained temp copy → content-address + contained write
594
+ * each page. Returns one result per page.
595
+ *
596
+ * SUSPENDED (see rasterizePdfPage's doc comment + DDR-167 addendum) — the
597
+ * read/page-count/cap steps below are real, tested, reusable building blocks;
598
+ * only the rasterize step is blocked, so this fails loud there rather than
599
+ * pretending the whole pipeline is unavailable.
600
+ */
601
+ export async function importPdf(inputPath, { root, designRootRel = '.design' }) {
602
+ const buffer = readPdfCapped(inputPath);
603
+ const pageCount = await getPdfPageCountIsolated(buffer);
604
+ assertPdfPageCap(pageCount);
605
+
606
+ const dir = mkdtempSync(join(tmpdir(), 'maude-import-pdfsrc-'));
607
+ const tempPdfPath = join(dir, 'source.pdf');
608
+ const results = [];
609
+ try {
610
+ writeFileSync(tempPdfPath, buffer);
611
+ for (let page = 1; page <= pageCount; page += 1) {
612
+ const png = await rasterizePdfPage(tempPdfPath, page);
613
+ // Re-sniff — never trust the browser produced a real PNG just because
614
+ // it was asked to.
615
+ if (
616
+ !(
617
+ png.length >= 8 &&
618
+ png[0] === 0x89 &&
619
+ png[1] === 0x50 &&
620
+ png[2] === 0x4e &&
621
+ png[3] === 0x47
622
+ )
623
+ ) {
624
+ throw new ImportAssetError(4, `page ${page} did not produce a valid PNG`);
625
+ }
626
+ results.push(writeContainedAsset(root, designRootRel, png, 'png'));
627
+ }
628
+ } finally {
629
+ rmSync(dir, { recursive: true, force: true });
630
+ }
631
+ return results;
632
+ }
633
+
634
+ // ============================================================================
635
+ // Decision 4 (DDR-174, T15) — local raster (PNG/JPEG) ingestion. The vision-
636
+ // reconstruction flow's source image (a Figma-frame export) is a plain raster,
637
+ // not SVG/PDF — this is the "same asset-upload gate any other image upload
638
+ // already goes through" DDR-174 Decision 4 assumes exists, extended to a local
639
+ // CLI path (the browser drag-drop `POST /_api/asset` already accepts PNG/JPEG,
640
+ // just not from a bare filesystem path with no browser in the loop). No
641
+ // sanitize step is needed (raster pixels carry no markup/script surface the
642
+ // way SVG does) — containment + magic-byte re-sniff is the whole control.
643
+ // ============================================================================
644
+
645
+ export const RASTER_MAX_BYTES = 25 * 1024 * 1024;
646
+
647
+ const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
648
+ const JPEG_MAGIC = [0xff, 0xd8, 0xff];
649
+
650
+ /** Sniff the REAL format from bytes — never trust the input filename's extension. */
651
+ export function sniffRasterKind(bytes) {
652
+ if (PNG_MAGIC.every((b, i) => bytes[i] === b)) return 'png';
653
+ if (JPEG_MAGIC.every((b, i) => bytes[i] === b)) return 'jpg';
654
+ return null;
655
+ }
656
+
657
+ /**
658
+ * Realpath-then-open, LEAF-symlink rejection checked against the ORIGINAL
659
+ * (pre-resolution) path — mirrors `_import-tokens.mjs`'s `readTokenFileCapped`,
660
+ * the corrected form of this repo's own symlink check (T11 found and fixed a
661
+ * structurally-inert version of this same check elsewhere in this file,
662
+ * `readPdfCapped`, which walks the POST-resolution path and can therefore
663
+ * never observe a symlink — flagged there as a follow-up, not silently
664
+ * repeated here).
665
+ */
666
+ export function readRasterCapped(inputPath, maxBytes = RASTER_MAX_BYTES) {
667
+ const absOriginal = resolve(inputPath);
668
+ let leafStat;
669
+ try {
670
+ leafStat = lstatSync(absOriginal);
671
+ } catch (err) {
672
+ throw new ImportAssetError(4, `could not resolve path: ${err?.message ?? err}`);
673
+ }
674
+ if (leafStat.isSymbolicLink()) {
675
+ throw new ImportAssetError(3, `refusing a path whose target is a symlink: ${absOriginal}`);
676
+ }
677
+ let real;
678
+ try {
679
+ real = realpathSync(inputPath);
680
+ } catch (err) {
681
+ throw new ImportAssetError(4, `could not resolve path: ${err?.message ?? err}`);
682
+ }
683
+ let buf;
684
+ try {
685
+ buf = readFileSync(real);
686
+ } catch (err) {
687
+ throw new ImportAssetError(4, `could not read file: ${err?.message ?? err}`);
688
+ }
689
+ if (buf.length === 0) throw new ImportAssetError(4, 'file is empty');
690
+ if (buf.length > maxBytes) {
691
+ throw new ImportAssetError(
692
+ 4,
693
+ `raster image exceeds the ${Math.round(maxBytes / (1024 * 1024))} MB cap`
694
+ );
695
+ }
696
+ return buf;
697
+ }
698
+
699
+ /** Full raster import: capped symlink-safe read → magic-byte sniff → contained write. */
700
+ export function importRaster(inputPath, { root, designRootRel = '.design' }) {
701
+ const buf = readRasterCapped(inputPath);
702
+ const kind = sniffRasterKind(buf);
703
+ if (!kind) {
704
+ throw new ImportAssetError(
705
+ 5,
706
+ 'not a recognized raster image (expected a PNG or JPEG magic-byte signature)'
707
+ );
708
+ }
709
+ return writeContainedAsset(root, designRootRel, buf, kind);
710
+ }
711
+
712
+ // ============================================================================
713
+ // CLI entry
714
+ // ============================================================================
715
+
716
+ function parseArgv(argv) {
717
+ const out = { file: null, root: null, designRoot: '.design', kind: null, json: false };
718
+ for (let i = 0; i < argv.length; i += 1) {
719
+ const a = argv[i];
720
+ switch (a) {
721
+ case '--root':
722
+ out.root = argv[++i];
723
+ break;
724
+ case '--design-root':
725
+ out.designRoot = argv[++i];
726
+ break;
727
+ case '--kind':
728
+ out.kind = argv[++i];
729
+ break;
730
+ case '--json':
731
+ out.json = true;
732
+ break;
733
+ case '--help':
734
+ case '-h':
735
+ out.help = true;
736
+ break;
737
+ default:
738
+ if (a.startsWith('-')) throw new ImportAssetError(2, `unknown flag ${a}`);
739
+ if (out.file === null) out.file = a;
740
+ else throw new ImportAssetError(2, `unexpected extra arg ${a}`);
741
+ }
742
+ }
743
+ return out;
744
+ }
745
+
746
+ const HELP = `import-asset — hardened local-file SVG/PDF/raster ingestion (reached via \`maude design import-asset\`)
747
+
748
+ Usage:
749
+ maude design import-asset <local-path> --root <repo> [--design-root .design]
750
+ [--kind svg|pdf|raster] [--json]
751
+
752
+ Sanitizes (SVG), rasterizes (PDF, one PNG per page), or content-addresses as-is
753
+ (raster PNG/JPEG — DDR-174 T15's vision-reconstruction source image path) a
754
+ LOCAL file, writing the result(s) to <designRoot>/assets/<sha8>.<ext> and
755
+ printing the reference path(s). See DDR-167 for the full security posture.
756
+
757
+ Exit: 0 ok · 2 usage · 3 sanitize/validation reject · 4 read/parse error ·
758
+ 5 unsupported media type · 6 write/containment error · 1 other.`;
759
+
760
+ async function main() {
761
+ let opts;
762
+ try {
763
+ opts = parseArgv(process.argv.slice(2));
764
+ } catch (err) {
765
+ process.stderr.write(`import-asset: ${err.message}\n`);
766
+ process.exit(err instanceof ImportAssetError ? err.code : 2);
767
+ }
768
+ if (opts.help) {
769
+ process.stdout.write(`${HELP}\n`);
770
+ return;
771
+ }
772
+ if (!opts.file) {
773
+ process.stderr.write('import-asset: <local-path> required\n');
774
+ process.exit(2);
775
+ }
776
+ const root = opts.root || process.env.CLAUDE_PROJECT_DIR || process.cwd();
777
+ try {
778
+ let results;
779
+ const lower = opts.file.toLowerCase();
780
+ const defaultKind = lower.endsWith('.pdf')
781
+ ? 'pdf'
782
+ : lower.endsWith('.png') || lower.endsWith('.jpg') || lower.endsWith('.jpeg')
783
+ ? 'raster'
784
+ : 'svg';
785
+ const kind = opts.kind || defaultKind;
786
+ if (kind === 'pdf') {
787
+ results = await importPdf(opts.file, { root, designRootRel: opts.designRoot });
788
+ } else if (kind === 'raster' || kind === 'png' || kind === 'jpg') {
789
+ results = [importRaster(opts.file, { root, designRootRel: opts.designRoot })];
790
+ } else {
791
+ const text = readFileSync(opts.file, 'utf8');
792
+ results = [await importSvg(text, { root, designRootRel: opts.designRoot })];
793
+ }
794
+ if (opts.json) {
795
+ process.stdout.write(`${JSON.stringify(results)}\n`);
796
+ } else {
797
+ for (const r of results) process.stdout.write(`${r.ref}\n`);
798
+ }
799
+ } catch (err) {
800
+ process.stderr.write(`import-asset: ${err.message}\n`);
801
+ process.exit(err instanceof ImportAssetError ? err.code : 1);
802
+ }
803
+ }
804
+
805
+ // import.meta.main is the reliable "am I the entry module?" flag under bun
806
+ // --compile — the argv/url compare below falsely matches inside a standalone
807
+ // binary (every bundled module's import.meta.url collapses to the binary's
808
+ // own path), which would hijack the process before Bun.serve ever runs (the
809
+ // v0.38.0 "Starting…" hang class of bug). Fall back to the argv compare only
810
+ // for the plain-`node` CLI path (Node <24 leaves import.meta.main undefined).
811
+ const runDirectly =
812
+ import.meta.main ?? (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href);
813
+ if (runDirectly) {
814
+ main();
815
+ }