@volter/editor-core 0.5.57

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 (719) hide show
  1. package/BUNDLED_NOTICES +1527 -0
  2. package/LICENSE +686 -0
  3. package/LICENSE-APACHE +202 -0
  4. package/NOTICE +18 -0
  5. package/README.md +24 -0
  6. package/dist/build/chunk-JS4D4XZ3.js +175 -0
  7. package/dist/build/chunk-JS4D4XZ3.js.map +7 -0
  8. package/dist/build/vite-plugin-product-contributions.js +100 -0
  9. package/dist/build/vite-plugin-product-contributions.js.map +7 -0
  10. package/dist/build/vite-plugin-shared-react.js +25 -0
  11. package/dist/build/vite-plugin-shared-react.js.map +7 -0
  12. package/dist/build/vite-plugin-shared-three.js +86 -0
  13. package/dist/build/vite-plugin-shared-three.js.map +7 -0
  14. package/dist/server/chunk-KMJC7AGO.js +98 -0
  15. package/dist/server/chunk-KMJC7AGO.js.map +7 -0
  16. package/dist/server/chunk-PSILUMGS.js +46 -0
  17. package/dist/server/chunk-PSILUMGS.js.map +7 -0
  18. package/dist/server/frame-proxy.js +255 -0
  19. package/dist/server/frame-proxy.js.map +7 -0
  20. package/dist/server/open-browser.js +53 -0
  21. package/dist/server/open-browser.js.map +7 -0
  22. package/dist/server/process-shutdown.js +127 -0
  23. package/dist/server/process-shutdown.js.map +7 -0
  24. package/dist/server/session-registry.js +141 -0
  25. package/dist/server/session-registry.js.map +7 -0
  26. package/dist/server/spawn-opener.js +11 -0
  27. package/dist/server/spawn-opener.js.map +7 -0
  28. package/dist/server/worktree-identity.js +11 -0
  29. package/dist/server/worktree-identity.js.map +7 -0
  30. package/dist-server/packaged.mjs +88816 -0
  31. package/package.json +118 -0
  32. package/scripts/build-plugins.mjs +14 -0
  33. package/scripts/build-server.mjs +26 -0
  34. package/scripts/build-session.mjs +9 -0
  35. package/server/account-credentials.ts +60 -0
  36. package/server/account-service.ts +1434 -0
  37. package/server/adapter-region-includes.ts +47 -0
  38. package/server/asset-catalog-v2.ts +175 -0
  39. package/server/asset-history-snapshots.ts +196 -0
  40. package/server/asset-ledger-store.ts +195 -0
  41. package/server/asset-library-routes.ts +1595 -0
  42. package/server/blender-wasm-artifact.ts +430 -0
  43. package/server/boot-timings.ts +106 -0
  44. package/server/canonical-path.ts +50 -0
  45. package/server/checkout-workspace-preflight.ts +349 -0
  46. package/server/cloud-asset-catalog.ts +158 -0
  47. package/server/coding-inference-launch.ts +237 -0
  48. package/server/collaboration-account-client.ts +202 -0
  49. package/server/collaboration-attribution.ts +122 -0
  50. package/server/collaboration-session.ts +934 -0
  51. package/server/comfyui-bridge.ts +248 -0
  52. package/server/console-ledger.ts +499 -0
  53. package/server/creation-site-transform.ts +304 -0
  54. package/server/creation-site-write.ts +648 -0
  55. package/server/data-file-serialize.ts +158 -0
  56. package/server/editor-brand-html.ts +72 -0
  57. package/server/editor-control-socket.ts +366 -0
  58. package/server/editor-server-options.ts +190 -0
  59. package/server/editor-server.ts +1483 -0
  60. package/server/editor-sse.ts +352 -0
  61. package/server/engine-provenance.ts +169 -0
  62. package/server/engine-source-restart.ts +117 -0
  63. package/server/frame-bridge.ts +115 -0
  64. package/server/frame-proxy.ts +490 -0
  65. package/server/frame-workbench.ts +259 -0
  66. package/server/frontend-handoff.ts +229 -0
  67. package/server/game-globals-shadow.ts +113 -0
  68. package/server/gameplay-session-retention.ts +149 -0
  69. package/server/gameplay-sessions.ts +123 -0
  70. package/server/generation-jobs.ts +201 -0
  71. package/server/generation-reconciler.ts +137 -0
  72. package/server/generative-execution-context.ts +59 -0
  73. package/server/git-workflow.ts +612 -0
  74. package/server/harness-chat-caller.ts +36 -0
  75. package/server/harness-chat-service.ts +1681 -0
  76. package/server/idle-shutdown.ts +170 -0
  77. package/server/js-profiling-policy.ts +52 -0
  78. package/server/launcher-settings.ts +84 -0
  79. package/server/local-asset-catalog.ts +410 -0
  80. package/server/managed-account-defaults.ts +22 -0
  81. package/server/mock-control-plane.ts +540 -0
  82. package/server/model-import-conversion.ts +345 -0
  83. package/server/native-credential-store.ts +162 -0
  84. package/server/open-browser.ts +118 -0
  85. package/server/packaged.ts +1192 -0
  86. package/server/play-stall.ts +126 -0
  87. package/server/process-shutdown.ts +218 -0
  88. package/server/product-presets.ts +55 -0
  89. package/server/project-build-artifact.ts +95 -0
  90. package/server/project-components.ts +100 -0
  91. package/server/project-dependency-invalidation.ts +114 -0
  92. package/server/project-file-scan.ts +235 -0
  93. package/server/project-hmr-files.ts +215 -0
  94. package/server/project-install-roots.ts +210 -0
  95. package/server/project-kinds.ts +71 -0
  96. package/server/project-mcp-servers.ts +82 -0
  97. package/server/project-module-freshness.ts +109 -0
  98. package/server/project-module-instance.ts +375 -0
  99. package/server/project-optimize-deps-entries.ts +827 -0
  100. package/server/project-output-writer.ts +679 -0
  101. package/server/project-package-origin.ts +63 -0
  102. package/server/project-root-surface.ts +831 -0
  103. package/server/project-scratch-path.ts +47 -0
  104. package/server/project-script-hmr.ts +619 -0
  105. package/server/project-serving-plugins.ts +236 -0
  106. package/server/project-tools.ts +653 -0
  107. package/server/project-validation.ts +250 -0
  108. package/server/project-verbs.ts +127 -0
  109. package/server/project-view.ts +52 -0
  110. package/server/project-watch.ts +1134 -0
  111. package/server/project-work-coordinator.ts +288 -0
  112. package/server/provider-credentials.ts +331 -0
  113. package/server/recent-projects-store.ts +98 -0
  114. package/server/redact-secrets.ts +71 -0
  115. package/server/repository-presence.ts +267 -0
  116. package/server/routes/account.ts +357 -0
  117. package/server/routes/agents.ts +60 -0
  118. package/server/routes/assets.ts +154 -0
  119. package/server/routes/build.ts +13 -0
  120. package/server/routes/collaboration.ts +595 -0
  121. package/server/routes/configurations.ts +419 -0
  122. package/server/routes/context.ts +264 -0
  123. package/server/routes/control-plane.ts +1811 -0
  124. package/server/routes/logs.ts +272 -0
  125. package/server/routes/project-identity.ts +302 -0
  126. package/server/routes/project-open.ts +429 -0
  127. package/server/routes/project-source.ts +686 -0
  128. package/server/routes/project-state.ts +383 -0
  129. package/server/routes/relay.ts +1029 -0
  130. package/server/routes/route-helpers.ts +29 -0
  131. package/server/routes/served-modules.ts +138 -0
  132. package/server/routes/session-tabs.ts +196 -0
  133. package/server/routes/settings.ts +121 -0
  134. package/server/routes/share-control.ts +486 -0
  135. package/server/routes/themes.ts +116 -0
  136. package/server/routes/tools.ts +230 -0
  137. package/server/routes/worktrees.ts +595 -0
  138. package/server/scoped-game-css.ts +461 -0
  139. package/server/server-utils.ts +1194 -0
  140. package/server/session-product.ts +112 -0
  141. package/server/session-registry.ts +230 -0
  142. package/server/share-claims.ts +138 -0
  143. package/server/share-host.ts +526 -0
  144. package/server/share-session-gateway.ts +1362 -0
  145. package/server/share-tunnel.ts +123 -0
  146. package/server/spawn-opener.ts +74 -0
  147. package/server/support/play/log-format.ts +76 -0
  148. package/server/support/play/session-record.ts +199 -0
  149. package/server/support/project/build-discipline.ts +718 -0
  150. package/server/support/project/inspection-node.ts +119 -0
  151. package/server/support/project/log-naming.ts +54 -0
  152. package/server/support/project/provenance.ts +290 -0
  153. package/server/support/project/run-name.ts +18 -0
  154. package/server/support/project/session-journal.ts +830 -0
  155. package/server/support/project/shared.ts +327 -0
  156. package/server/tab-bootstrap.ts +62 -0
  157. package/server/tab-heartbeat.ts +477 -0
  158. package/server/tab-lifecycle.ts +550 -0
  159. package/server/tab-presence.ts +1508 -0
  160. package/server/team-agent-mirror.ts +95 -0
  161. package/server/twin-auth.ts +138 -0
  162. package/server/vendored-lock-recorder.ts +968 -0
  163. package/server/worktree-identity.ts +121 -0
  164. package/server/worktree-management.ts +398 -0
  165. package/server/worktree-retention.d.mts +13 -0
  166. package/server/worktree-retention.mjs +205 -0
  167. package/src/EditorContext.tsx +169 -0
  168. package/src/account.ts +333 -0
  169. package/src/action-registry.ts +532 -0
  170. package/src/active-product.ts +74 -0
  171. package/src/active-project.ts +155 -0
  172. package/src/adapter-editor-config.ts +25 -0
  173. package/src/adapter-observation.ts +49 -0
  174. package/src/animation/stage-transport.ts +379 -0
  175. package/src/animation/three-clips-subject.ts +172 -0
  176. package/src/api/asset-library-wire.ts +45 -0
  177. package/src/api/assets.ts +365 -0
  178. package/src/api/base.ts +10 -0
  179. package/src/api/build.ts +99 -0
  180. package/src/api/git-wire.ts +56 -0
  181. package/src/api/logs.ts +92 -0
  182. package/src/api/project-identity.ts +74 -0
  183. package/src/api/project-open.ts +355 -0
  184. package/src/api/project-source.ts +162 -0
  185. package/src/api/project-state.ts +107 -0
  186. package/src/api/relay.ts +268 -0
  187. package/src/api/settings.ts +36 -0
  188. package/src/api/themes.ts +45 -0
  189. package/src/api/worktrees.ts +257 -0
  190. package/src/asset-compare-core.ts +171 -0
  191. package/src/asset-compare.ts +294 -0
  192. package/src/asset-editor-context.tsx +100 -0
  193. package/src/asset-events.ts +97 -0
  194. package/src/asset-inspector-actions.ts +87 -0
  195. package/src/asset-preview-framing.ts +357 -0
  196. package/src/asset-preview.ts +2802 -0
  197. package/src/asset-selection-viewer-registry.ts +113 -0
  198. package/src/asset-selection.ts +133 -0
  199. package/src/asset-workflow/asset-capabilities.ts +344 -0
  200. package/src/asset-workflow/asset-import-jobs.ts +106 -0
  201. package/src/asset-workflow/asset-ledger-backend.ts +126 -0
  202. package/src/asset-workflow/asset-ledger.ts +156 -0
  203. package/src/asset-workflow/asset-materialization-report.ts +140 -0
  204. package/src/asset-workflow/asset-pack-manifest.ts +320 -0
  205. package/src/asset-workflow/asset-types.ts +142 -0
  206. package/src/asset-workflow/asset-workflow-quality.ts +61 -0
  207. package/src/asset-workflow/audio-preview-player.ts +193 -0
  208. package/src/asset-workflow/audio-waveform.ts +22 -0
  209. package/src/asset-workflow/cloud-asset-client.ts +263 -0
  210. package/src/asset-workflow/folder-preview.ts +345 -0
  211. package/src/asset-workflow/hosted-asset-materialization.ts +236 -0
  212. package/src/asset-workflow/image-view-scale.ts +32 -0
  213. package/src/asset-workflow/import-contract.ts +124 -0
  214. package/src/asset-workflow/ledger-write-lock.ts +244 -0
  215. package/src/asset-workflow/model-inspection.ts +818 -0
  216. package/src/asset-workflow/pixi-spritesheet.ts +197 -0
  217. package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
  218. package/src/asset-workflow/project-asset-commands.ts +23 -0
  219. package/src/asset-workflow/project-asset-health.ts +139 -0
  220. package/src/asset-workflow/project-asset-operations.ts +154 -0
  221. package/src/asset-workflow/project-asset-roots.ts +68 -0
  222. package/src/asset-workflow/project-content.ts +288 -0
  223. package/src/asset-workflow/project-source-index.ts +550 -0
  224. package/src/asset-workflow/thumbnail-system.ts +256 -0
  225. package/src/authoring/active-adapter.ts +199 -0
  226. package/src/authoring/active-systems.ts +422 -0
  227. package/src/authoring/adapter-key.ts +18 -0
  228. package/src/authoring/authoring-asset-url.ts +27 -0
  229. package/src/authoring/bootstrap-state.ts +49 -0
  230. package/src/authoring/boundary-authoring-adapter.ts +184 -0
  231. package/src/authoring/breakpoint-state.ts +43 -0
  232. package/src/authoring/canvas-scene-guides.ts +76 -0
  233. package/src/authoring/component-instance-root.ts +171 -0
  234. package/src/authoring/composite-authoring-adapter.ts +2110 -0
  235. package/src/authoring/consumer-actions.ts +520 -0
  236. package/src/authoring/css-numeric-style.ts +97 -0
  237. package/src/authoring/design-time-layers.ts +849 -0
  238. package/src/authoring/design-time-mount-registry.ts +235 -0
  239. package/src/authoring/design-time-settle.ts +343 -0
  240. package/src/authoring/edit-mode-authoring.ts +619 -0
  241. package/src/authoring/eyedropper-session.ts +60 -0
  242. package/src/authoring/instance-source-menu-register.ts +13 -0
  243. package/src/authoring/instance-source-menu.ts +135 -0
  244. package/src/authoring/layered-pick.ts +183 -0
  245. package/src/authoring/live-gesture-lock.ts +50 -0
  246. package/src/authoring/live-object-transform.ts +62 -0
  247. package/src/authoring/mount-failure-report.ts +154 -0
  248. package/src/authoring/mounted-root-subjects.ts +144 -0
  249. package/src/authoring/no-authoring-adapter.ts +55 -0
  250. package/src/authoring/null-inspection-subjects.tsx +75 -0
  251. package/src/authoring/object3d-document-persistence.ts +106 -0
  252. package/src/authoring/object3d-document-session-registry.ts +120 -0
  253. package/src/authoring/object3d-document-session.ts +1326 -0
  254. package/src/authoring/object3d-gesture-controller.ts +113 -0
  255. package/src/authoring/panel-authoring.ts +121 -0
  256. package/src/authoring/prefab-instance-inspector-section.tsx +237 -0
  257. package/src/authoring/prefab-instance-section-model.ts +55 -0
  258. package/src/authoring/project-authoring-session.ts +105 -0
  259. package/src/authoring/provenance.ts +99 -0
  260. package/src/authoring/quarks-particle-systems.ts +19 -0
  261. package/src/authoring/react-canvas-navigation.ts +255 -0
  262. package/src/authoring/react-design-canvas-style.ts +20 -0
  263. package/src/authoring/react-story-board.ts +937 -0
  264. package/src/authoring/selection-scope.ts +195 -0
  265. package/src/authoring/shell-document-ops.ts +169 -0
  266. package/src/authoring/shell-object3d-document-write-policy.ts +108 -0
  267. package/src/authoring/shell-viewport-policy.ts +48 -0
  268. package/src/authoring/source-object3d-authoring-adapter.ts +526 -0
  269. package/src/authoring/stories-scope.ts +35 -0
  270. package/src/authoring/story-board-chrome-fit.ts +107 -0
  271. package/src/authoring/story-board-presentation.ts +111 -0
  272. package/src/authoring/three-projection-core.ts +226 -0
  273. package/src/authoring/viewport-pick-context.ts +40 -0
  274. package/src/authoring/viewport-raycast.ts +240 -0
  275. package/src/authoring/viewport-tool-context.ts +73 -0
  276. package/src/authoring/world-canvas-viewport-state.ts +35 -0
  277. package/src/authoring/world-hidden-viewport.ts +152 -0
  278. package/src/authoring/world-pan-state.ts +198 -0
  279. package/src/authoring/world-session-state.ts +101 -0
  280. package/src/authoring/write-pipe.ts +173 -0
  281. package/src/availability-tick.ts +66 -0
  282. package/src/bitmap-label.ts +120 -0
  283. package/src/blender-tab-metrics.ts +161 -0
  284. package/src/board-open-actions.ts +20 -0
  285. package/src/boot-routing.ts +391 -0
  286. package/src/build-identity.ts +15 -0
  287. package/src/bytes-codec.ts +62 -0
  288. package/src/camera-authoring.ts +175 -0
  289. package/src/cancellation-reason.ts +58 -0
  290. package/src/canvas-preview-frames.ts +482 -0
  291. package/src/capture-camera-pose.ts +77 -0
  292. package/src/chrome-registry.ts +159 -0
  293. package/src/chrome-slot-registry.ts +91 -0
  294. package/src/collaboration-client.ts +264 -0
  295. package/src/collaboration-presence.ts +41 -0
  296. package/src/command-dispatch.ts +19 -0
  297. package/src/command-listener.ts +2743 -0
  298. package/src/command-registry.ts +70 -0
  299. package/src/component-board-registry.ts +205 -0
  300. package/src/component-states-registry.ts +200 -0
  301. package/src/components/AgentPresentationNotice.tsx +75 -0
  302. package/src/components/AlignToolbar.tsx +204 -0
  303. package/src/components/AppRoot.tsx +394 -0
  304. package/src/components/ApplicationMenus.tsx +415 -0
  305. package/src/components/AssetBrowser.tsx +2843 -0
  306. package/src/components/AssetEditorShell.tsx +216 -0
  307. package/src/components/AssetInspectorToolSection.tsx +124 -0
  308. package/src/components/BoardRulers.tsx +354 -0
  309. package/src/components/CameraInfo.tsx +81 -0
  310. package/src/components/CanvasSceneViewport.tsx +556 -0
  311. package/src/components/CapabilityCoverageSection.tsx +185 -0
  312. package/src/components/CenterDocuments.tsx +22 -0
  313. package/src/components/ChromeSlot.tsx +20 -0
  314. package/src/components/CodeView.tsx +470 -0
  315. package/src/components/CollaborationHeaderControl.css +74 -0
  316. package/src/components/CompactInspectorCard.tsx +190 -0
  317. package/src/components/CompactInspectorShell.tsx +39 -0
  318. package/src/components/ConsolePanel.css +55 -0
  319. package/src/components/ConsolePanel.tsx +270 -0
  320. package/src/components/DefaultEditorLayout.tsx +106 -0
  321. package/src/components/DocumentHeaderStrip.tsx +96 -0
  322. package/src/components/DocumentShelfRail.tsx +45 -0
  323. package/src/components/DocumentThumbnail.tsx +345 -0
  324. package/src/components/EditorLeaseGuard.tsx +372 -0
  325. package/src/components/ErrorBanner.tsx +123 -0
  326. package/src/components/FolderPreviewTile.tsx +159 -0
  327. package/src/components/GameHierarchy.tsx +3842 -0
  328. package/src/components/GameplaySessionTimeline.tsx +295 -0
  329. package/src/components/InspectionProjection.tsx +968 -0
  330. package/src/components/Inspector.tsx +233 -0
  331. package/src/components/InspectorCanvasPreview.tsx +35 -0
  332. package/src/components/InspectorFieldsSection.tsx +290 -0
  333. package/src/components/InspectorObjectPreview.tsx +57 -0
  334. package/src/components/InspectorStoriesSection.tsx +92 -0
  335. package/src/components/InspectorToolSection.tsx +96 -0
  336. package/src/components/InspectorTransformSection.tsx +245 -0
  337. package/src/components/LightExplorerPanel.tsx +433 -0
  338. package/src/components/MediaProperties.tsx +145 -0
  339. package/src/components/NonThreeAuthoringBootstrap.tsx +19 -0
  340. package/src/components/Object3DDocumentToolbar.css +100 -0
  341. package/src/components/Object3DDocumentToolbar.tsx +454 -0
  342. package/src/components/Object3DDocumentViewport.tsx +58 -0
  343. package/src/components/ProjectHeader.css +617 -0
  344. package/src/components/ProjectHeader.tsx +328 -0
  345. package/src/components/ProjectLayout.tsx +249 -0
  346. package/src/components/ReactCanvasControls.tsx +284 -0
  347. package/src/components/RootSelectionOverlay.tsx +3162 -0
  348. package/src/components/RootTextEditor.tsx +79 -0
  349. package/src/components/SaveStatus.tsx +70 -0
  350. package/src/components/StageHost.tsx +1954 -0
  351. package/src/components/StageOverlays.tsx +21 -0
  352. package/src/components/StartupErrorScreen.tsx +76 -0
  353. package/src/components/StartupLoadingScreen.tsx +65 -0
  354. package/src/components/StatsOverlay.tsx +78 -0
  355. package/src/components/SurfaceStateOverlay.tsx +24 -0
  356. package/src/components/ToolContributionSurfaces.tsx +65 -0
  357. package/src/components/ToolHost.tsx +370 -0
  358. package/src/components/ToolObject3DPreview.tsx +39 -0
  359. package/src/components/Toolbar.tsx +595 -0
  360. package/src/components/TransientHint.tsx +44 -0
  361. package/src/components/TransportStrip.tsx +174 -0
  362. package/src/components/VersionControlSection.tsx +470 -0
  363. package/src/components/VgaiLogo.css +83 -0
  364. package/src/components/VgaiLogo.tsx +35 -0
  365. package/src/components/ViewportControlsHint.tsx +60 -0
  366. package/src/components/ViewportFurniture.tsx +401 -0
  367. package/src/components/ViewportOverlay.tsx +145 -0
  368. package/src/components/ViewportOverlaysMenu.tsx +153 -0
  369. package/src/components/ViewportShadingMenu.tsx +300 -0
  370. package/src/components/ViewportViewMenu.tsx +101 -0
  371. package/src/components/WorkspaceDocumentSurface.tsx +204 -0
  372. package/src/components/WorkspaceUtilitySurface.tsx +22 -0
  373. package/src/components/WorktreeSwitcher.css +239 -0
  374. package/src/components/WorktreeSwitcher.tsx +890 -0
  375. package/src/components/account-documents.tsx +1162 -0
  376. package/src/components/asset-documents.tsx +812 -0
  377. package/src/components/asset-editor-persistence.ts +216 -0
  378. package/src/components/asset-selection-section.tsx +535 -0
  379. package/src/components/asset-thumbnails.tsx +312 -0
  380. package/src/components/asset-viewers/AudioViewer.tsx +201 -0
  381. package/src/components/asset-viewers/EntityModelDocument.tsx +122 -0
  382. package/src/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  383. package/src/components/asset-viewers/GenericJsonViewer.tsx +102 -0
  384. package/src/components/asset-viewers/ImageViewer.tsx +300 -0
  385. package/src/components/asset-viewers/JsonAssetDocument.tsx +93 -0
  386. package/src/components/asset-viewers/LiveModuleDocument.tsx +417 -0
  387. package/src/components/asset-viewers/LutAssetDocument.tsx +444 -0
  388. package/src/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  389. package/src/components/asset-viewers/Object3DPreview.tsx +359 -0
  390. package/src/components/asset-viewers/OnlineAssetDetail.tsx +421 -0
  391. package/src/components/asset-viewers/PasteboardModuleDocument.tsx +67 -0
  392. package/src/components/asset-viewers/QuarksAssetDocument.tsx +526 -0
  393. package/src/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  394. package/src/components/asset-viewers/SourceAssetViewer.tsx +281 -0
  395. package/src/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
  396. package/src/components/asset-viewers/VideoViewer.tsx +101 -0
  397. package/src/components/asset-viewers/shader-source.ts +144 -0
  398. package/src/components/asset-workflow.css +731 -0
  399. package/src/components/board-guides.ts +150 -0
  400. package/src/components/compact-inspector.css +539 -0
  401. package/src/components/core-utilities.tsx +90 -0
  402. package/src/components/editor-notifications.css +40 -0
  403. package/src/components/engine-workspace.css +274 -0
  404. package/src/components/inspector-preview-section.tsx +238 -0
  405. package/src/components/inspector-property-grouping.ts +64 -0
  406. package/src/components/inspector-revert-label.ts +20 -0
  407. package/src/components/inspector-selection.ts +42 -0
  408. package/src/components/inspector-stories-gating.ts +171 -0
  409. package/src/components/inspector-transform-subject.ts +11 -0
  410. package/src/components/inspector-transform.ts +75 -0
  411. package/src/components/kind-documents.tsx +500 -0
  412. package/src/components/palette-action-publisher.tsx +140 -0
  413. package/src/components/primitives/DraftColorInput.tsx +74 -0
  414. package/src/components/product-shell.css +1829 -0
  415. package/src/components/project-tool-documents.tsx +410 -0
  416. package/src/components/scene-documents.tsx +223 -0
  417. package/src/components/stage-keyboard.tsx +37 -0
  418. package/src/components/stage-overlay-set.tsx +107 -0
  419. package/src/components/stage-presence-markers.ts +478 -0
  420. package/src/components/standard-viewport-dressing.ts +417 -0
  421. package/src/components/status-contributions.tsx +403 -0
  422. package/src/components/tool-documents.tsx +309 -0
  423. package/src/components/tool-schema-form.tsx +262 -0
  424. package/src/components/use-after-paint.ts +41 -0
  425. package/src/components/use-project-image-assets.ts +86 -0
  426. package/src/components/viewport-header-controls.css +90 -0
  427. package/src/components/viewport-surface-status.tsx +55 -0
  428. package/src/components/workspace-history.ts +32 -0
  429. package/src/components/workspace-static-panel-registry.tsx +152 -0
  430. package/src/components/workspace-surfaces.css +621 -0
  431. package/src/components/world-documents.tsx +586 -0
  432. package/src/components/world-overlay-gestures.ts +1694 -0
  433. package/src/composite-screenshot.ts +1641 -0
  434. package/src/console-sync.ts +131 -0
  435. package/src/constraint-helper.ts +338 -0
  436. package/src/content-entry-source-registry.ts +184 -0
  437. package/src/coverage/authoring-seam-evidence.ts +300 -0
  438. package/src/coverage/canvas-reveal.ts +192 -0
  439. package/src/coverage/design-time-surfaces.ts +101 -0
  440. package/src/coverage/live-seam-evidence.ts +11 -0
  441. package/src/coverage/ontology-invariants.ts +466 -0
  442. package/src/coverage/session-vitals.ts +501 -0
  443. package/src/coverage/system-seam-evidence.ts +72 -0
  444. package/src/crash-null-boundary.ts +36 -0
  445. package/src/creation-site-edit.ts +1479 -0
  446. package/src/creation-site-registry.ts +160 -0
  447. package/src/delegate-harness-registry.ts +143 -0
  448. package/src/document-context-registry.ts +177 -0
  449. package/src/document-open-registry.ts +200 -0
  450. package/src/document-preview-source.ts +20 -0
  451. package/src/document-renderer-session.ts +138 -0
  452. package/src/editor-api.ts +46 -0
  453. package/src/editor-chrome-capture.ts +139 -0
  454. package/src/editor-commands.ts +164 -0
  455. package/src/editor-console.ts +563 -0
  456. package/src/editor-current-view.ts +82 -0
  457. package/src/editor-document-probe.ts +881 -0
  458. package/src/editor-git-client.ts +115 -0
  459. package/src/editor-host-door.ts +462 -0
  460. package/src/editor-hotkeys.ts +842 -0
  461. package/src/editor-lease-view.ts +39 -0
  462. package/src/editor-lease.ts +415 -0
  463. package/src/editor-mode.ts +19 -0
  464. package/src/editor-notifications.ts +140 -0
  465. package/src/editor-presence.ts +563 -0
  466. package/src/editor-presentation-activity.ts +50 -0
  467. package/src/editor-presentation-notice.ts +42 -0
  468. package/src/editor-runtime.tsx +145 -0
  469. package/src/editor-server-response.ts +86 -0
  470. package/src/editor-session-attribution.ts +75 -0
  471. package/src/editor-session-mode.ts +54 -0
  472. package/src/editor-shell-store.ts +1304 -0
  473. package/src/editor-state-facets.ts +74 -0
  474. package/src/editor-styles.css +33 -0
  475. package/src/editor-view-presentation.ts +810 -0
  476. package/src/editor-viewport.ts +5302 -0
  477. package/src/entity-lod.ts +31 -0
  478. package/src/entity-object.ts +91 -0
  479. package/src/files/file-provider.ts +62 -0
  480. package/src/files/project-files.ts +264 -0
  481. package/src/finders/index.ts +136 -0
  482. package/src/finders/scenes-from-entrypoint-selection.ts +387 -0
  483. package/src/frame/bridge.tsx +1408 -0
  484. package/src/frame/product.ts +77 -0
  485. package/src/gameplay-dom-recording.ts +319 -0
  486. package/src/gameplay-export-state.ts +14 -0
  487. package/src/gameplay-replay.ts +417 -0
  488. package/src/gameplay-session-time.ts +9 -0
  489. package/src/gameplay-sessions.ts +204 -0
  490. package/src/harness-chat-types.ts +253 -0
  491. package/src/hierarchy-component-marks.ts +298 -0
  492. package/src/hierarchy-drop.ts +91 -0
  493. package/src/hierarchy-expansion-state.ts +80 -0
  494. package/src/hierarchy-header-slot.ts +52 -0
  495. package/src/hierarchy-internals.ts +197 -0
  496. package/src/hierarchy-kind-icon.ts +217 -0
  497. package/src/hierarchy-mark-reader.ts +73 -0
  498. package/src/hierarchy-menu-registry.ts +67 -0
  499. package/src/hierarchy-node-rows.ts +307 -0
  500. package/src/hierarchy-panel-view.ts +280 -0
  501. package/src/hierarchy-projection.ts +76 -0
  502. package/src/hierarchy-row-cache.ts +243 -0
  503. package/src/hierarchy-row-model.ts +308 -0
  504. package/src/hierarchy-rows.ts +11 -0
  505. package/src/hierarchy-walk.ts +86 -0
  506. package/src/history/editor-session.ts +25 -0
  507. package/src/history/history-commands.ts +147 -0
  508. package/src/history/history-delegate.ts +163 -0
  509. package/src/history/history-limit-notices.ts +43 -0
  510. package/src/history/history-service.ts +1173 -0
  511. package/src/history/persistence-coordinator.ts +35 -0
  512. package/src/history/project-file-history.ts +386 -0
  513. package/src/history/project-root-history-backends.ts +139 -0
  514. package/src/history/resource-registry.ts +209 -0
  515. package/src/history/snapshot-store.ts +103 -0
  516. package/src/history/source-history-backend.ts +546 -0
  517. package/src/history/types.ts +124 -0
  518. package/src/hmr-registration-group.ts +67 -0
  519. package/src/hmr-stable-react-context.ts +23 -0
  520. package/src/hotkeys.ts +188 -0
  521. package/src/inference-diagnostics.ts +69 -0
  522. package/src/initial-project.ts +80 -0
  523. package/src/inspection/active-subject.ts +578 -0
  524. package/src/inspection/active-surface.ts +142 -0
  525. package/src/inspection/compose.ts +1064 -0
  526. package/src/inspection/display.ts +170 -0
  527. package/src/inspection/document-subject.ts +109 -0
  528. package/src/inspection/game-subject.ts +85 -0
  529. package/src/inspection/model.ts +542 -0
  530. package/src/inspection/null-subject.ts +115 -0
  531. package/src/inspection/serialize.ts +357 -0
  532. package/src/inspection/use-active-inspection.ts +180 -0
  533. package/src/inspector-presentation.ts +201 -0
  534. package/src/inspector-section-registry.ts +221 -0
  535. package/src/instance-source-actions.ts +163 -0
  536. package/src/instanced-presentation.ts +164 -0
  537. package/src/js-heap.ts +71 -0
  538. package/src/key-actions.ts +91 -0
  539. package/src/keymap-presets.ts +401 -0
  540. package/src/layout-policy.ts +31 -0
  541. package/src/learn-links.ts +73 -0
  542. package/src/light-explorer-model.ts +134 -0
  543. package/src/live-canvas-frame.ts +55 -0
  544. package/src/live-document.ts +279 -0
  545. package/src/live-module-source.ts +230 -0
  546. package/src/live-session-registry.ts +220 -0
  547. package/src/live-transition.ts +633 -0
  548. package/src/manifest-project.ts +107 -0
  549. package/src/model-thumbnail.ts +523 -0
  550. package/src/native-selection-style.ts +202 -0
  551. package/src/object3d-document-write-policy.ts +137 -0
  552. package/src/packaged-runtime.ts +108 -0
  553. package/src/palettes/maya.palette.json +57 -0
  554. package/src/palettes/substance.palette.json +57 -0
  555. package/src/pasteboard-module.ts +81 -0
  556. package/src/performance-profiler.ts +367 -0
  557. package/src/performance-sources.ts +69 -0
  558. package/src/play-boot-phase.ts +145 -0
  559. package/src/presentation-surface.ts +248 -0
  560. package/src/project-adapter.ts +1143 -0
  561. package/src/project-asset-refresh.ts +26 -0
  562. package/src/project-declaration-refresh.ts +31 -0
  563. package/src/project-local-state.ts +118 -0
  564. package/src/project-manager.ts +243 -0
  565. package/src/project-module-changes.ts +163 -0
  566. package/src/project-module-split.ts +266 -0
  567. package/src/project-provenance.ts +115 -0
  568. package/src/project-ready.ts +42 -0
  569. package/src/project-session-reset.ts +67 -0
  570. package/src/project-shape.ts +68 -0
  571. package/src/project-tool-discovery.ts +66 -0
  572. package/src/project-tools.ts +107 -0
  573. package/src/project-work-types.ts +149 -0
  574. package/src/projection/three.ts +898 -0
  575. package/src/projection/types.ts +44 -0
  576. package/src/readiness.ts +113 -0
  577. package/src/reflection-probe-helper.ts +142 -0
  578. package/src/reported-play-state.ts +90 -0
  579. package/src/resolve-relative-specifier.ts +33 -0
  580. package/src/scene-document-plan.ts +315 -0
  581. package/src/scene-framing.ts +315 -0
  582. package/src/scene-live-open.ts +210 -0
  583. package/src/scene-view-fog.ts +89 -0
  584. package/src/scoped-game-css.ts +152 -0
  585. package/src/session-orphan-record.ts +193 -0
  586. package/src/session-tombstone.ts +126 -0
  587. package/src/settings/settings-provider.ts +82 -0
  588. package/src/settings-store.ts +345 -0
  589. package/src/shared-view-restore.ts +42 -0
  590. package/src/shell-store-door.ts +45 -0
  591. package/src/source-conflict.ts +122 -0
  592. package/src/spatial-handle-visuals.ts +332 -0
  593. package/src/stage-context.ts +341 -0
  594. package/src/stage-store-registry.ts +63 -0
  595. package/src/stale-chunk-recovery.ts +34 -0
  596. package/src/state-report-deferral.ts +73 -0
  597. package/src/storage/host-files-storage.ts +97 -0
  598. package/src/storage/http-storage.ts +174 -0
  599. package/src/storage/index.ts +75 -0
  600. package/src/storage/mem-storage.ts +149 -0
  601. package/src/storage/path-lock.ts +44 -0
  602. package/src/storage/paths.ts +26 -0
  603. package/src/storage/types.ts +116 -0
  604. package/src/stories/StoryComponentThumbnail.tsx +184 -0
  605. package/src/stories/StoryPreviewMount.tsx +306 -0
  606. package/src/stories/component-content-source.tsx +106 -0
  607. package/src/stories/component-states-source.ts +78 -0
  608. package/src/stories/compose-project-stories.ts +255 -0
  609. package/src/stories/pixi-story-model.ts +30 -0
  610. package/src/stories/prefabs-finder.ts +54 -0
  611. package/src/stories/prefabs-from-stories.ts +182 -0
  612. package/src/stories/project-story-discovery.ts +78 -0
  613. package/src/stories/project-story-regions.ts +24 -0
  614. package/src/stories/story-actions.ts +54 -0
  615. package/src/stories/story-capture-command.ts +124 -0
  616. package/src/stories/story-capture.ts +584 -0
  617. package/src/stories/story-declared-medium.ts +126 -0
  618. package/src/stories/story-discovery.ts +176 -0
  619. package/src/stories/story-document-openers.ts +36 -0
  620. package/src/stories/story-dom-runtime.ts +78 -0
  621. package/src/stories/story-grouping.ts +111 -0
  622. package/src/stories/story-lane.ts +88 -0
  623. package/src/stories/story-mount-turn.ts +27 -0
  624. package/src/stories/story-opener.ts +260 -0
  625. package/src/stories/story-pixi-preview.ts +408 -0
  626. package/src/stories/story-presentation.ts +215 -0
  627. package/src/stories/story-registry.ts +517 -0
  628. package/src/stories/story-three-preview.ts +807 -0
  629. package/src/stories/three-story-model.ts +96 -0
  630. package/src/story-three-preview-runtime.ts +56 -0
  631. package/src/surface-keyboard.ts +112 -0
  632. package/src/surface-state.ts +124 -0
  633. package/src/tab-bootstrap.js +628 -0
  634. package/src/tab-census.ts +198 -0
  635. package/src/tab-lifecycle-client.ts +196 -0
  636. package/src/theme-library.ts +844 -0
  637. package/src/theme-preference.ts +378 -0
  638. package/src/theme.css +3437 -0
  639. package/src/three-viewport/bone-selection-highlight.ts +119 -0
  640. package/src/three-viewport/camera-fit.ts +41 -0
  641. package/src/three-viewport/interactive-renderer.ts +132 -0
  642. package/src/three-viewport/selection-brackets.ts +310 -0
  643. package/src/three-viewport/selection-outline.ts +183 -0
  644. package/src/three-viewport/skeleton-helper.ts +61 -0
  645. package/src/three-viewport/source-color.ts +167 -0
  646. package/src/three-viewport/studio-environment.ts +96 -0
  647. package/src/three-viewport-presentation.ts +23 -0
  648. package/src/tool-contribution-play.ts +74 -0
  649. package/src/tool-loader.ts +1798 -0
  650. package/src/transform-mode-request.ts +57 -0
  651. package/src/transient-hint.ts +78 -0
  652. package/src/trigger-volume-helper.ts +116 -0
  653. package/src/ui-source/adapter-region-includes.ts +241 -0
  654. package/src/ui-source/ensure-import.ts +132 -0
  655. package/src/ui-source/file-region-resolver.ts +302 -0
  656. package/src/ui-source/inspect.ts +775 -0
  657. package/src/ui-source/oid-transform.ts +1845 -0
  658. package/src/ui-source/plan-csf-story.ts +196 -0
  659. package/src/ui-source/plan-extract-component.ts +421 -0
  660. package/src/ui-source/plan-fork-component.ts +621 -0
  661. package/src/ui-source/plan-named-style.ts +95 -0
  662. package/src/ui-source/plan-source-edit.ts +353 -0
  663. package/src/ui-source/r3f-contract-resolver.ts +327 -0
  664. package/src/ui-source/r3f-diagnostic-index.ts +165 -0
  665. package/src/ui-source/r3f-environment-binding.ts +133 -0
  666. package/src/ui-source/r3f-joint-binding.ts +251 -0
  667. package/src/ui-source/r3f-lod-binding.ts +91 -0
  668. package/src/ui-source/r3f-particle-binding.ts +235 -0
  669. package/src/ui-source/r3f-physics-binding.ts +483 -0
  670. package/src/ui-source/r3f-project-contracts.ts +444 -0
  671. package/src/ui-source/relative-import-specifier.ts +34 -0
  672. package/src/ui-source/reparent-guard.ts +544 -0
  673. package/src/ui-source/source-edit-request.ts +97 -0
  674. package/src/ui-source/source-write-backend.ts +618 -0
  675. package/src/ui-source/syntactic-prop-specs.ts +296 -0
  676. package/src/ui-source/tier-source-write-backend.ts +279 -0
  677. package/src/ui-source/ts-ast.ts +171 -0
  678. package/src/ui-source/utility-class-support.ts +169 -0
  679. package/src/ui-source/write-component-default.ts +133 -0
  680. package/src/ui-source/writer.ts +1989 -0
  681. package/src/viewport-activation-timings.ts +840 -0
  682. package/src/viewport-authoring-policy.ts +186 -0
  683. package/src/viewport-controls-hint.ts +57 -0
  684. package/src/viewport-door.ts +237 -0
  685. package/src/viewport-shading-boundary.ts +12 -0
  686. package/src/vite-error-surface.ts +141 -0
  687. package/src/wait-until.ts +37 -0
  688. package/src/workspace-areas.ts +156 -0
  689. package/src/workspace-aux-commands.ts +11 -0
  690. package/src/workspace-available-documents.ts +127 -0
  691. package/src/workspace-core-utilities.ts +31 -0
  692. package/src/workspace-document-ids.ts +59 -0
  693. package/src/workspace-document-registry.ts +612 -0
  694. package/src/workspace-document-restore.ts +145 -0
  695. package/src/workspace-host-commands.ts +141 -0
  696. package/src/workspace-persistence-gate.ts +40 -0
  697. package/src/workspace-play-utilities.ts +44 -0
  698. package/src/workspace-presets.ts +425 -0
  699. package/src/workspace-regions.ts +294 -0
  700. package/src/workspace-state-persistence.ts +536 -0
  701. package/src/workspace-static-panels.ts +73 -0
  702. package/src/workspace-status-registry.ts +121 -0
  703. package/src/workspace-style.ts +239 -0
  704. package/src/workspace-utility-commands.ts +74 -0
  705. package/src/workspace-utility-registry.ts +263 -0
  706. package/src/workspace-viewport-rect.ts +97 -0
  707. package/src/world-adoption.ts +115 -0
  708. package/src/world-document-routing.ts +104 -0
  709. package/vite-plugin-creation-site-write.ts +184 -0
  710. package/vite-plugin-creation-site.ts +80 -0
  711. package/vite-plugin-game-static.ts +303 -0
  712. package/vite-plugin-module-doorways.ts +336 -0
  713. package/vite-plugin-product-contributions.ts +197 -0
  714. package/vite-plugin-project-game-static.ts +125 -0
  715. package/vite-plugin-project-jsx-js.ts +65 -0
  716. package/vite-plugin-project-root-absolute-assets.ts +359 -0
  717. package/vite-plugin-shared-react.ts +420 -0
  718. package/vite-plugin-shared-three.ts +203 -0
  719. package/vite-plugin-ui-oid.ts +2106 -0
@@ -0,0 +1,1194 @@
1
+ /**
2
+ * Pure, dependency-light helpers for the editor server.
3
+ *
4
+ * Everything here is side-effect free and unit-testable (see
5
+ * packages/editor/test/server-security.test.ts). The route handlers in
6
+ * editor-server.ts / asset-library-routes.ts delegate their security and
7
+ * correctness checks to these functions so the logic can be verified in
8
+ * isolation.
9
+ */
10
+
11
+ import { existsSync } from 'node:fs';
12
+ import { realpath } from 'node:fs/promises';
13
+ import { createRequire } from 'node:module';
14
+ import { dirname, join, resolve, sep } from 'node:path';
15
+ import { type CommandResult, relayCommandTimeoutMs } from '@volter/editor-sdk/session/command-table';
16
+ import type { UnresolvedConsoleSummary } from './console-ledger';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Path containment (S1 / S5 / S6)
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * True if `child` resolves to the same path as `parent`, or to a path strictly
24
+ * inside it. Defends against `..` traversal and sibling-prefix attacks where a
25
+ * naive `startsWith` would accept `/a/public-secrets` as inside `/a/public`.
26
+ */
27
+ export function isPathInside(parent: string, child: string): boolean {
28
+ const resolvedParent = resolve(parent);
29
+ const resolvedChild = resolve(child);
30
+ if (resolvedChild === resolvedParent) return true;
31
+ const parentWithSep = resolvedParent.endsWith(sep) ? resolvedParent : resolvedParent + sep;
32
+ return resolvedChild.startsWith(parentWithSep);
33
+ }
34
+
35
+ /** Resolve both sides through the filesystem before accepting containment.
36
+ * Lexical containment alone lets an in-root symlink reach another worktree or
37
+ * an arbitrary host directory. Existing read targets must pass this check
38
+ * immediately before bytes are read. */
39
+ export async function isCanonicalPathInside(parent: string, child: string): Promise<boolean> {
40
+ if (!isPathInside(parent, child)) return false;
41
+ try {
42
+ const [canonicalParent, canonicalChild] = await Promise.all([
43
+ realpath(parent),
44
+ realpath(child),
45
+ ]);
46
+ return isPathInside(canonicalParent, canonicalChild);
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /** Validate the directory chain used to create or replace a file. The final
53
+ * leaf may not exist yet, so the nearest existing ancestor is canonicalized.
54
+ * This rejects an intermediate symlink that would carry an atomic temp-file
55
+ * write outside the project root. */
56
+ export async function isCanonicalWritePathInside(parent: string, child: string): Promise<boolean> {
57
+ if (!isPathInside(parent, child)) return false;
58
+ let ancestor = dirname(child);
59
+ for (;;) {
60
+ try {
61
+ const [canonicalParent, canonicalAncestor] = await Promise.all([
62
+ realpath(parent),
63
+ realpath(ancestor),
64
+ ]);
65
+ return isPathInside(canonicalParent, canonicalAncestor);
66
+ } catch (error) {
67
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return false;
68
+ const next = dirname(ancestor);
69
+ if (next === ancestor) return false;
70
+ ancestor = next;
71
+ }
72
+ }
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Asset source + host allowlists (S1 / S2)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ export const ALLOWED_ASSET_SOURCES = ['polyhaven', 'ambientcg', 'local'] as const;
80
+ export type AllowedAssetSource = (typeof ALLOWED_ASSET_SOURCES)[number];
81
+
82
+ /** Allowlist the `source` segment so it can never escape the library directory. */
83
+ export function isAllowedAssetSource(source: unknown): source is AllowedAssetSource {
84
+ return (
85
+ typeof source === 'string' && (ALLOWED_ASSET_SOURCES as readonly string[]).includes(source)
86
+ );
87
+ }
88
+
89
+ /** Known CDN / API domains the server is permitted to fetch from. */
90
+ export const ASSET_HOST_ALLOWLIST = [
91
+ 'polyhaven.com',
92
+ 'polyhaven.org',
93
+ 'ambientcg.com',
94
+ 'struffelproduction.com', // ambientCG's download CDN
95
+ ];
96
+
97
+ /**
98
+ * True if `host` is an IPv4/IPv6 literal inside a private, loopback,
99
+ * link-local, or otherwise non-routable range. Used to block SSRF to metadata
100
+ * endpoints (e.g. 169.254.169.254) and internal services.
101
+ */
102
+ export function isPrivateOrLinkLocalHost(host: string): boolean {
103
+ const h = host.replace(/^\[|\]$/g, '').toLowerCase();
104
+
105
+ if (h === 'localhost' || h.endsWith('.localhost')) return true;
106
+
107
+ // IPv6 loopback / link-local / unique-local.
108
+ if (h === '::1' || h === '::') return true;
109
+ if (h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd')) return true;
110
+ // IPv4-mapped IPv6, e.g. ::ffff:169.254.169.254
111
+ const mapped = h.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
112
+ if (mapped) return isPrivateOrLinkLocalHost(mapped[1]!);
113
+
114
+ const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
115
+ if (!m) return false;
116
+ const a = Number(m[1]);
117
+ const b = Number(m[2]);
118
+ if (a === 0 || a === 10 || a === 127) return true; // 0.x, private, loopback
119
+ if (a === 169 && b === 254) return true; // link-local (cloud metadata)
120
+ if (a === 172 && b >= 16 && b <= 31) return true; // private
121
+ if (a === 192 && b === 168) return true; // private
122
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
123
+ if (a >= 224) return true; // multicast / reserved
124
+ return false;
125
+ }
126
+
127
+ /**
128
+ * Validate a body-supplied download URL: must be HTTPS, point at an allowlisted
129
+ * CDN host, and not resolve to a private / link-local literal IP. (S2)
130
+ */
131
+ export function isAllowedAssetHost(urlString: string): boolean {
132
+ let u: URL;
133
+ try {
134
+ u = new URL(urlString);
135
+ } catch {
136
+ return false;
137
+ }
138
+ if (u.protocol !== 'https:') return false;
139
+ const host = u.hostname.toLowerCase();
140
+ if (isPrivateOrLinkLocalHost(host)) return false;
141
+ return ASSET_HOST_ALLOWLIST.some((d) => host === d || host.endsWith(`.${d}`));
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // AppleScript escaping (S4)
146
+ // ---------------------------------------------------------------------------
147
+
148
+ // Matches ASCII control characters (U+0000–U+001F).
149
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: this security boundary intentionally strips the full ASCII control range.
150
+ const CONTROL_CHARS = /[\u0000-\u001f]/g;
151
+
152
+ /**
153
+ * Escape a string for safe inclusion inside a double-quoted AppleScript literal.
154
+ * Backslashes must be doubled BEFORE quotes are escaped, otherwise an injected
155
+ * trailing backslash neutralises the closing quote. Control characters are
156
+ * stripped so they can't break out of the `-e` argument.
157
+ */
158
+ export function escapeAppleScriptString(s: string): string {
159
+ return s.replace(CONTROL_CHARS, ' ').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Content-Disposition filename sanitization (S7)
164
+ // ---------------------------------------------------------------------------
165
+
166
+ // Control chars, double-quote, and backslash are unsafe in a quoted filename.
167
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: content-disposition must reject the full ASCII control range.
168
+ const UNSAFE_FILENAME_CHARS = /[\u0000-\u001f"\\]/g;
169
+
170
+ /**
171
+ * Reduce an arbitrary name to a safe `filename="..."` value: basename only, no
172
+ * path separators, quotes, backslashes, or control characters.
173
+ */
174
+ export function sanitizeContentDispositionFilename(name: string): string {
175
+ const base = name.split(/[\\/]/).pop() ?? '';
176
+ const cleaned = base.replace(UNSAFE_FILENAME_CHARS, '').trim();
177
+ return cleaned || 'download';
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // /@fs served-extension allowlist (S5)
182
+ // ---------------------------------------------------------------------------
183
+
184
+ export const SERVABLE_FS_EXTENSIONS: ReadonlySet<string> = new Set([
185
+ 'ts',
186
+ 'tsx',
187
+ 'js',
188
+ 'jsx',
189
+ 'mjs',
190
+ 'cjs',
191
+ 'json',
192
+ 'wasm',
193
+ 'css',
194
+ 'glsl',
195
+ 'vert',
196
+ 'frag',
197
+ ]);
198
+
199
+ /** Restrict /@fs serving to source/asset file types (never .env, keys, etc.). */
200
+ export function isServableFsExtension(ext: string): boolean {
201
+ return SERVABLE_FS_EXTENSIONS.has(ext.toLowerCase());
202
+ }
203
+
204
+ /** `/@fs` is a module transport, not a general project-file browser. Keep the
205
+ * private editor/Git estates and dotenv variants out even when their final
206
+ * suffix (for example `.vgai/session.json` or `.env.production.json`) would
207
+ * otherwise pass the source-extension allowlist. */
208
+ export function isServableFsPath(path: string): boolean {
209
+ return !path.split(/[\\/]+/).some((segment) => {
210
+ const normalized = segment.toLowerCase();
211
+ return normalized === '.vgai' || normalized === '.git' || normalized.startsWith('.env');
212
+ });
213
+ }
214
+
215
+ /** A path inside a Vite CACHE DIRECTORY (`node_modules/.vite`,
216
+ * `node_modules/.vite-editor/v2-<key>`, …), which holds the dependency
217
+ * optimizer's OUTPUT.
218
+ *
219
+ * Those files are written asynchronously: on a cold boot the browser requests
220
+ * `deps/react-dom_client.js?v=<hash>` while the optimizer is still bundling it,
221
+ * and Vite's own middleware is the owner that WAITS for the run to finish (and
222
+ * answers `504 Outdated Optimize Dep`, which makes the client reload, when the
223
+ * hash went stale). A `stat`-based "this module is missing" answer is therefore
224
+ * wrong here even though the file genuinely is not on disk yet — it races the
225
+ * optimizer and reports a transient absence as a permanent one.
226
+ *
227
+ * Measured 2026-08-20: the missing-module 404 below fired on
228
+ * `react_jsx-dev-runtime.js` and `react-dom_client.js` mid-optimization, so the
229
+ * editor shell never got React, the page sat on "Opening project…" forever, and
230
+ * `vgai doctor` waited out its whole budget for a play control that could never
231
+ * mount. */
232
+ export function isViteDepCachePath(path: string): boolean {
233
+ return path.split(/[\\/]+/).some((segment) => segment.toLowerCase().startsWith('.vite'));
234
+ }
235
+
236
+ /** WRITABLE project-root `.vgai` paths — the provenance ledger only.
237
+ * Session ownership, collaboration persistence, catalogs and future private
238
+ * metadata must never become reachable merely because they share a directory.
239
+ *
240
+ * Deliberately NARROWER than {@link isReadableVgaiPath}: run evidence below is
241
+ * readable and not writable, because its project-owned Node helper files real
242
+ * invocations and letting the panel write would let it invent a run nothing
243
+ * ran. */
244
+ export function isPublicVgaiLedgerPath(path: string): boolean {
245
+ return path === '.vgai/provenance.json';
246
+ }
247
+
248
+ /** Project-root editor metadata/cache paths the browser may regenerate. */
249
+ export function isWritableVgaiEditorPath(path: string): boolean {
250
+ return (
251
+ isPublicVgaiLedgerPath(path) ||
252
+ path === '.vgai/thumbnails.json' ||
253
+ /^\.vgai\/cache\/document-previews\/[a-f0-9]{64}\.png$/.test(path)
254
+ );
255
+ }
256
+
257
+ /** READABLE project-root `.vgai` paths — the public ledger only.
258
+ *
259
+ * Every other `.vgai/` file stays unreachable, which is why this is an
260
+ * allowlist of literals rather than a prefix. */
261
+ export function isReadableVgaiPath(path: string): boolean {
262
+ return isWritableVgaiEditorPath(path);
263
+ }
264
+
265
+ export const PROJECT_RESOURCE_EXTENSIONS: ReadonlySet<string> = new Set([
266
+ 'avif',
267
+ 'bin',
268
+ 'bmp',
269
+ 'csv',
270
+ 'cube',
271
+ 'dds',
272
+ 'exr',
273
+ 'fbx',
274
+ 'flac',
275
+ 'frag',
276
+ 'gif',
277
+ 'glb',
278
+ 'gltf',
279
+ 'glsl',
280
+ 'hdr',
281
+ 'jpeg',
282
+ 'jpg',
283
+ 'json',
284
+ 'ktx2',
285
+ 'm4a',
286
+ 'mp3',
287
+ 'mp4',
288
+ 'mtl',
289
+ 'obj',
290
+ 'ogg',
291
+ 'ply',
292
+ 'spv',
293
+ 'stl',
294
+ 'svg',
295
+ 'tga',
296
+ 'tmj',
297
+ 'txt',
298
+ 'vert',
299
+ 'wasm',
300
+ 'wav',
301
+ 'webm',
302
+ 'webp',
303
+ 'yaml',
304
+ 'yml',
305
+ ]);
306
+
307
+ /**
308
+ * Project-owned Asset Lab documents may span ordinary authored resources, but
309
+ * they may not turn the document serializer into a second source-code or
310
+ * project-control writer. Keep this route below the two estates where authored
311
+ * resources live and leave executable source to the checksum-guarded
312
+ * `/__ui-source/*` seam.
313
+ */
314
+ export function isWritableProjectResourcePath(path: string): boolean {
315
+ if (path === '.vgai/thumbnails.json') return true;
316
+ if (!path || path.startsWith('/') || path.includes('\\') || path.includes('\0')) return false;
317
+ const segments = path.split('/');
318
+ if (
319
+ segments.some((segment) => {
320
+ const normalized = segment.toLowerCase();
321
+ return (
322
+ !segment ||
323
+ segment === '.' ||
324
+ segment === '..' ||
325
+ segment.startsWith('.') ||
326
+ normalized === 'node_modules' ||
327
+ normalized === 'vendor'
328
+ );
329
+ })
330
+ ) {
331
+ return false;
332
+ }
333
+ if (segments[0] !== 'src' && segments[0] !== 'public') return false;
334
+ const filename = segments.at(-1) ?? '';
335
+ const extension = filename.includes('.') ? (filename.split('.').at(-1) ?? '').toLowerCase() : '';
336
+ return PROJECT_RESOURCE_EXTENSIONS.has(extension);
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Origin allowlist for mutating editor routes (S3)
341
+ // ---------------------------------------------------------------------------
342
+
343
+ /**
344
+ * The origin of the Code-OSS DESKTOP workbench page — Electron's privileged
345
+ * `vscode-file` scheme with the fixed authority it mints for the app root. It is
346
+ * the ONE origin other than this server's own that ever hosts the editor
347
+ * (docs/CODE-OSS.md §Desktop): on desktop the workbench is loaded off disk by
348
+ * Electron and the session stays on loopback http, so the two cannot be made one
349
+ * the way the web shape's proxy makes them one.
350
+ *
351
+ * Allowing it where a loopback origin is allowed is not a widening of the
352
+ * drive-by surface this guard exists for: no web page can be served from
353
+ * `vscode-file://vscode-app` — the scheme is registered by the Electron main
354
+ * process and its handler reads the app's own files — so an attacker's page can
355
+ * never carry this Origin.
356
+ */
357
+ export const DESKTOP_FRAME_ORIGIN = 'vscode-file://vscode-app';
358
+
359
+ /**
360
+ * THE SCRIPTS A CROSS-ORIGIN-ISOLATED FRAME LOADS **NO-CORS** FROM THIS
361
+ * SESSION, and the one header that lets it: `Cross-Origin-Resource-Policy`.
362
+ *
363
+ * The Code-OSS desktop frame runs cross-origin-isolated (`--enable-coi`, which
364
+ * the Blender worker needs for `SharedArrayBuffer`), and such a realm refuses
365
+ * every cross-origin subresource fetched in `no-cors` mode unless the response
366
+ * says out loud that it may be embedded. CORS does not cover these: `fetch`
367
+ * and a module `import` are cors-mode and already pass, but `importScripts`
368
+ * inside a worker is not, and that is exactly how two of this session's
369
+ * scripts are loaded on that page:
370
+ *
371
+ * - `/__editor/tab-heartbeat.js` — the tab's heartbeat worker, loaded through
372
+ * the frame's `blob:` doorway (docs/CODE-OSS.md §Boot, DESKTOP), and
373
+ * - `/__editor/blender-wasm/blender_browser.js` — the Emscripten glue, which
374
+ * Blender's own pthreads load as a CLASSIC worker from this same URL.
375
+ *
376
+ * Both were measured failing on 2026-09-19: the heartbeat worker died on
377
+ * `net::ERR_BLOCKED_BY_RESPONSE` before its first line (the VS Code window sat
378
+ * blessed, answering commands, and never beating), and the pthread that runs
379
+ * Blender's `main()` never started, so the first bpy call never answered and
380
+ * nothing anywhere reported an error.
381
+ *
382
+ * `cross-origin` and not `same-site`: `vscode-file://vscode-app` is a scheme of
383
+ * its own, so nothing narrower can name it. It is safe on exactly these
384
+ * responses — plain, secret-free program text this session serves a frame on
385
+ * purpose — and is set nowhere else.
386
+ */
387
+ export function allowCrossOriginFrameEmbedding(res: {
388
+ setHeader(name: string, value: string): void;
389
+ }): void {
390
+ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
391
+ }
392
+
393
+ /** True for loopback hostnames (localhost / 127.0.0.0/8 / ::1). */
394
+ export function isLoopbackHostname(host: string): boolean {
395
+ const h = host.replace(/^\[|\]$/g, '').toLowerCase();
396
+ if (h === 'localhost' || h === '::1') return true;
397
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h);
398
+ }
399
+
400
+ /**
401
+ * CSRF / drive-by defense for mutating `/__editor/*` routes. A browser always
402
+ * sends an `Origin` on cross-site POSTs; if present it must be a loopback origin,
403
+ * the Code-OSS desktop frame ({@link DESKTOP_FRAME_ORIGIN}), or an
404
+ * explicitly-allowed host. Non-browser clients (the CLI/SDK using Node `fetch`)
405
+ * send no `Origin` and are allowed.
406
+ */
407
+ export function isAllowedEditorOrigin(
408
+ origin: string | undefined,
409
+ extraAllowedHosts: string[] = [],
410
+ ): boolean {
411
+ if (!origin) return true;
412
+ if (origin === DESKTOP_FRAME_ORIGIN) return true;
413
+ let u: URL;
414
+ try {
415
+ u = new URL(origin);
416
+ } catch {
417
+ return false;
418
+ }
419
+ const host = u.hostname.toLowerCase();
420
+ if (isLoopbackHostname(host)) return true;
421
+ return extraAllowedHosts.some((h) => h.toLowerCase() === host);
422
+ }
423
+
424
+ // ---------------------------------------------------------------------------
425
+ // Installed-package source resolution (B1b, packaging plan §3(b)1)
426
+ // ---------------------------------------------------------------------------
427
+
428
+ /**
429
+ * Resolve `packageName`'s own `src/` directory as INSTALLED in `fromDir`'s
430
+ * `node_modules` — Node's own resolution algorithm, rooted at `fromDir` (a
431
+ * project directory), not at wherever this server process's own code lives.
432
+ *
433
+ * Why: a project that pins `@vgai/game-runtime@0.3.0` must be served ITS 0.3.0
434
+ * source, never a copy baked into whatever package happens to be running the
435
+ * editor server — otherwise the exact version-skew class the pin exists to
436
+ * kill (an editor silently serving a different engine version than the one
437
+ * the project declares) reappears one layer down, inside script-serving.
438
+ * `packageName`'s `exports` map must publish a `./package.json` entry for
439
+ * `require.resolve` to find it.
440
+ *
441
+ * Returns `null` (never throws) when `packageName` isn't resolvable from
442
+ * `fromDir` — no project open, a pre-Phase-B project with no such
443
+ * dependency, or a project that hasn't `npm install`ed yet. Callers fall back
444
+ * to a checkout-relative default in that case.
445
+ */
446
+ export function resolveInstalledPackageSrcDir(fromDir: string, packageName: string): string | null {
447
+ try {
448
+ const req = createRequire(join(fromDir, 'package.json'));
449
+ const pkgJsonPath = req.resolve(`${packageName}/package.json`);
450
+ return join(dirname(pkgJsonPath), 'src');
451
+ } catch {
452
+ return null;
453
+ }
454
+ }
455
+
456
+ // ---------------------------------------------------------------------------
457
+ // Bind host + listen-error helpers (S3 / SC5)
458
+ // ---------------------------------------------------------------------------
459
+
460
+ /**
461
+ * Resolve the interface to bind to. Native hosts default to loopback. WSL is
462
+ * the exception: the editor process runs in WSL while the browser runs on
463
+ * Windows, and Windows cannot reach a listener bound only to WSL's loopback
464
+ * interface. Binding the WSL listener on all of its interfaces lets Windows'
465
+ * localhost forwarding reach it; the editor's origin checks still protect
466
+ * mutating routes. An explicit host always wins.
467
+ */
468
+ export function resolveBindHost(
469
+ env: NodeJS.ProcessEnv = process.env,
470
+ platform: NodeJS.Platform = process.platform,
471
+ ): string {
472
+ const explicitHost = env['VGAI_EDITOR_HOST'] || env['EDITOR_HOST'];
473
+ if (explicitHost) return explicitHost;
474
+
475
+ const isWsl = platform === 'linux' && Boolean(env['WSL_INTEROP'] || env['WSL_DISTRO_NAME']);
476
+ return isWsl ? '0.0.0.0' : '127.0.0.1';
477
+ }
478
+
479
+ /**
480
+ * Format a friendly message for a server `listen` error (notably EADDRINUSE).
481
+ *
482
+ * `portEnvVar` is REQUIRED because the editor servers do not read the
483
+ * same variable, and the message used to hardcode the wrong one. `dev.ts` (what
484
+ * `npm run dev` runs) honours only `VGAI_EDITOR_PORT`;
485
+ * `packaged.ts` honours `PORT` then `VGAI_EDITOR_PORT`. The old text
486
+ * said "set PORT / VGAI_EDITOR_HOST" for both — so on the most common path
487
+ * it named a variable `dev.ts` ignores, and offered a HOST variable as the way
488
+ * out of a PORT conflict. Following it verbatim reproduces the exact error it
489
+ * was printed to resolve (confirmed 2026-07-30: `PORT=5311 npm run dev` bound
490
+ * the built-in default again and died the same way).
491
+ */
492
+ export function friendlyListenError(
493
+ err: NodeJS.ErrnoException,
494
+ port: number,
495
+ host: string,
496
+ portEnvVar: string,
497
+ ): string {
498
+ if (err.code === 'EADDRINUSE') {
499
+ return (
500
+ `Port ${port} is already in use on ${host}. ` +
501
+ `Another editor instance may already be running — stop it, or set ${portEnvVar} ` +
502
+ `to a free port (or VGAI_EDITOR_HOST to a different address) and try again.`
503
+ );
504
+ }
505
+ if (err.code === 'EACCES') {
506
+ return `Permission denied binding to ${host}:${port}. Try a port above 1024.`;
507
+ }
508
+ return `Failed to start server on ${host}:${port}: ${err.message}`;
509
+ }
510
+
511
+ // ---------------------------------------------------------------------------
512
+ // File-move disambiguation (SC2)
513
+ // ---------------------------------------------------------------------------
514
+
515
+ export interface MoveCandidate {
516
+ hash: string;
517
+ size: number;
518
+ timestamp: number;
519
+ }
520
+
521
+ /**
522
+ * A delete+create pair is treated as a move (and triggers scene-ref rewriting)
523
+ * only when it is unambiguous: the added file must match a pending unlink by
524
+ * BOTH content hash AND byte size, that match must be unique, and it must fall
525
+ * within the move window. Hash-only matching risks rewriting scene files on an
526
+ * unrelated delete+create that happens to collide. (SC2)
527
+ */
528
+ export function findUniqueMoveMatch<T extends MoveCandidate>(
529
+ candidates: readonly T[],
530
+ addedHash: string,
531
+ addedSize: number,
532
+ now: number,
533
+ windowMs: number,
534
+ ): T | null {
535
+ const matches = candidates.filter(
536
+ (c) => c.hash === addedHash && c.size === addedSize && now - c.timestamp < windowMs,
537
+ );
538
+ return matches.length === 1 ? matches[0]! : null;
539
+ }
540
+
541
+ // ---------------------------------------------------------------------------
542
+ // Manifest write validation (A4, D8) — POST /__editor/manifest
543
+ // ---------------------------------------------------------------------------
544
+
545
+ export interface ManifestWriteBody {
546
+ path?: unknown;
547
+ content?: unknown;
548
+ }
549
+
550
+ export type ManifestWriteValidation =
551
+ | { ok: true; content: string }
552
+ | { ok: false; status: number; error: string };
553
+
554
+ /**
555
+ * Validate a `POST /__editor/manifest` request body. The route HARD-CODES the
556
+ * write destination (`join(projectRoot, 'vgai.project.json')`) — this function
557
+ * never returns a path, only a green light + the (already-string, already-
558
+ * valid-JSON) content to write, so there is no way for a caller to derive the
559
+ * destination from user input even by accident. Rejects: no project open,
560
+ * any `path` other than the literal `'vgai.project.json'` (traversal, a
561
+ * different filename, an absolute path — all rejected identically), a
562
+ * non-string `content`, and content that fails to `JSON.parse`.
563
+ */
564
+ export function validateManifestWrite(
565
+ projectRoot: string,
566
+ engineRoot: string,
567
+ body: ManifestWriteBody,
568
+ ): ManifestWriteValidation {
569
+ if (projectRoot === engineRoot) {
570
+ return { ok: false, status: 400, error: 'No project open.' };
571
+ }
572
+ if (body.path !== undefined && body.path !== 'vgai.project.json') {
573
+ return {
574
+ ok: false,
575
+ status: 400,
576
+ error: "Invalid path — only 'vgai.project.json' may be written.",
577
+ };
578
+ }
579
+ if (typeof body.content !== 'string') {
580
+ return { ok: false, status: 400, error: 'Invalid content — expected a JSON string.' };
581
+ }
582
+ try {
583
+ JSON.parse(body.content);
584
+ } catch {
585
+ return { ok: false, status: 400, error: 'Content is not valid JSON.' };
586
+ }
587
+ return { ok: true, content: body.content };
588
+ }
589
+
590
+ // ---------------------------------------------------------------------------
591
+ // Project src/ watcher classification (W6a)
592
+ // ---------------------------------------------------------------------------
593
+
594
+ /**
595
+ * The naming convention that MAKES a module an editor contribution.
596
+ *
597
+ * Nothing enumerates contribution modules any more, so the filename is the
598
+ * whole declaration that a file is one. Each suffix names its contribution
599
+ * point (`workspace.document`, `selection.inspector`, `asset.inspector`,
600
+ * `generation.result`, `workspace.utility`, `workspace.analytics`) — the module still exports the
601
+ * authoritative `point`; this only decides what gets looked at.
602
+ *
603
+ * The convention has to be exact, because a file that matches but exports no
604
+ * component is a LOUD error, not a silent skip. `builder-document.tsx` (the
605
+ * shared `createBuilderDocument` helper that ships beside real documents) is
606
+ * the live proof: a `-document` suffix would sweep it in. Only the dotted form
607
+ * counts.
608
+ */
609
+ // The convention itself lives in `@volter/editor-sdk/session/
610
+ // tool-contribution-convention` so every program that asks shares the ONE
611
+ // definition; re-exported here so server importers keep their import site.
612
+ export {
613
+ isToolContributionModule,
614
+ TOOL_CONTRIBUTION_SUFFIXES,
615
+ } from '@volter/editor-sdk/session/tool-contribution-convention';
616
+
617
+ import {
618
+ isEditorLanePath,
619
+ isToolContributionModule,
620
+ } from '@volter/editor-sdk/session/tool-contribution-convention';
621
+
622
+ /**
623
+ * Which editor list a `src/**` add/unlink invalidates, or `null` if it's
624
+ * irrelevant. Feeds the editor-server's second `src/`-scoped chokidar
625
+ * watcher (`startWatcher()` in editor-server.ts) so a NEW registered tool
626
+ * module shows up in the dock without a full editor reload (spec §7 W4 field
627
+ * note c). `stories` (C3, spec §9) is the same physics for a new/removed
628
+ * `*.stories.tsx`/`*.stories.ts` anywhere under `src/` — the Stories tab.
629
+ *
630
+ * Data assets are deliberately NOT classified here: the data capability
631
+ * rescans on mount, on activation and on focus, which is the same rescan a
632
+ * watcher event would have triggered.
633
+ *
634
+ * `relPath` is project-root-relative with forward slashes (same convention
635
+ * `/__editor/data-files` already uses).
636
+ */
637
+ export function classifyProjectSrcPath(relPath: string): 'tools' | 'stories' | null {
638
+ if (
639
+ isEditorLanePath(relPath) &&
640
+ (relPath.endsWith('.tool.ts') ||
641
+ relPath.endsWith('.tool.js') ||
642
+ isToolContributionModule(relPath))
643
+ )
644
+ return 'tools';
645
+ // C3 (spec §9) — conventional CSF story files, colocated anywhere under one
646
+ // of the project's STORY SOURCE DIRS ({@link projectStorySourceDirs}), not
647
+ // confined to one subfolder like tools/data. `src/…` is the in-project form;
648
+ // a leading `../` is the out-of-project one, which arises only for a project
649
+ // whose manifest declares a root entry outside its own folder (a
650
+ // repo-vendored game). The caller's watcher is rooted at exactly those dirs,
651
+ // so a `../`-prefixed path here is under one by construction.
652
+ if (
653
+ (relPath.startsWith('src/') || relPath.startsWith('../')) &&
654
+ (relPath.endsWith('.stories.tsx') || relPath.endsWith('.stories.ts'))
655
+ ) {
656
+ return 'stories';
657
+ }
658
+ return null;
659
+ }
660
+
661
+ // ---------------------------------------------------------------------------
662
+ // Where a project's authored source actually lives
663
+ // ---------------------------------------------------------------------------
664
+
665
+ /**
666
+ * Every directory tree that holds the open project's own authored source —
667
+ * what `/__editor/story-files` scans for `*.stories.tsx`/`*.stories.ts`.
668
+ *
669
+ * `<projectRoot>/src` is the answer for every project whose roots live inside
670
+ * its own folder, which is every scaffolded project and every `examples/<id>`.
671
+ * It is NOT the answer in general, and assuming it was is what left a whole
672
+ * class of project storyless by construction: a manifest may declare a root
673
+ * whose `entry` resolves OUTSIDE the project folder — a repo-vendored game is
674
+ * exactly this shape (`packages/editor/src/ingest/games/<id>/` holds the
675
+ * manifest and the host shim; the game's source is
676
+ * `vendor/games/<id>/src/`). Stories colocated with those components sat on
677
+ * disk and no scan ever looked at them.
678
+ *
679
+ * So the scan set is DERIVED FROM THE MANIFEST: `<projectRoot>/src`, plus, for
680
+ * every `entry` any root declares that lands outside `projectRoot`, that
681
+ * entry's own source tree — its nearest ancestor named `src` when it has one
682
+ * (so the whole game's source is covered, not just the folder its entry file
683
+ * happens to sit in), else the entry's own directory. Entries INSIDE the
684
+ * project add nothing: `<projectRoot>/src` already covers them.
685
+ *
686
+ * Pure over an already-parsed manifest so it is unit-testable with no disk;
687
+ * the caller reads and parses `vgai.project.json` itself. Every returned path
688
+ * is absolute and de-duplicated, and a nested directory is dropped when an
689
+ * ancestor is already in the set so nothing is scanned twice.
690
+ */
691
+ export function projectStorySourceDirs(projectRoot: string, manifest: unknown): string[] {
692
+ const root = resolve(projectRoot);
693
+ const dirs = [join(root, 'src')];
694
+
695
+ for (const entry of declaredRootEntries(manifest)) {
696
+ const absolute = resolve(root, entry);
697
+ if (isPathInside(root, absolute)) continue;
698
+ dirs.push(sourceTreeOf(absolute));
699
+ }
700
+
701
+ const unique: string[] = [];
702
+ for (const dir of dirs) {
703
+ if (unique.some((kept) => kept === dir || isPathInside(kept, dir))) continue;
704
+ unique.push(dir);
705
+ }
706
+ return unique;
707
+ }
708
+
709
+ /** Every `entry` string declared anywhere under the manifest's `roots` — a
710
+ * root's own `entry`, and the nested ones an adapter descriptor carries (an
711
+ * root's `world.entry`). Walked generically rather than
712
+ * by a fixed path list: `entry` means the same thing at every depth, and a
713
+ * hard-coded shape here would go stale the next time the adapter descriptor
714
+ * grows a level. */
715
+ function declaredRootEntries(manifest: unknown): string[] {
716
+ const out: string[] = [];
717
+ const roots = (manifest as { roots?: unknown } | null)?.roots;
718
+ if (!Array.isArray(roots)) return out;
719
+ const visit = (node: unknown): void => {
720
+ if (Array.isArray(node)) {
721
+ for (const item of node) visit(item);
722
+ return;
723
+ }
724
+ if (!node || typeof node !== 'object') return;
725
+ for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
726
+ if (key === 'entry' && typeof value === 'string' && value.length > 0) out.push(value);
727
+ else visit(value);
728
+ }
729
+ };
730
+ visit(roots);
731
+ return out;
732
+ }
733
+
734
+ /** The source TREE an out-of-project entry belongs to: its nearest ancestor
735
+ * directory named `src`, else the directory the entry file sits in. */
736
+ function sourceTreeOf(entryPath: string): string {
737
+ let dir = dirname(entryPath);
738
+ let parent = dirname(dir);
739
+ while (parent !== dir) {
740
+ if (dir.endsWith(`${sep}src`)) return dir;
741
+ dir = parent;
742
+ parent = dirname(dir);
743
+ }
744
+ return dirname(entryPath);
745
+ }
746
+
747
+ // ---------------------------------------------------------------------------
748
+ // Watcher polling resolution (#131)
749
+ // ---------------------------------------------------------------------------
750
+
751
+ /**
752
+ * Chokidar options fragment deciding whether an editor-server watcher polls.
753
+ *
754
+ * #131: on WSL drvfs mounts (`/mnt/<drive>/…`) inotify never fires, so every
755
+ * SSE broadcast the editor UI lives on (`assets-changed`, `asset-moved`,
756
+ * `tool/data/story-files-changed`, manifest validation) was structurally dead
757
+ * there — the owner watched an agent author a scene and the open editor never
758
+ * repainted until a manual browser refresh. dev.ts already auto-polls VITE's
759
+ * watcher on drvfs (`startProjectPollWatcher`) but deliberately never feeds
760
+ * these watchers (see the decoupling note at the srcWatcher construction), so
761
+ * they need their own decision. Chokidar's scoped `usePolling` is safe here
762
+ * where Vite-wide polling was not: these watchers cover only a game project's
763
+ * `public/`+`src/` trees and one manifest file, not the whole engine tree
764
+ * that made drvfs stat-flood starve the event loop (dev.ts field note
765
+ * 2026-07-10).
766
+ *
767
+ * Same env contract as dev.ts's poller: `VGAI_WATCH_POLL=0` forces off
768
+ * anywhere, `=1` forces on (default 1000ms interval), `=<ms>` forces on with
769
+ * that interval. Unset: auto-enable iff `root` sits on a drvfs mount
770
+ * (linux + `/mnt/<drive>/`). Pass `root: null` to opt out of auto-detection
771
+ * (engine-repo-rooted watchers, where `public/` includes the vendored-game
772
+ * trees and polling would be needlessly broad).
773
+ */
774
+ export function resolveWatcherPollOptions(
775
+ root: string | null,
776
+ env: Record<string, string | undefined> = process.env,
777
+ platform: NodeJS.Platform = process.platform,
778
+ ): { usePolling: true; interval: number } | Record<string, never> {
779
+ const raw = env['VGAI_WATCH_POLL'];
780
+ if (raw !== undefined && raw !== '') {
781
+ if (raw === '0') return {};
782
+ const ms = Number(raw);
783
+ return { usePolling: true, interval: Number.isFinite(ms) && ms > 1 ? ms : 1000 };
784
+ }
785
+ const onDrvfs = root !== null && platform === 'linux' && /^\/mnt\/[a-z]\//i.test(root);
786
+ return onDrvfs ? { usePolling: true, interval: 1000 } : {};
787
+ }
788
+
789
+ // ---------------------------------------------------------------------------
790
+ // Command-relay response shaping (SC1)
791
+ // ---------------------------------------------------------------------------
792
+
793
+ /**
794
+ * A relay command's SETTLED result: the tab's own answer
795
+ * ({@link CommandResult}, declared once in `src/command-table.ts` and imported
796
+ * by both ends of the wire), or the server's verdict for a command no tab
797
+ * answered.
798
+ *
799
+ * `timedOut` is the one field the server adds, because the server is the only
800
+ * thing that can produce it — no browser handler has a concept of "nobody
801
+ * answered". Every site that sets it (`control-plane.ts`) pairs it with
802
+ * `ok: false`, and {@link commandResponseFor} reads it only after `ok` is
803
+ * false, which is why it can be an optional marker on top of a REQUIRED `ok`
804
+ * rather than a second half-truth beside an optional one.
805
+ */
806
+ export interface RelayedCommandResult extends CommandResult {
807
+ timedOut?: boolean;
808
+ }
809
+
810
+ /**
811
+ * The relay's per-command WORK budget and the ANSWER SHAPE both live in
812
+ * `src/command-table.ts`, beside the vocabulary they are columns of —
813
+ * re-exported here because the server's relay is their only reader and this is
814
+ * where that reader looks. The delivery budget below is a different clock and
815
+ * stays here.
816
+ */
817
+ export { type CommandResult, relayCommandTimeoutMs };
818
+
819
+ /**
820
+ * How long the relay waits for the editor tab to say it RECEIVED a command,
821
+ * as opposed to finishing it.
822
+ *
823
+ * The long budgets above are for the WORK — a game's async `setup()`, a
824
+ * teardown chain, a sheet of rasterized stories. They were also, silently,
825
+ * the budget for DELIVERY: a tab that stopped answering the SSE command
826
+ * channel altogether made `vgai play` sit for the full 120 seconds and then
827
+ * report "Command timed out — editor connected but did not respond", a
828
+ * sentence that names no cause and no remedy (this file's own note above
829
+ * records the same message sending a real investigation looking for a dead
830
+ * editor). Measured on a hidden tab: a healthy play acks in ~350 ms with
831
+ * `requestAnimationFrame` fully parked and timers clamped to a minute, so a
832
+ * play still silent after this window is not a slow boot — it is a tab that
833
+ * is not running its command listener.
834
+ *
835
+ * The receipt (`command-listener.ts` posts it the instant the SSE event
836
+ * arrives, BEFORE it starts the work) separates the two: acknowledged, and
837
+ * the command's full budget stands untouched; unacknowledged, and the caller
838
+ * learns in seconds with the facts the server already holds.
839
+ *
840
+ * The "not a slow boot" inference above holds only when the tab's SSE
841
+ * socket is GONE. Measured 2026-08-09 on a loaded box (load ~19, cold
842
+ * editor graph): a live tab's main thread blocked for 60-100s mid-boot, the
843
+ * SSE command event sat queued the whole time, and every command executed
844
+ * the moment the thread freed — receipts included. So a live socket EXTENDS
845
+ * the wait (up to {@link RELAY_DELIVERY_MAX_WAIT_MS}) instead of failing at
846
+ * this first window; only a dead socket or the max wait ends it.
847
+ */
848
+ export const RELAY_DELIVERY_ACK_MS = 8000;
849
+
850
+ /**
851
+ * Ceiling for the receipt wait while the controller tab's SSE socket stays
852
+ * open. A blocked-but-alive main thread (a cold editor boot under machine
853
+ * load) holds its socket and runs the queued command when it unblocks —
854
+ * measured at 60-100s on 2026-08-09, when five consecutive 8s windows
855
+ * false-failed a play that then executed anyway (and the caller's blind
856
+ * resends, queued behind it, stopped the game it had just started). 45s
857
+ * outlasts an ordinary cold-boot stall while still naming a genuine zombie
858
+ * tab (socket open, listener never installed) in under a minute instead of
859
+ * the command's 120s budget.
860
+ *
861
+ * A CEILING, not a fixed wait: the caller clamps it to the type's own
862
+ * {@link relayCommandTimeoutMs}, because `stop` (30s),
863
+ * `capture-asset-preview` (30s) and `bridge-screenshot` (15s) are all
864
+ * shorter. Unclamped, their own timer would always win and those callers
865
+ * would get the generic "editor connected but did not respond" — the very
866
+ * message the receipt exists to replace.
867
+ */
868
+ export const RELAY_DELIVERY_MAX_WAIT_MS = 45_000;
869
+
870
+ /**
871
+ * The delivery-acknowledgement window for one command type, or `null` when
872
+ * the type has none.
873
+ *
874
+ * Only types whose own budget EXCEEDS {@link RELAY_DELIVERY_ACK_MS} get one:
875
+ * an ordinary 5s command already fails faster than any ack timer could, so
876
+ * arming one would add a second timer that can never be the informative one —
877
+ * the same "keep the informative timer the one that fires" rule
878
+ * {@link relayCommandTimeoutMs} states for its client-side counterparts.
879
+ */
880
+ export function relayCommandAckDeadlineMs(type: unknown): number | null {
881
+ return relayCommandTimeoutMs(type) > RELAY_DELIVERY_ACK_MS ? RELAY_DELIVERY_ACK_MS : null;
882
+ }
883
+
884
+ /**
885
+ * The refusal for a command whose controlling tab went away before answering.
886
+ *
887
+ * ONE constant because two different paths emit it — the SSE `close`
888
+ * handler's `failCommandsOwnedBy`, and the receipt window when it finds the
889
+ * socket already gone — and because a THIRD place, in another package, reads
890
+ * it: the CLI decides a play is safe to resend by matching a fragment of this
891
+ * sentence (`vgai-cli/src/play-retry.ts`, `TRANSIENT_RELAY_ERRORS`). Drift
892
+ * between copies would not fail a build or a type check; it would silently
893
+ * turn a retryable disconnect into a dead end. `play-retry.test.ts` pins the
894
+ * cross-package half by running this exact string through that matcher.
895
+ */
896
+ export const CONTROLLER_DISCONNECTED_MESSAGE =
897
+ 'The editor tab controlling this command disconnected before reporting a result.';
898
+
899
+ /** What the server knows about the tab it handed a command to. Every field is
900
+ * already in the `/__editor/state` snapshot that tab POSTs. */
901
+ export interface UnacknowledgedCommandContext {
902
+ /** The command type that went unacknowledged. */
903
+ readonly type: unknown;
904
+ /** Page visibility last reported by that tab, or `null` if it never reported. */
905
+ readonly visibility: 'visible' | 'hidden' | null;
906
+ /** Whether that tab last reported window focus; `null` if it never reported. */
907
+ readonly focused: boolean | null;
908
+ /** Milliseconds since that tab last POSTed anything, or `null` if never. */
909
+ readonly silentForMs: number | null;
910
+ /** How long the relay actually waited for the receipt before giving up —
911
+ * the type's clamped ceiling, so it varies by command. */
912
+ readonly waitedMs: number;
913
+ /**
914
+ * Age of the last protocol pong on that tab's control socket, or `null`
915
+ * when the transport has none (the SSE stream and the share tunnel's
916
+ * bridge). A pong is answered by the browser's NETWORK stack, so a fresh
917
+ * one alongside app-level silence is positive evidence of the blocked-main-
918
+ * thread case rather than an inference from it.
919
+ */
920
+ readonly lastPongAgeMs?: number | null;
921
+ /**
922
+ * What the main-thread echo said. `'unanswered'` means the page's inline
923
+ * bootstrap responder — attached before any module loads — did not reply,
924
+ * so the thread really is blocked. `'answered'` is the opposite and much
925
+ * worse news: the thread is free and still did not pick the command up, so
926
+ * the tab's command listener is not running. `'unavailable'` is the SSE /
927
+ * tunnel transport, which cannot ask.
928
+ */
929
+ readonly mainThreadEcho?: 'answered' | 'unanswered' | 'unavailable';
930
+ }
931
+
932
+ /**
933
+ * The refusal for a command the controlling tab never acknowledged.
934
+ *
935
+ * Names the condition (how long that tab has been silent, what it was
936
+ * showing) and the remedy, because the generic timeout named neither. It
937
+ * deliberately does NOT blame page visibility: a hidden tab runs commands
938
+ * fine (measured — see {@link RELAY_DELIVERY_ACK_MS}), so "your tab is
939
+ * hidden" would send the reader to foreground a tab that was never the
940
+ * problem. Visibility is reported as one observation beside the others.
941
+ *
942
+ * What it must NOT claim is that nothing was started. The old wording did,
943
+ * and it was false: a main thread blocked by a cold boot under load holds
944
+ * its socket, keeps the SSE event queued, and runs the command when it
945
+ * unblocks (measured 2026-08-09 — five refusals in a row, each of whose
946
+ * plays later executed, and the caller's blind resends then stopped the
947
+ * game). So this says the command is queued and warns AGAINST resending.
948
+ *
949
+ * There is deliberately no second, tab-is-gone ending here: a controller
950
+ * whose socket dies is settled by `failCommandsOwnedBy` on the `close`
951
+ * handler, with its own message, before this window can expire. "Its
952
+ * connection is still open" is therefore provable rather than assumed.
953
+ *
954
+ * On the duplex control socket the diagnosis stops being an inference. The
955
+ * pong age proves the transport is live, and the main-thread echo separates
956
+ * the two endings the old single sentence had to merge: `'unanswered'` is a
957
+ * genuinely blocked thread (queued, will run, do not resend), `'answered'` is
958
+ * a responsive tab whose command listener is not running (queued behind
959
+ * nothing — a reload is the actual remedy). Both stay NON-transient for
960
+ * `vgai-cli`'s resend matcher: neither is a lost command.
961
+ */
962
+ export function unacknowledgedCommandMessage(context: UnacknowledgedCommandContext): string {
963
+ const waited = (context.waitedMs / 1000).toFixed(0);
964
+ const silence =
965
+ context.silentForMs === null
966
+ ? 'has never reported any state'
967
+ : `last reported state ${(context.silentForMs / 1000).toFixed(1)}s ago`;
968
+ const presence =
969
+ context.visibility === null
970
+ ? 'presence unknown'
971
+ : `page ${context.visibility}, ${context.focused ? 'focused' : 'unfocused'}`;
972
+ const pong =
973
+ typeof context.lastPongAgeMs === 'number'
974
+ ? `, socket pong ${(context.lastPongAgeMs / 1000).toFixed(1)}s ago`
975
+ : '';
976
+ const opening =
977
+ `The editor tab did not pick up "${String(context.type)}" within ${waited}s. Its connection ` +
978
+ `is still open (${presence}${pong}) and it ${silence}`;
979
+ if (context.mainThreadEcho === 'answered') {
980
+ return (
981
+ `${opening} — but its main thread ANSWERED a liveness echo, so the thread is not blocked ` +
982
+ `and the tab simply is not running a command listener. The command stays queued there and ` +
983
+ `will not run by itself, so do NOT resend it blindly: reload the tab, or re-run ` +
984
+ `\`volter-editor edit\` (which reuses the session and self-heals the tab).`
985
+ );
986
+ }
987
+ const evidence =
988
+ context.mainThreadEcho === 'unanswered'
989
+ ? ` — its main thread did not answer a liveness echo either; the cause has not been established`
990
+ : ` — main-thread liveness was not measured`;
991
+ return (
992
+ `${opening}${evidence}. The command is queued in that tab and may still run when it ` +
993
+ `unblocks, so do NOT resend it blindly. Watch \`volter-editor status\` for fresh state; if the tab ` +
994
+ `stays silent, reload it or re-run \`volter-editor edit\` (which reuses the session and self-heals ` +
995
+ `the tab).`
996
+ );
997
+ }
998
+
999
+ // ---------------------------------------------------------------------------
1000
+ // Command-listener health (P36)
1001
+ // ---------------------------------------------------------------------------
1002
+
1003
+ /**
1004
+ * What the server has MEASURED about one page-load's command listener.
1005
+ *
1006
+ * The gap this closes: presence is owned by the tiny pre-React entry
1007
+ * (`src/early-editor-presence.ts`), which opens the control channel before any
1008
+ * module loads, while the listener that actually executes commands attaches
1009
+ * much later (`connectCommandListener`, once the whole React graph is up). So a
1010
+ * page can beat, hold a live socket, count as PRESENT and BLESSED — and be
1011
+ * unable to run anything. During the incident this comes from, `vgai status`
1012
+ * answered happily for eight minutes about a session in exactly that state; the
1013
+ * only way to learn the truth was to issue a command and watch it hang.
1014
+ *
1015
+ * Every field is a timestamp the server already stamps for its own reasons. No
1016
+ * field is inferred, and nothing here guesses when one is absent.
1017
+ */
1018
+ export interface CommandListenerFacts {
1019
+ /** When this page-load reported its listener ATTACHED, or `null` if it never
1020
+ * has. Cleared when the page reports it detaching. */
1021
+ readonly attachedAt: number | null;
1022
+ /** When the relay last handed this page a command, or `null`. */
1023
+ readonly lastRelayAt: number | null;
1024
+ /** When this page last acknowledged RECEIPT of a relayed command, or `null`. */
1025
+ readonly lastReceiptAt: number | null;
1026
+ }
1027
+
1028
+ /** The standing health verdict `vgai status` prints per tab. */
1029
+ export type CommandListenerHealth = 'ready' | 'not attached' | `silent since ${string}`;
1030
+
1031
+ /**
1032
+ * The verdict, from those facts alone.
1033
+ *
1034
+ * `not attached` is the zombie page — the one the incident hid. `silent since`
1035
+ * is the other half: a listener that DID attach and has since stopped taking
1036
+ * commands, which the receipt path proves without asking the page anything (a
1037
+ * relay went out, the ack window passed, no receipt came back). `ready` is the
1038
+ * only remaining case, and it is a positive report rather than the absence of
1039
+ * evidence.
1040
+ *
1041
+ * The grace before "silent" is {@link RELAY_DELIVERY_ACK_MS}, deliberately the
1042
+ * same window the relay itself waits for a receipt: a command relayed 50ms ago
1043
+ * has not had time to be acknowledged, and calling that silence would make the
1044
+ * field cry wolf on every healthy `vgai play`.
1045
+ */
1046
+ export function commandListenerHealth(
1047
+ facts: CommandListenerFacts,
1048
+ now: number,
1049
+ ): CommandListenerHealth {
1050
+ if (facts.attachedAt === null) return 'not attached';
1051
+ const { lastRelayAt, lastReceiptAt } = facts;
1052
+ const unanswered =
1053
+ lastRelayAt !== null &&
1054
+ (lastReceiptAt === null || lastReceiptAt < lastRelayAt) &&
1055
+ now - lastRelayAt >= RELAY_DELIVERY_ACK_MS;
1056
+ if (!unanswered) return 'ready';
1057
+ return `silent since ${((now - lastRelayAt) / 1000).toFixed(1)}s ago`;
1058
+ }
1059
+
1060
+ /** Map a relayed-command result to an HTTP status + body. A timeout (no editor
1061
+ * connected) is a distinct 504 error rather than a fake success. (SC1)
1062
+ * `data` is spread into the response body at the top level — alongside
1063
+ * `ok: true` on success (so `HttpEditorTransport.sendCommand` sees it
1064
+ * without a second field of indirection) and alongside `ok: false`/`error`
1065
+ * on the refusal path.
1066
+ *
1067
+ * A REFUSAL travels as HTTP 200. It is a first-class ANSWER from the game —
1068
+ * a steer verb racing its run's end, a poll of a run-scoped provider that is
1069
+ * legitimately absent — and every first-party caller reads `body.ok`, never
1070
+ * the status. Encoding it as 400 had a real cost: Chromium prints an
1071
+ * unsuppressable "Failed to load resource: 400" console error for every
1072
+ * non-2xx subresource, so an editor panel's 700 ms `bot.status` poll flooded
1073
+ * the editor console (~1.4 lines/s, all play long) — the exact channel the
1074
+ * dev-tools warning backstop and `vgai status` report from — and no
1075
+ * try/catch on the caller can silence the browser's own network log. The
1076
+ * timeout stays 504: no-editor/no-answer is a genuine gateway condition,
1077
+ * and rare enough that its console line is signal. */
1078
+ export function commandResponseFor(
1079
+ result: RelayedCommandResult,
1080
+ unresolvedConsole?: UnresolvedConsoleSummary,
1081
+ ): {
1082
+ status: number;
1083
+ body: Record<string, unknown>;
1084
+ } {
1085
+ // THE CHOKE POINT for console loudness. Every relayed command — `play`,
1086
+ // `eval`, `screenshot`, every debug-plane read, everything the CLI and the
1087
+ // editor SDK ever ask a tab to do — comes back through this one function, so
1088
+ // the unresolved-console counts are attached here ONCE instead of in each of
1089
+ // the CLI's ~35 output sites. It is the FIRST key of every envelope by
1090
+ // deliberate choice: a structured reader slicing the first field, and a human
1091
+ // eyeballing a pretty-printed body, both hit it before the answer they came
1092
+ // for. Omitted entirely (not zero-filled) when the caller has no ledger, so
1093
+ // "this server predates the contract" and "this session is clean" stay
1094
+ // distinguishable.
1095
+ const head = unresolvedConsole === undefined ? {} : { unresolvedConsole };
1096
+ if (result.ok) return { status: 200, body: { ...head, ok: true, ...(result.data ?? {}) } };
1097
+ if (result.timedOut) {
1098
+ return {
1099
+ status: 504,
1100
+ body: {
1101
+ ...head,
1102
+ ok: false,
1103
+ error: result.error ?? 'Command timed out — no editor connected.',
1104
+ },
1105
+ };
1106
+ }
1107
+ return {
1108
+ status: 200,
1109
+ body: { ...head, ok: false, error: result.error, ...(result.data ?? {}) },
1110
+ };
1111
+ }
1112
+
1113
+ // ---------------------------------------------------------------------------
1114
+ // Runtime/scaffold distribution classification
1115
+ // ---------------------------------------------------------------------------
1116
+
1117
+ /**
1118
+ * True iff a root is a monorepo checkout rather than an installed package
1119
+ * distribution. The result selects link-vs-registry scaffold dependencies and
1120
+ * the packaged React runtime path; both distributions can create projects.
1121
+ *
1122
+ * A packaged (`node_modules`-installed) `@vgai/editor`'s `engineRoot` is the
1123
+ * EDITOR PACKAGE's own root instead of a monorepo checkout root (see
1124
+ * `packaged.ts` is constructed with `engineRoot: editorPackageRoot`; its
1125
+ * separate `scaffoldRoot` points at the npm installation that contains the
1126
+ * shipped template and release train.
1127
+ */
1128
+ export function isMonorepoScaffoldRoot(engineRoot: string): boolean {
1129
+ return existsSync(join(engineRoot, 'packages', 'editor', 'template'));
1130
+ }
1131
+
1132
+ // ---------------------------------------------------------------------------
1133
+ // Asset listing (S-8 — ingest-boot 404 noise)
1134
+ // ---------------------------------------------------------------------------
1135
+
1136
+ /**
1137
+ * What `GET /__editor/assets` should answer when `readdir` failed.
1138
+ *
1139
+ * S-8 (the SimCity ingest ledger): the asset browser lists the project's asset
1140
+ * ROOT (`<project>/public`) at boot, three times. A project that simply has no
1141
+ * such folder — every source-mounted foreign game, and micropolisJS keeps its
1142
+ * own assets at `src/public` — answered 404 three times before the editor had
1143
+ * finished booting, which is exactly the noise that makes a real failure hard
1144
+ * to see.
1145
+ *
1146
+ * The rule, and it is a correctness fix rather than a suppression: "list the
1147
+ * asset root of a project that has no asset root" has a true answer, and it is
1148
+ * the EMPTY LISTING, not an error. Nothing is being fabricated — an absent
1149
+ * directory contains no assets. A named SUBdirectory is the opposite case: the
1150
+ * caller asserted a path that does not exist, and 404 is the honest reply.
1151
+ */
1152
+ /**
1153
+ * Where Content lists media. A first-party project owns `<project>/public`.
1154
+ * A source-mounted ingest fixture often has no such folder — its bytes live
1155
+ * in `vendor/games/<id>/public`. Listing that tree is not fabrication: those
1156
+ * are the game's images and audio. Writes still target the project public/.
1157
+ */
1158
+ /**
1159
+ * Where Content lists one named root. `public` keeps the rule below verbatim;
1160
+ * `references` is the project's own reference-material folder — ordinary files
1161
+ * beside `public/`, never vendored and never substituted, because reference
1162
+ * material belongs to the project someone opened and nothing else.
1163
+ *
1164
+ * An unknown root name resolves to the public root rather than anywhere else:
1165
+ * the listing routes validate names, and a silent fallback to the safest root
1166
+ * is the only wrong answer that cannot read a directory nobody asked for.
1167
+ */
1168
+ export function resolveListedAssetRoot(
1169
+ projectRoot: string,
1170
+ engineRoot: string,
1171
+ root: string,
1172
+ ): string {
1173
+ if (root === 'references') return resolve(projectRoot, 'references');
1174
+ return resolveListedPublicRoot(projectRoot, engineRoot);
1175
+ }
1176
+
1177
+ export function resolveListedPublicRoot(projectRoot: string, engineRoot: string): string {
1178
+ const projectPublic = resolve(projectRoot, 'public');
1179
+ if (existsSync(projectPublic)) return projectPublic;
1180
+ const id = projectRoot.split(sep).filter(Boolean).at(-1);
1181
+ if (!id) return projectPublic;
1182
+ const vendorPublic = resolve(engineRoot, 'vendor', 'games', id, 'public');
1183
+ return existsSync(vendorPublic) ? vendorPublic : projectPublic;
1184
+ }
1185
+
1186
+ export function assetListingErrorResponse(
1187
+ dir: string,
1188
+ errorCode: string | undefined,
1189
+ ): { kind: 'empty' } | { kind: 'error'; status: 404 | 500; message: string } {
1190
+ const missing = errorCode === 'ENOENT' || errorCode === 'ENOTDIR';
1191
+ if (!missing) return { kind: 'error', status: 500, message: 'Internal server error.' };
1192
+ if (dir === '') return { kind: 'empty' };
1193
+ return { kind: 'error', status: 404, message: 'Directory not found.' };
1194
+ }