studiograph 1.3.48-next.21 → 1.3.48-next.210

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 (966) hide show
  1. package/README.md +26 -16
  2. package/dist/agent/agent-pool.d.ts +87 -3
  3. package/dist/agent/agent-pool.js +188 -15
  4. package/dist/agent/agent-pool.js.map +1 -1
  5. package/dist/agent/followups.d.ts +34 -0
  6. package/dist/agent/followups.js +52 -0
  7. package/dist/agent/followups.js.map +1 -0
  8. package/dist/agent/orchestrator.d.ts +101 -4
  9. package/dist/agent/orchestrator.js +464 -66
  10. package/dist/agent/orchestrator.js.map +1 -1
  11. package/dist/agent/prompts/entity-types-section.d.ts +34 -0
  12. package/dist/agent/prompts/entity-types-section.js +76 -0
  13. package/dist/agent/prompts/entity-types-section.js.map +1 -0
  14. package/dist/agent/prompts/system.md +112 -54
  15. package/dist/agent/skill-loader.d.ts +30 -17
  16. package/dist/agent/skill-loader.js +63 -30
  17. package/dist/agent/skill-loader.js.map +1 -1
  18. package/dist/agent/skills/artifact-canvas/skill.md +152 -0
  19. package/dist/agent/skills/entity-schema.md +75 -431
  20. package/dist/agent/skills/interview/entity-templates.md +38 -37
  21. package/dist/agent/skills/interview/question-domains.md +54 -49
  22. package/dist/agent/skills/interview/skill.md +84 -16
  23. package/dist/agent/skills/schema-rules.md +28 -28
  24. package/dist/agent/tools/artifact-tools.d.ts +124 -0
  25. package/dist/agent/tools/artifact-tools.js +207 -0
  26. package/dist/agent/tools/artifact-tools.js.map +1 -0
  27. package/dist/agent/tools/capture-tools.d.ts +31 -0
  28. package/dist/agent/tools/capture-tools.js +40 -0
  29. package/dist/agent/tools/capture-tools.js.map +1 -0
  30. package/dist/agent/tools/folder-tools.d.ts +47 -0
  31. package/dist/agent/tools/folder-tools.js +249 -0
  32. package/dist/agent/tools/folder-tools.js.map +1 -0
  33. package/dist/agent/tools/fs-tools.d.ts +6 -5
  34. package/dist/agent/tools/fs-tools.js +5 -5
  35. package/dist/agent/tools/fs-tools.js.map +1 -1
  36. package/dist/agent/tools/graph-tools.d.ts +48 -72
  37. package/dist/agent/tools/graph-tools.js +852 -329
  38. package/dist/agent/tools/graph-tools.js.map +1 -1
  39. package/dist/agent/tools/history-tools.d.ts +95 -0
  40. package/dist/agent/tools/history-tools.js +461 -0
  41. package/dist/agent/tools/history-tools.js.map +1 -0
  42. package/dist/agent/tools/load-skill.d.ts +6 -5
  43. package/dist/agent/tools/load-skill.js +3 -3
  44. package/dist/agent/tools/load-skill.js.map +1 -1
  45. package/dist/agent/tools/ops-tools.d.ts +5 -4
  46. package/dist/agent/tools/ops-tools.js +130 -236
  47. package/dist/agent/tools/ops-tools.js.map +1 -1
  48. package/dist/agent/tools/permission-tools.d.ts +87 -17
  49. package/dist/agent/tools/permission-tools.js +233 -43
  50. package/dist/agent/tools/permission-tools.js.map +1 -1
  51. package/dist/agent/tools/tool-loader.js +5 -4
  52. package/dist/agent/tools/tool-loader.js.map +1 -1
  53. package/dist/agent/tools/web-tools.js +58 -9
  54. package/dist/agent/tools/web-tools.js.map +1 -1
  55. package/dist/cli/commands/about.d.ts +1 -0
  56. package/dist/cli/commands/about.js +55 -3
  57. package/dist/cli/commands/about.js.map +1 -1
  58. package/dist/cli/commands/admin/audit-workspace.d.ts +23 -0
  59. package/dist/cli/commands/admin/audit-workspace.js +133 -0
  60. package/dist/cli/commands/admin/audit-workspace.js.map +1 -0
  61. package/dist/cli/commands/admin/audit.d.ts +21 -0
  62. package/dist/cli/commands/admin/audit.js +174 -0
  63. package/dist/cli/commands/admin/audit.js.map +1 -0
  64. package/dist/cli/commands/admin/backup.d.ts +54 -0
  65. package/dist/cli/commands/admin/backup.js +207 -0
  66. package/dist/cli/commands/admin/backup.js.map +1 -0
  67. package/dist/cli/commands/admin/folder.d.ts +11 -0
  68. package/dist/cli/commands/{collection.js → admin/folder.js} +33 -32
  69. package/dist/cli/commands/admin/folder.js.map +1 -0
  70. package/dist/cli/commands/admin/index.d.ts +16 -0
  71. package/dist/cli/commands/admin/index.js +46 -0
  72. package/dist/cli/commands/admin/index.js.map +1 -0
  73. package/dist/cli/commands/admin/migrate-assets.d.ts +53 -0
  74. package/dist/cli/commands/admin/migrate-assets.js +184 -0
  75. package/dist/cli/commands/admin/migrate-assets.js.map +1 -0
  76. package/dist/cli/commands/admin/migrate.d.ts +56 -0
  77. package/dist/cli/commands/admin/migrate.js +263 -0
  78. package/dist/cli/commands/admin/migrate.js.map +1 -0
  79. package/dist/cli/commands/admin/restore.d.ts +24 -0
  80. package/dist/cli/commands/admin/restore.js +228 -0
  81. package/dist/cli/commands/admin/restore.js.map +1 -0
  82. package/dist/cli/commands/admin/secrets.d.ts +23 -0
  83. package/dist/cli/commands/admin/secrets.js +256 -0
  84. package/dist/cli/commands/admin/secrets.js.map +1 -0
  85. package/dist/cli/commands/admin/settings.d.ts +28 -0
  86. package/dist/cli/commands/admin/settings.js +284 -0
  87. package/dist/cli/commands/admin/settings.js.map +1 -0
  88. package/dist/cli/commands/admin/token.d.ts +42 -0
  89. package/dist/cli/commands/admin/token.js +126 -0
  90. package/dist/cli/commands/admin/token.js.map +1 -0
  91. package/dist/cli/commands/admin/transfer-ownership.d.ts +15 -0
  92. package/dist/cli/commands/admin/transfer-ownership.js +106 -0
  93. package/dist/cli/commands/admin/transfer-ownership.js.map +1 -0
  94. package/dist/cli/commands/admin/user.d.ts +11 -0
  95. package/dist/cli/commands/admin/user.js +187 -0
  96. package/dist/cli/commands/admin/user.js.map +1 -0
  97. package/dist/cli/commands/clone.d.ts +25 -3
  98. package/dist/cli/commands/clone.js +142 -36
  99. package/dist/cli/commands/clone.js.map +1 -1
  100. package/dist/cli/commands/config.d.ts +23 -107
  101. package/dist/cli/commands/config.js +52 -260
  102. package/dist/cli/commands/config.js.map +1 -1
  103. package/dist/cli/commands/connector.d.ts +10 -13
  104. package/dist/cli/commands/connector.js +16 -58
  105. package/dist/cli/commands/connector.js.map +1 -1
  106. package/dist/cli/commands/deploy.js +215 -68
  107. package/dist/cli/commands/deploy.js.map +1 -1
  108. package/dist/cli/commands/index.js +5 -1
  109. package/dist/cli/commands/index.js.map +1 -1
  110. package/dist/cli/commands/init.js +10 -30
  111. package/dist/cli/commands/init.js.map +1 -1
  112. package/dist/cli/commands/mcp.js +54 -6
  113. package/dist/cli/commands/mcp.js.map +1 -1
  114. package/dist/cli/commands/r2.d.ts +3 -0
  115. package/dist/cli/commands/r2.js +223 -204
  116. package/dist/cli/commands/r2.js.map +1 -1
  117. package/dist/cli/commands/redeploy.js +65 -12
  118. package/dist/cli/commands/redeploy.js.map +1 -1
  119. package/dist/cli/commands/reset.d.ts +22 -6
  120. package/dist/cli/commands/reset.js +132 -34
  121. package/dist/cli/commands/reset.js.map +1 -1
  122. package/dist/cli/commands/serve.js +231 -26
  123. package/dist/cli/commands/serve.js.map +1 -1
  124. package/dist/cli/commands/sync.d.ts +1 -1
  125. package/dist/cli/commands/sync.js +237 -227
  126. package/dist/cli/commands/sync.js.map +1 -1
  127. package/dist/cli/crypto-bundle.d.ts +39 -0
  128. package/dist/cli/crypto-bundle.js +94 -0
  129. package/dist/cli/crypto-bundle.js.map +1 -0
  130. package/dist/cli/index.js +24 -26
  131. package/dist/cli/index.js.map +1 -1
  132. package/dist/cli/railway-pin.d.ts +68 -0
  133. package/dist/cli/railway-pin.js +96 -0
  134. package/dist/cli/railway-pin.js.map +1 -0
  135. package/dist/cli/railway-provisioning.d.ts +53 -0
  136. package/dist/cli/railway-provisioning.js +85 -0
  137. package/dist/cli/railway-provisioning.js.map +1 -0
  138. package/dist/cli/setup-wizard.d.ts +30 -49
  139. package/dist/cli/setup-wizard.js +39 -40
  140. package/dist/cli/setup-wizard.js.map +1 -1
  141. package/dist/core/accent-palette.d.ts +22 -0
  142. package/dist/core/accent-palette.js +47 -0
  143. package/dist/core/accent-palette.js.map +1 -0
  144. package/dist/core/artifact-asset-urls.d.ts +51 -0
  145. package/dist/core/artifact-asset-urls.js +79 -0
  146. package/dist/core/artifact-asset-urls.js.map +1 -0
  147. package/dist/core/artifact-bundle.d.ts +69 -0
  148. package/dist/core/artifact-bundle.js +305 -0
  149. package/dist/core/artifact-bundle.js.map +1 -0
  150. package/dist/core/artifact-escape.d.ts +5 -0
  151. package/dist/core/artifact-escape.js +26 -0
  152. package/dist/core/artifact-escape.js.map +1 -0
  153. package/dist/core/artifact-export.d.ts +17 -0
  154. package/dist/core/artifact-export.js +529 -0
  155. package/dist/core/artifact-export.js.map +1 -0
  156. package/dist/core/cell-anchor.d.ts +29 -0
  157. package/dist/core/cell-anchor.js +53 -0
  158. package/dist/core/cell-anchor.js.map +1 -0
  159. package/dist/core/comment-anchor.d.ts +41 -0
  160. package/dist/core/comment-anchor.js +147 -0
  161. package/dist/core/comment-anchor.js.map +1 -0
  162. package/dist/core/commit-messages.d.ts +57 -0
  163. package/dist/core/commit-messages.js +85 -0
  164. package/dist/core/commit-messages.js.map +1 -0
  165. package/dist/core/embeds.d.ts +96 -0
  166. package/dist/core/embeds.js +187 -0
  167. package/dist/core/embeds.js.map +1 -0
  168. package/dist/core/entity-body-templates.d.ts +21 -0
  169. package/dist/core/entity-body-templates.js +67 -0
  170. package/dist/core/entity-body-templates.js.map +1 -0
  171. package/dist/core/entity-types-catalog.d.ts +65 -0
  172. package/dist/core/entity-types-catalog.js +142 -0
  173. package/dist/core/entity-types-catalog.js.map +1 -0
  174. package/dist/core/field-library.d.ts +62 -0
  175. package/dist/core/field-library.js +129 -0
  176. package/dist/core/field-library.js.map +1 -0
  177. package/dist/core/folder-paths.d.ts +41 -0
  178. package/dist/core/folder-paths.js +95 -0
  179. package/dist/core/folder-paths.js.map +1 -0
  180. package/dist/core/folder-sidecar.d.ts +36 -0
  181. package/dist/core/folder-sidecar.js +91 -0
  182. package/dist/core/folder-sidecar.js.map +1 -0
  183. package/dist/core/format-registry.d.ts +170 -0
  184. package/dist/core/format-registry.js +412 -0
  185. package/dist/core/format-registry.js.map +1 -0
  186. package/dist/core/graph.d.ts +773 -14
  187. package/dist/core/graph.js +2269 -308
  188. package/dist/core/graph.js.map +1 -1
  189. package/dist/core/history-diff.d.ts +35 -0
  190. package/dist/core/history-diff.js +114 -0
  191. package/dist/core/history-diff.js.map +1 -0
  192. package/dist/core/refs.d.ts +41 -0
  193. package/dist/core/refs.js +80 -0
  194. package/dist/core/refs.js.map +1 -0
  195. package/dist/core/schema-registry.d.ts +66 -0
  196. package/dist/core/schema-registry.js +198 -37
  197. package/dist/core/schema-registry.js.map +1 -1
  198. package/dist/core/schemas/connector.d.ts +67 -0
  199. package/dist/core/schemas/connector.js +100 -0
  200. package/dist/core/schemas/connector.js.map +1 -0
  201. package/dist/core/schemas/workspace.d.ts +122 -0
  202. package/dist/core/schemas/workspace.js +211 -0
  203. package/dist/core/schemas/workspace.js.map +1 -0
  204. package/dist/core/secrets/SecretStore.d.ts +77 -0
  205. package/dist/core/secrets/SecretStore.js +13 -0
  206. package/dist/core/secrets/SecretStore.js.map +1 -0
  207. package/dist/core/secrets/SettingsResolver.d.ts +54 -0
  208. package/dist/core/secrets/SettingsResolver.js +80 -0
  209. package/dist/core/secrets/SettingsResolver.js.map +1 -0
  210. package/dist/core/secrets/SettingsStore.d.ts +30 -0
  211. package/dist/core/secrets/SettingsStore.js +9 -0
  212. package/dist/core/secrets/SettingsStore.js.map +1 -0
  213. package/dist/core/secrets/SqliteEncryptedStore.d.ts +90 -0
  214. package/dist/core/secrets/SqliteEncryptedStore.js +598 -0
  215. package/dist/core/secrets/SqliteEncryptedStore.js.map +1 -0
  216. package/dist/core/secrets/audit.d.ts +36 -0
  217. package/dist/core/secrets/audit.js +60 -0
  218. package/dist/core/secrets/audit.js.map +1 -0
  219. package/dist/core/secrets/auth-db-migration.d.ts +129 -0
  220. package/dist/core/secrets/auth-db-migration.js +653 -0
  221. package/dist/core/secrets/auth-db-migration.js.map +1 -0
  222. package/dist/core/secrets/bundle.d.ts +141 -0
  223. package/dist/core/secrets/bundle.js +435 -0
  224. package/dist/core/secrets/bundle.js.map +1 -0
  225. package/dist/core/secrets/dual-write.d.ts +81 -0
  226. package/dist/core/secrets/dual-write.js +247 -0
  227. package/dist/core/secrets/dual-write.js.map +1 -0
  228. package/dist/core/secrets/encryption.d.ts +44 -0
  229. package/dist/core/secrets/encryption.js +97 -0
  230. package/dist/core/secrets/encryption.js.map +1 -0
  231. package/dist/core/secrets/index.d.ts +49 -0
  232. package/dist/core/secrets/index.js +74 -0
  233. package/dist/core/secrets/index.js.map +1 -0
  234. package/dist/core/secrets/master-key.d.ts +38 -0
  235. package/dist/core/secrets/master-key.js +122 -0
  236. package/dist/core/secrets/master-key.js.map +1 -0
  237. package/dist/core/secrets/probes/anthropic.d.ts +98 -0
  238. package/dist/core/secrets/probes/anthropic.js +300 -0
  239. package/dist/core/secrets/probes/anthropic.js.map +1 -0
  240. package/dist/core/secrets/probes/brave.d.ts +9 -0
  241. package/dist/core/secrets/probes/brave.js +15 -0
  242. package/dist/core/secrets/probes/brave.js.map +1 -0
  243. package/dist/core/secrets/probes/r2.d.ts +15 -0
  244. package/dist/core/secrets/probes/r2.js +14 -0
  245. package/dist/core/secrets/probes/r2.js.map +1 -0
  246. package/dist/core/secrets/probes/tavily.d.ts +18 -0
  247. package/dist/core/secrets/probes/tavily.js +58 -0
  248. package/dist/core/secrets/probes/tavily.js.map +1 -0
  249. package/dist/core/secrets/probes/voyage.d.ts +13 -0
  250. package/dist/core/secrets/probes/voyage.js +52 -0
  251. package/dist/core/secrets/probes/voyage.js.map +1 -0
  252. package/dist/core/secrets/r2-structural-migration.d.ts +39 -0
  253. package/dist/core/secrets/r2-structural-migration.js +109 -0
  254. package/dist/core/secrets/r2-structural-migration.js.map +1 -0
  255. package/dist/core/secrets/read-flip.d.ts +59 -0
  256. package/dist/core/secrets/read-flip.js +87 -0
  257. package/dist/core/secrets/read-flip.js.map +1 -0
  258. package/dist/core/secrets/registry.d.ts +188 -0
  259. package/dist/core/secrets/registry.js +616 -0
  260. package/dist/core/secrets/registry.js.map +1 -0
  261. package/dist/core/types.d.ts +77 -483
  262. package/dist/core/types.js +25 -3
  263. package/dist/core/types.js.map +1 -1
  264. package/dist/core/user-config.d.ts +75 -12
  265. package/dist/core/user-config.js +155 -14
  266. package/dist/core/user-config.js.map +1 -1
  267. package/dist/core/validation.d.ts +786 -1373
  268. package/dist/core/validation.js +458 -147
  269. package/dist/core/validation.js.map +1 -1
  270. package/dist/core/workspace-manager.d.ts +87 -14
  271. package/dist/core/workspace-manager.js +222 -94
  272. package/dist/core/workspace-manager.js.map +1 -1
  273. package/dist/core/workspace.d.ts +19 -5
  274. package/dist/core/workspace.js +67 -39
  275. package/dist/core/workspace.js.map +1 -1
  276. package/dist/mcp/comment-virtual-entity.d.ts +36 -0
  277. package/dist/mcp/comment-virtual-entity.js +71 -0
  278. package/dist/mcp/comment-virtual-entity.js.map +1 -0
  279. package/dist/mcp/connector-manager.d.ts +72 -2
  280. package/dist/mcp/connector-manager.js +211 -32
  281. package/dist/mcp/connector-manager.js.map +1 -1
  282. package/dist/mcp/oauth-provider.d.ts +0 -5
  283. package/dist/mcp/oauth-provider.js +10 -22
  284. package/dist/mcp/oauth-provider.js.map +1 -1
  285. package/dist/mcp/oauth-registry.d.ts +46 -8
  286. package/dist/mcp/oauth-registry.js +52 -13
  287. package/dist/mcp/oauth-registry.js.map +1 -1
  288. package/dist/mcp/server-discovery.d.ts +73 -0
  289. package/dist/mcp/server-discovery.js +164 -0
  290. package/dist/mcp/server-discovery.js.map +1 -0
  291. package/dist/mcp/server.d.ts +24 -3
  292. package/dist/mcp/server.js +53 -10
  293. package/dist/mcp/server.js.map +1 -1
  294. package/dist/mcp/state-signer.d.ts +62 -0
  295. package/dist/mcp/state-signer.js +205 -0
  296. package/dist/mcp/state-signer.js.map +1 -0
  297. package/dist/mcp/stdio-proxy.d.ts +67 -0
  298. package/dist/mcp/stdio-proxy.js +149 -0
  299. package/dist/mcp/stdio-proxy.js.map +1 -0
  300. package/dist/mcp/tools.d.ts +65 -2
  301. package/dist/mcp/tools.js +1772 -124
  302. package/dist/mcp/tools.js.map +1 -1
  303. package/dist/server/__tests__/helpers/graph-api.d.ts +16 -0
  304. package/dist/server/__tests__/helpers/graph-api.js +16 -0
  305. package/dist/server/__tests__/helpers/graph-api.js.map +1 -0
  306. package/dist/server/artifact-asset-mint.d.ts +41 -0
  307. package/dist/server/artifact-asset-mint.js +59 -0
  308. package/dist/server/artifact-asset-mint.js.map +1 -0
  309. package/dist/server/asset-index-queue.d.ts +98 -0
  310. package/dist/server/asset-index-queue.js +193 -0
  311. package/dist/server/asset-index-queue.js.map +1 -0
  312. package/dist/server/body-write-coordinator.d.ts +161 -0
  313. package/dist/server/body-write-coordinator.js +182 -0
  314. package/dist/server/body-write-coordinator.js.map +1 -0
  315. package/dist/server/cloud-billing-flow.d.ts +32 -0
  316. package/dist/server/cloud-billing-flow.js +75 -0
  317. package/dist/server/cloud-billing-flow.js.map +1 -0
  318. package/dist/server/commit-audit.d.ts +26 -0
  319. package/dist/server/commit-audit.js +30 -0
  320. package/dist/server/commit-audit.js.map +1 -0
  321. package/dist/server/index.d.ts +29 -3
  322. package/dist/server/index.js +657 -269
  323. package/dist/server/index.js.map +1 -1
  324. package/dist/server/middleware/sanitize.d.ts +46 -0
  325. package/dist/server/middleware/sanitize.js +182 -0
  326. package/dist/server/middleware/sanitize.js.map +1 -0
  327. package/dist/server/routes/artifact-export-api.d.ts +11 -0
  328. package/dist/server/routes/artifact-export-api.js +159 -0
  329. package/dist/server/routes/artifact-export-api.js.map +1 -0
  330. package/dist/server/routes/asset-api.d.ts +76 -1
  331. package/dist/server/routes/asset-api.js +761 -61
  332. package/dist/server/routes/asset-api.js.map +1 -1
  333. package/dist/server/routes/audit-api.d.ts +24 -0
  334. package/dist/server/routes/audit-api.js +117 -0
  335. package/dist/server/routes/audit-api.js.map +1 -0
  336. package/dist/server/routes/auth-api.js +532 -63
  337. package/dist/server/routes/auth-api.js.map +1 -1
  338. package/dist/server/routes/capture-api.d.ts +10 -3
  339. package/dist/server/routes/capture-api.js +213 -25
  340. package/dist/server/routes/capture-api.js.map +1 -1
  341. package/dist/server/routes/chat-sessions-api.d.ts +13 -0
  342. package/dist/server/routes/chat-sessions-api.js +455 -0
  343. package/dist/server/routes/chat-sessions-api.js.map +1 -0
  344. package/dist/server/routes/chat.d.ts +35 -1
  345. package/dist/server/routes/chat.js +699 -54
  346. package/dist/server/routes/chat.js.map +1 -1
  347. package/dist/server/routes/comments-api.d.ts +15 -0
  348. package/dist/server/routes/comments-api.js +444 -0
  349. package/dist/server/routes/comments-api.js.map +1 -0
  350. package/dist/server/routes/config-api.d.ts +3 -2
  351. package/dist/server/routes/config-api.js +179 -49
  352. package/dist/server/routes/config-api.js.map +1 -1
  353. package/dist/server/routes/connectors-api.js +177 -42
  354. package/dist/server/routes/connectors-api.js.map +1 -1
  355. package/dist/server/routes/event-ingest-api.d.ts +15 -0
  356. package/dist/server/routes/{meeting-ingest-api.js → event-ingest-api.js} +35 -20
  357. package/dist/server/routes/event-ingest-api.js.map +1 -0
  358. package/dist/server/routes/field-library-api.d.ts +28 -0
  359. package/dist/server/routes/field-library-api.js +126 -0
  360. package/dist/server/routes/field-library-api.js.map +1 -0
  361. package/dist/server/routes/git-http.d.ts +2 -2
  362. package/dist/server/routes/git-http.js +86 -39
  363. package/dist/server/routes/git-http.js.map +1 -1
  364. package/dist/server/routes/graph-api-access.d.ts +57 -0
  365. package/dist/server/routes/graph-api-access.js +112 -0
  366. package/dist/server/routes/graph-api-access.js.map +1 -0
  367. package/dist/server/routes/graph-api.d.ts +1 -1
  368. package/dist/server/routes/graph-api.js +1455 -325
  369. package/dist/server/routes/graph-api.js.map +1 -1
  370. package/dist/server/routes/health.d.ts +18 -0
  371. package/dist/server/routes/health.js +74 -0
  372. package/dist/server/routes/health.js.map +1 -0
  373. package/dist/server/routes/import-batch.d.ts +24 -0
  374. package/dist/server/routes/import-batch.js +33 -0
  375. package/dist/server/routes/import-batch.js.map +1 -0
  376. package/dist/server/routes/insights-api.d.ts +9 -2
  377. package/dist/server/routes/insights-api.js +355 -61
  378. package/dist/server/routes/insights-api.js.map +1 -1
  379. package/dist/server/routes/mcp.d.ts +7 -3
  380. package/dist/server/routes/mcp.js +24 -8
  381. package/dist/server/routes/mcp.js.map +1 -1
  382. package/dist/server/routes/oauth-api.d.ts +67 -0
  383. package/dist/server/routes/oauth-api.js +421 -0
  384. package/dist/server/routes/oauth-api.js.map +1 -0
  385. package/dist/server/routes/permissions-api.d.ts +9 -2
  386. package/dist/server/routes/permissions-api.js +87 -31
  387. package/dist/server/routes/permissions-api.js.map +1 -1
  388. package/dist/server/routes/r2-api.d.ts +25 -0
  389. package/dist/server/routes/r2-api.js +276 -0
  390. package/dist/server/routes/r2-api.js.map +1 -0
  391. package/dist/server/routes/secrets-api.d.ts +36 -0
  392. package/dist/server/routes/secrets-api.js +308 -0
  393. package/dist/server/routes/secrets-api.js.map +1 -0
  394. package/dist/server/routes/settings-api.d.ts +29 -0
  395. package/dist/server/routes/settings-api.js +180 -0
  396. package/dist/server/routes/settings-api.js.map +1 -0
  397. package/dist/server/routes/share-api.d.ts +32 -0
  398. package/dist/server/routes/share-api.js +234 -0
  399. package/dist/server/routes/share-api.js.map +1 -0
  400. package/dist/server/routes/views-api.js +51 -0
  401. package/dist/server/routes/views-api.js.map +1 -1
  402. package/dist/server/routes/workspace-api.d.ts +2 -1
  403. package/dist/server/routes/workspace-api.js +142 -23
  404. package/dist/server/routes/workspace-api.js.map +1 -1
  405. package/dist/server/routes/ws.d.ts +3 -2
  406. package/dist/server/routes/ws.js +68 -17
  407. package/dist/server/routes/ws.js.map +1 -1
  408. package/dist/server/session-manager.d.ts +48 -5
  409. package/dist/server/session-manager.js +182 -14
  410. package/dist/server/session-manager.js.map +1 -1
  411. package/dist/server/ws-hub.d.ts +124 -37
  412. package/dist/server/ws-hub.js +0 -0
  413. package/dist/server/ws-hub.js.map +1 -1
  414. package/dist/server/yjs-manager.d.ts +278 -7
  415. package/dist/server/yjs-manager.js +830 -38
  416. package/dist/server/yjs-manager.js.map +1 -1
  417. package/dist/server/yjs-persistence.d.ts +165 -0
  418. package/dist/server/yjs-persistence.js +557 -0
  419. package/dist/server/yjs-persistence.js.map +1 -0
  420. package/dist/server/yjs-text-diff.d.ts +32 -0
  421. package/dist/server/yjs-text-diff.js +61 -0
  422. package/dist/server/yjs-text-diff.js.map +1 -0
  423. package/dist/services/access-control.d.ts +73 -0
  424. package/dist/services/access-control.js +165 -0
  425. package/dist/services/access-control.js.map +1 -0
  426. package/dist/services/artifact-asset-token.d.ts +69 -0
  427. package/dist/services/artifact-asset-token.js +94 -0
  428. package/dist/services/artifact-asset-token.js.map +1 -0
  429. package/dist/services/artifact-library-sources.d.ts +3 -0
  430. package/dist/services/artifact-library-sources.js +44 -0
  431. package/dist/services/artifact-library-sources.js.map +1 -0
  432. package/dist/services/artifact-render-export.d.ts +97 -0
  433. package/dist/services/artifact-render-export.js +467 -0
  434. package/dist/services/artifact-render-export.js.map +1 -0
  435. package/dist/services/asset-caption-service.d.ts +103 -0
  436. package/dist/services/asset-caption-service.js +186 -0
  437. package/dist/services/asset-caption-service.js.map +1 -0
  438. package/dist/services/asset-delete-authz.d.ts +31 -0
  439. package/dist/services/asset-delete-authz.js +61 -0
  440. package/dist/services/asset-delete-authz.js.map +1 -0
  441. package/dist/services/asset-finalize-service.d.ts +58 -0
  442. package/dist/services/asset-finalize-service.js +216 -0
  443. package/dist/services/asset-finalize-service.js.map +1 -0
  444. package/dist/services/asset-import-service.d.ts +111 -0
  445. package/dist/services/asset-import-service.js +394 -0
  446. package/dist/services/asset-import-service.js.map +1 -0
  447. package/dist/services/asset-index-sweep.d.ts +95 -0
  448. package/dist/services/asset-index-sweep.js +249 -0
  449. package/dist/services/asset-index-sweep.js.map +1 -0
  450. package/dist/services/asset-lifecycle-check.d.ts +22 -0
  451. package/dist/services/asset-lifecycle-check.js +80 -0
  452. package/dist/services/asset-lifecycle-check.js.map +1 -0
  453. package/dist/services/asset-listing.d.ts +72 -0
  454. package/dist/services/asset-listing.js +321 -0
  455. package/dist/services/asset-listing.js.map +1 -0
  456. package/dist/services/asset-presign-service.d.ts +68 -0
  457. package/dist/services/asset-presign-service.js +124 -0
  458. package/dist/services/asset-presign-service.js.map +1 -0
  459. package/dist/services/asset-sidecar-service.d.ts +28 -0
  460. package/dist/services/asset-sidecar-service.js +79 -0
  461. package/dist/services/asset-sidecar-service.js.map +1 -0
  462. package/dist/services/asset-upload-errors.d.ts +41 -0
  463. package/dist/services/asset-upload-errors.js +45 -0
  464. package/dist/services/asset-upload-errors.js.map +1 -0
  465. package/dist/services/asset-upload-service.d.ts +56 -0
  466. package/dist/services/asset-upload-service.js +200 -0
  467. package/dist/services/asset-upload-service.js.map +1 -0
  468. package/dist/services/asset-upload-token.d.ts +58 -0
  469. package/dist/services/asset-upload-token.js +75 -0
  470. package/dist/services/asset-upload-token.js.map +1 -0
  471. package/dist/services/assets/asset-index-service.d.ts +127 -0
  472. package/dist/services/assets/asset-index-service.js +227 -0
  473. package/dist/services/assets/asset-index-service.js.map +1 -0
  474. package/dist/services/assets/base.d.ts +128 -2
  475. package/dist/services/assets/base.js +98 -1
  476. package/dist/services/assets/base.js.map +1 -1
  477. package/dist/services/assets/index.d.ts +114 -1
  478. package/dist/services/assets/index.js +221 -0
  479. package/dist/services/assets/index.js.map +1 -1
  480. package/dist/services/assets/local.d.ts +16 -1
  481. package/dist/services/assets/local.js +110 -1
  482. package/dist/services/assets/local.js.map +1 -1
  483. package/dist/services/assets/r2-sync.d.ts +88 -0
  484. package/dist/services/assets/r2-sync.js +412 -0
  485. package/dist/services/assets/r2-sync.js.map +1 -0
  486. package/dist/services/assets/r2.d.ts +87 -1
  487. package/dist/services/assets/r2.js +203 -1
  488. package/dist/services/assets/r2.js.map +1 -1
  489. package/dist/services/auth-service.d.ts +312 -45
  490. package/dist/services/auth-service.js +1011 -195
  491. package/dist/services/auth-service.js.map +1 -1
  492. package/dist/services/chat-session-service.d.ts +188 -0
  493. package/dist/services/chat-session-service.js +512 -0
  494. package/dist/services/chat-session-service.js.map +1 -0
  495. package/dist/services/cloud-inference-billing.d.ts +64 -0
  496. package/dist/services/cloud-inference-billing.js +313 -0
  497. package/dist/services/cloud-inference-billing.js.map +1 -0
  498. package/dist/services/comment-service.d.ts +97 -0
  499. package/dist/services/comment-service.js +341 -0
  500. package/dist/services/comment-service.js.map +1 -0
  501. package/dist/services/comment-virtual-entity.d.ts +36 -0
  502. package/dist/services/comment-virtual-entity.js +71 -0
  503. package/dist/services/comment-virtual-entity.js.map +1 -0
  504. package/dist/services/convert-service.d.ts +64 -0
  505. package/dist/services/convert-service.js +68 -0
  506. package/dist/services/convert-service.js.map +1 -0
  507. package/dist/services/csv-service.d.ts +22 -17
  508. package/dist/services/csv-service.js +55 -92
  509. package/dist/services/csv-service.js.map +1 -1
  510. package/dist/services/entity-mutations.d.ts +213 -0
  511. package/dist/services/entity-mutations.js +380 -0
  512. package/dist/services/entity-mutations.js.map +1 -0
  513. package/dist/services/{meeting-processor.d.ts → event-processor.d.ts} +15 -8
  514. package/dist/services/{meeting-processor.js → event-processor.js} +124 -65
  515. package/dist/services/event-processor.js.map +1 -0
  516. package/dist/services/extract/docx.d.ts +1 -0
  517. package/dist/services/extract/docx.js +233 -0
  518. package/dist/services/extract/docx.js.map +1 -0
  519. package/dist/services/extract/index.d.ts +9 -0
  520. package/dist/services/extract/index.js +10 -0
  521. package/dist/services/extract/index.js.map +1 -0
  522. package/dist/services/extract/pdf.d.ts +13 -0
  523. package/dist/services/extract/pdf.js +69 -0
  524. package/dist/services/extract/pdf.js.map +1 -0
  525. package/dist/services/folder-creation.d.ts +33 -0
  526. package/dist/services/folder-creation.js +103 -0
  527. package/dist/services/folder-creation.js.map +1 -0
  528. package/dist/services/git.d.ts +58 -0
  529. package/dist/services/git.js +194 -55
  530. package/dist/services/git.js.map +1 -1
  531. package/dist/services/graph-maintenance.js +4 -4
  532. package/dist/services/graph-maintenance.js.map +1 -1
  533. package/dist/services/import-service.d.ts +51 -2
  534. package/dist/services/import-service.js +254 -107
  535. package/dist/services/import-service.js.map +1 -1
  536. package/dist/services/lint-service.js +10 -17
  537. package/dist/services/lint-service.js.map +1 -1
  538. package/dist/services/markdown.d.ts +12 -1
  539. package/dist/services/markdown.js +77 -9
  540. package/dist/services/markdown.js.map +1 -1
  541. package/dist/services/mention-detector.d.ts +23 -0
  542. package/dist/services/mention-detector.js +47 -0
  543. package/dist/services/mention-detector.js.map +1 -0
  544. package/dist/services/model-capabilities.d.ts +41 -0
  545. package/dist/services/model-capabilities.js +107 -0
  546. package/dist/services/model-capabilities.js.map +1 -0
  547. package/dist/services/notification-broadcast.d.ts +32 -0
  548. package/dist/services/notification-broadcast.js +38 -0
  549. package/dist/services/notification-broadcast.js.map +1 -0
  550. package/dist/services/notification-service.d.ts +41 -0
  551. package/dist/services/notification-service.js +100 -0
  552. package/dist/services/notification-service.js.map +1 -0
  553. package/dist/services/oauth-server-store.d.ts +147 -0
  554. package/dist/services/oauth-server-store.js +319 -0
  555. package/dist/services/oauth-server-store.js.map +1 -0
  556. package/dist/services/orphan-service.js +2 -2
  557. package/dist/services/orphan-service.js.map +1 -1
  558. package/dist/services/personal-folder.d.ts +40 -0
  559. package/dist/services/personal-folder.js +101 -0
  560. package/dist/services/personal-folder.js.map +1 -0
  561. package/dist/services/scratch-asset-promote.d.ts +57 -0
  562. package/dist/services/scratch-asset-promote.js +95 -0
  563. package/dist/services/scratch-asset-promote.js.map +1 -0
  564. package/dist/services/scratch-attachment-service.d.ts +196 -0
  565. package/dist/services/scratch-attachment-service.js +347 -0
  566. package/dist/services/scratch-attachment-service.js.map +1 -0
  567. package/dist/services/share-link-service.d.ts +57 -0
  568. package/dist/services/share-link-service.js +142 -0
  569. package/dist/services/share-link-service.js.map +1 -0
  570. package/dist/services/user-person-bridge.d.ts +136 -0
  571. package/dist/services/user-person-bridge.js +340 -0
  572. package/dist/services/user-person-bridge.js.map +1 -0
  573. package/dist/services/vector-service.d.ts +282 -5
  574. package/dist/services/vector-service.js +585 -29
  575. package/dist/services/vector-service.js.map +1 -1
  576. package/dist/services/workspace-access.d.ts +108 -0
  577. package/dist/services/workspace-access.js +149 -0
  578. package/dist/services/workspace-access.js.map +1 -0
  579. package/dist/services/workspace-assets-folder.d.ts +46 -0
  580. package/dist/services/workspace-assets-folder.js +75 -0
  581. package/dist/services/workspace-assets-folder.js.map +1 -0
  582. package/dist/utils/git.d.ts +17 -2
  583. package/dist/utils/git.js +23 -7
  584. package/dist/utils/git.js.map +1 -1
  585. package/dist/utils/log.d.ts +42 -0
  586. package/dist/utils/log.js +137 -0
  587. package/dist/utils/log.js.map +1 -0
  588. package/dist/utils/preflight.js +2 -2
  589. package/dist/utils/preflight.js.map +1 -1
  590. package/dist/utils/version-checker.d.ts +12 -19
  591. package/dist/utils/version-checker.js +14 -104
  592. package/dist/utils/version-checker.js.map +1 -1
  593. package/dist/web/_app/immutable/assets/0.Bs_z3Z86.css +2 -0
  594. package/dist/web/_app/immutable/assets/12.DlIf1mKh.css +1 -0
  595. package/dist/web/_app/immutable/assets/14.7d-Q37wc.css +1 -0
  596. package/dist/web/_app/immutable/assets/15.BwBFdc1D.css +1 -0
  597. package/dist/web/_app/immutable/assets/16.CmLrbzZp.css +1 -0
  598. package/dist/web/_app/immutable/assets/17.DcuK5ZVm.css +1 -0
  599. package/dist/web/_app/immutable/assets/2.DATnR31c.css +1 -0
  600. package/dist/web/_app/immutable/assets/3.Cz4H8n_O.css +1 -0
  601. package/dist/web/_app/immutable/assets/4.aDHteTDy.css +1 -0
  602. package/dist/web/_app/immutable/assets/6.CF1i5Bon.css +1 -0
  603. package/dist/web/_app/immutable/assets/ArtifactEntityView.C4ooklFD.css +1 -0
  604. package/dist/web/_app/immutable/assets/AssetLibraryPicker.BOFW0MvF.css +1 -0
  605. package/dist/web/_app/immutable/assets/BulkActionsBar.cmKvHzRK.css +1 -0
  606. package/dist/web/_app/immutable/assets/CalendarView.lBCvS3X5.css +1 -0
  607. package/dist/web/_app/immutable/assets/CodeMirrorEditor.D6hf2pNJ.css +1 -0
  608. package/dist/web/_app/immutable/assets/CopyField.DVGZtw4U.css +1 -0
  609. package/dist/web/_app/immutable/assets/FolderMembersPanel.kiYnDFHu.css +1 -0
  610. package/dist/web/_app/immutable/assets/FolderMutationDialogs.BMRF6Z3z.css +1 -0
  611. package/dist/web/_app/immutable/assets/FolderPicker.Cp46GHe2.css +1 -0
  612. package/dist/web/_app/immutable/assets/GalleryView.WBGxxUQc.css +1 -0
  613. package/dist/web/_app/immutable/assets/GraphView.BJtGL9py.css +1 -0
  614. package/dist/web/_app/immutable/assets/KanbanView.BOaI5KFL.css +1 -0
  615. package/dist/web/_app/immutable/assets/Link.vV0ne105.css +1 -0
  616. package/dist/web/_app/immutable/assets/Markdown.BZnsSOSf.css +1 -0
  617. package/dist/web/_app/immutable/assets/Rail.ilusFgD4.css +1 -0
  618. package/dist/web/_app/immutable/assets/SettingsDialog.D7-BO6S_.css +1 -0
  619. package/dist/web/_app/immutable/assets/Spinner.UBfl0pab.css +1 -0
  620. package/dist/web/_app/immutable/assets/StopFilledAlt.fNlDN65D.css +1 -0
  621. package/dist/web/_app/immutable/assets/TimelineView.xAZBJhHw.css +1 -0
  622. package/dist/web/_app/immutable/assets/WorkspaceView.C8ErUJAY.css +1 -0
  623. package/dist/web/_app/immutable/assets/button.BlLm4Dg1.css +1 -0
  624. package/dist/web/_app/immutable/assets/history-summary.LpmHXasl.css +1 -0
  625. package/dist/web/_app/immutable/assets/ibm-plex-mono-latin-400-normal.CvHOgSBP.woff +0 -0
  626. package/dist/web/_app/immutable/assets/ibm-plex-mono-latin-400-normal.DMJ8VG8y.woff2 +0 -0
  627. package/dist/web/_app/immutable/assets/ibm-plex-mono-latin-500-normal.CB9ihrfo.woff +0 -0
  628. package/dist/web/_app/immutable/assets/ibm-plex-mono-latin-500-normal.DSY6xOcd.woff2 +0 -0
  629. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-400-italic.CZTNEAuW.woff2 +0 -0
  630. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-400-italic.CsGl1sm0.woff +0 -0
  631. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-400-normal.CDDApCn2.woff2 +0 -0
  632. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-400-normal.CYLoc0-x.woff +0 -0
  633. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-500-italic.BNK2_mGO.woff2 +0 -0
  634. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-500-italic.DpEwFAQM.woff +0 -0
  635. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-500-normal.6ng42L7E.woff2 +0 -0
  636. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-500-normal.BgVn5rGT.woff +0 -0
  637. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-600-normal.Cu4Hd6ag.woff +0 -0
  638. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-600-normal.CuJfVYMP.woff2 +0 -0
  639. package/dist/web/_app/immutable/assets/inline-list.cqga56xT.css +1 -0
  640. package/dist/web/_app/immutable/assets/list-columns.BgnafZ4K.css +1 -0
  641. package/dist/web/_app/immutable/assets/realtime.DDCWxn3B.css +1 -0
  642. package/dist/web/_app/immutable/assets/select.B90KUqR4.css +1 -0
  643. package/dist/web/_app/immutable/assets/sheet.RF9RBmml.css +1 -0
  644. package/dist/web/_app/immutable/chunks/22qYtvr82.js +1 -0
  645. package/dist/web/_app/immutable/chunks/3anKjXOb.js +1 -0
  646. package/dist/web/_app/immutable/chunks/6S9WOky52.js +1 -0
  647. package/dist/web/_app/immutable/chunks/7xJ91z7d.js +2 -0
  648. package/dist/web/_app/immutable/chunks/9K8VREGP.js +1 -0
  649. package/dist/web/_app/immutable/chunks/B-TGZnCt2.js +1 -0
  650. package/dist/web/_app/immutable/chunks/B-n5q6ku.js +1 -0
  651. package/dist/web/_app/immutable/chunks/B0p2fSEV2.js +1 -0
  652. package/dist/web/_app/immutable/chunks/B2oi87jG.js +2 -0
  653. package/dist/web/_app/immutable/chunks/B4DG5QOL.js +1 -0
  654. package/dist/web/_app/immutable/chunks/B6Lt-qaJ.js +1 -0
  655. package/dist/web/_app/immutable/chunks/B6qeIQZz.js +2 -0
  656. package/dist/web/_app/immutable/chunks/B9KfEsQw2.js +1 -0
  657. package/dist/web/_app/immutable/chunks/B9VRF5aH2.js +1 -0
  658. package/dist/web/_app/immutable/chunks/BCQQzpbj.js +1 -0
  659. package/dist/web/_app/immutable/chunks/BF0vIqH9.js +1 -0
  660. package/dist/web/_app/immutable/chunks/BFp1K70l.js +8 -0
  661. package/dist/web/_app/immutable/chunks/BFr7Funh.js +1 -0
  662. package/dist/web/_app/immutable/chunks/BFx32D20.js +2 -0
  663. package/dist/web/_app/immutable/chunks/BLs1h0Rh2.js +1 -0
  664. package/dist/web/_app/immutable/chunks/BPzV_xOS2.js +1 -0
  665. package/dist/web/_app/immutable/chunks/BRvMjAzW.js +2 -0
  666. package/dist/web/_app/immutable/chunks/BV-iCKqa.js +1 -0
  667. package/dist/web/_app/immutable/chunks/BZi3uKF4.js +1 -0
  668. package/dist/web/_app/immutable/chunks/B_PyL87x.js +2 -0
  669. package/dist/web/_app/immutable/chunks/BbfHGU9n2.js +1 -0
  670. package/dist/web/_app/immutable/chunks/Bbqu2-5G2.js +1 -0
  671. package/dist/web/_app/immutable/chunks/BdWZJpmv.js +2 -0
  672. package/dist/web/_app/immutable/chunks/BfnFtsiY.js +1 -0
  673. package/dist/web/_app/immutable/chunks/BlgjVmFM2.js +14 -0
  674. package/dist/web/_app/immutable/chunks/BmgXvmIG.js +5 -0
  675. package/dist/web/_app/immutable/chunks/BpCG9m962.js +1 -0
  676. package/dist/web/_app/immutable/chunks/BrveeylZ.js +1 -0
  677. package/dist/web/_app/immutable/chunks/BsW72fyR2.js +1 -0
  678. package/dist/web/_app/immutable/chunks/Bti_XKwx.js +2692 -0
  679. package/dist/web/_app/immutable/chunks/BuhW5Fr0.js +689 -0
  680. package/dist/web/_app/immutable/chunks/Bx74EEyn2.js +1 -0
  681. package/dist/web/_app/immutable/chunks/BxPN0T61.js +1 -0
  682. package/dist/web/_app/immutable/chunks/Bxk27YWG.js +1 -0
  683. package/dist/web/_app/immutable/chunks/Bz9lrgGs.js +1 -0
  684. package/dist/web/_app/immutable/chunks/BzaxZ6j12.js +1 -0
  685. package/dist/web/_app/immutable/chunks/C1B4xDs0.js +1 -0
  686. package/dist/web/_app/immutable/chunks/C1trcC8M.js +7 -0
  687. package/dist/web/_app/immutable/chunks/C3F_NMTk.js +3 -0
  688. package/dist/web/_app/immutable/chunks/C4PZmGUQ.js +2 -0
  689. package/dist/web/_app/immutable/chunks/C6wYiSz8.js +1 -0
  690. package/dist/web/_app/immutable/chunks/C70saxIl.js +1 -0
  691. package/dist/web/_app/immutable/chunks/C9tvkk7Y2.js +22 -0
  692. package/dist/web/_app/immutable/chunks/CE1XZ5DE.js +1 -0
  693. package/dist/web/_app/immutable/chunks/CEahbtnP.js +1 -0
  694. package/dist/web/_app/immutable/chunks/CGkd0a-c2.js +2 -0
  695. package/dist/web/_app/immutable/chunks/CIoNEHQ82.js +184 -0
  696. package/dist/web/_app/immutable/chunks/CK4z74w5.js +1 -0
  697. package/dist/web/_app/immutable/chunks/COo3HgCZ.js +1 -0
  698. package/dist/web/_app/immutable/chunks/CP8_U450.js +1 -0
  699. package/dist/web/_app/immutable/chunks/CQ1eQ93t2.js +6 -0
  700. package/dist/web/_app/immutable/chunks/CQyBDccA.js +1 -0
  701. package/dist/web/_app/immutable/chunks/CUFQMEBn2.js +1 -0
  702. package/dist/web/_app/immutable/chunks/CV5OSCJc.js +1 -0
  703. package/dist/web/_app/immutable/chunks/CYEYDNwO2.js +1 -0
  704. package/dist/web/_app/immutable/chunks/CZgFaimi2.js +83 -0
  705. package/dist/web/_app/immutable/chunks/CZqvCTCv.js +1 -0
  706. package/dist/web/_app/immutable/chunks/CcWaWbrI2.js +1 -0
  707. package/dist/web/_app/immutable/chunks/CdtzRVZK.js +1 -0
  708. package/dist/web/_app/immutable/chunks/CfYG72Hc.js +1 -0
  709. package/dist/web/_app/immutable/chunks/Cg-ND0cl.js +1 -0
  710. package/dist/web/_app/immutable/chunks/Ch1kmm3J2.js +1 -0
  711. package/dist/web/_app/immutable/chunks/CjiMLTwX2.js +1 -0
  712. package/dist/web/_app/immutable/chunks/CkoeSJ8K.js +6 -0
  713. package/dist/web/_app/immutable/chunks/Cp87MaaW.js +1 -0
  714. package/dist/web/_app/immutable/chunks/Cqm5wAin.js +1 -0
  715. package/dist/web/_app/immutable/chunks/Ct1uesdW.js +2 -0
  716. package/dist/web/_app/immutable/chunks/CuYPXpWU.js +1 -0
  717. package/dist/web/_app/immutable/chunks/Cuq8J2Td.js +1 -0
  718. package/dist/web/_app/immutable/chunks/CvSEodTA.js +1 -0
  719. package/dist/web/_app/immutable/chunks/CvqqrTrj.js +1 -0
  720. package/dist/web/_app/immutable/chunks/CzXdOgVl2.js +7 -0
  721. package/dist/web/_app/immutable/chunks/D0W7c6Sg.js +185 -0
  722. package/dist/web/_app/immutable/chunks/D0rBhnqB2.js +1 -0
  723. package/dist/web/_app/immutable/chunks/D1-GZSN1.js +1 -0
  724. package/dist/web/_app/immutable/chunks/D1BLcNJ7.js +1 -0
  725. package/dist/web/_app/immutable/chunks/D1yvtriI.js +1 -0
  726. package/dist/web/_app/immutable/chunks/D25NMRpl.js +1 -0
  727. package/dist/web/_app/immutable/chunks/D3F675fu2.js +2 -0
  728. package/dist/web/_app/immutable/chunks/D4UrtGC1.js +1 -0
  729. package/dist/web/_app/immutable/chunks/D8wZ2xul.js +4 -0
  730. package/dist/web/_app/immutable/chunks/DA3HEX3X.js +1 -0
  731. package/dist/web/_app/immutable/chunks/DE2TCESD2.js +1 -0
  732. package/dist/web/_app/immutable/chunks/DN-gIRdd2.js +1 -0
  733. package/dist/web/_app/immutable/chunks/DPUVu0T0.js +1 -0
  734. package/dist/web/_app/immutable/chunks/DPcejqKZ.js +1 -0
  735. package/dist/web/_app/immutable/chunks/DQMWa2t7.js +1 -0
  736. package/dist/web/_app/immutable/chunks/DRPMJ-3M.js +2 -0
  737. package/dist/web/_app/immutable/chunks/DRPnwQAm.js +1 -0
  738. package/dist/web/_app/immutable/chunks/DXmfl4Hq2.js +1 -0
  739. package/dist/web/_app/immutable/chunks/DXsFrDer2.js +1 -0
  740. package/dist/web/_app/immutable/chunks/DYc5KW3F.js +1 -0
  741. package/dist/web/_app/immutable/chunks/DcPrzUMJ.js +1 -0
  742. package/dist/web/_app/immutable/chunks/DdQVGKgM.js +1 -0
  743. package/dist/web/_app/immutable/chunks/DiFfW--R2.js +2 -0
  744. package/dist/web/_app/immutable/chunks/DjIqa1_y.js +2 -0
  745. package/dist/web/_app/immutable/chunks/Dl63IUnH2.js +224 -0
  746. package/dist/web/_app/immutable/chunks/DmFdD6zT.js +1 -0
  747. package/dist/web/_app/immutable/chunks/Dn3R8S-U.js +1 -0
  748. package/dist/web/_app/immutable/chunks/DpNr1GQ2.js +1 -0
  749. package/dist/web/_app/immutable/chunks/DsFEd8vL2.js +1 -0
  750. package/dist/web/_app/immutable/chunks/DuxziNnw.js +1 -0
  751. package/dist/web/_app/immutable/chunks/FuiJnZG0.js +1 -0
  752. package/dist/web/_app/immutable/chunks/Izkulod7.js +1 -0
  753. package/dist/web/_app/immutable/chunks/LYL8znYR2.js +23 -0
  754. package/dist/web/_app/immutable/chunks/ON7Gta0d.js +1 -0
  755. package/dist/web/_app/immutable/chunks/PhN6tXuJ2.js +1 -0
  756. package/dist/web/_app/immutable/chunks/PyLyCpuL.js +1 -0
  757. package/dist/web/_app/immutable/chunks/RG7IUVLn2.js +2 -0
  758. package/dist/web/_app/immutable/chunks/RK4gCWN0.js +2 -0
  759. package/dist/web/_app/immutable/chunks/SnzlL4ZV.js +1 -0
  760. package/dist/web/_app/immutable/chunks/XcWejFm_.js +5 -0
  761. package/dist/web/_app/immutable/chunks/Xkp5hSA1.js +1 -0
  762. package/dist/web/_app/immutable/chunks/al_nidE9.js +1 -0
  763. package/dist/web/_app/immutable/chunks/e50-izZO2.js +1 -0
  764. package/dist/web/_app/immutable/chunks/golz20La2.js +2 -0
  765. package/dist/web/_app/immutable/chunks/hG-QCarB.js +1 -0
  766. package/dist/web/_app/immutable/chunks/kNaey6uv.js +1 -0
  767. package/dist/web/_app/immutable/chunks/nXReuy-5.js +3 -0
  768. package/dist/web/_app/immutable/chunks/o61aZZFZ2.js +66 -0
  769. package/dist/web/_app/immutable/chunks/qwr5boV8.js +1 -0
  770. package/dist/web/_app/immutable/chunks/rjjyuGhb.js +1 -0
  771. package/dist/web/_app/immutable/chunks/uBgoVlsx.js +3 -0
  772. package/dist/web/_app/immutable/chunks/xY0c-UfG.js +3 -0
  773. package/dist/web/_app/immutable/chunks/xeNvx4Bl2.js +1 -0
  774. package/dist/web/_app/immutable/chunks/xihTtKlq.js +1 -0
  775. package/dist/web/_app/immutable/entry/app.dqA31fjy.js +2 -0
  776. package/dist/web/_app/immutable/entry/start.BB1XrAea.js +1 -0
  777. package/dist/web/_app/immutable/nodes/0.dALEQAnu.js +2 -0
  778. package/dist/web/_app/immutable/nodes/1.aFU32Ukj.js +1 -0
  779. package/dist/web/_app/immutable/nodes/10.C2wB-OnW.js +1 -0
  780. package/dist/web/_app/immutable/nodes/11.CI3a8Rj5.js +1 -0
  781. package/dist/web/_app/immutable/nodes/12.CsTvsKzB.js +1 -0
  782. package/dist/web/_app/immutable/nodes/13.CYrHXCA8.js +1 -0
  783. package/dist/web/_app/immutable/nodes/14.B31HrQ26.js +1 -0
  784. package/dist/web/_app/immutable/nodes/15.DxgnZ61h.js +1 -0
  785. package/dist/web/_app/immutable/nodes/16.DAP8cwVb.js +1 -0
  786. package/dist/web/_app/immutable/nodes/17.CmwXtCDY.js +77 -0
  787. package/dist/web/_app/immutable/nodes/2.BuGXEIlN.js +122 -0
  788. package/dist/web/_app/immutable/nodes/3.CmhZJWHE.js +6 -0
  789. package/dist/web/_app/immutable/nodes/4.BRfoPTm-.js +11 -0
  790. package/dist/web/_app/immutable/nodes/5.C7gPuZM_.js +1 -0
  791. package/dist/web/_app/immutable/nodes/6.qKODVbya.js +6 -0
  792. package/dist/web/_app/immutable/nodes/7.BZO37zAJ.js +1 -0
  793. package/dist/web/_app/immutable/nodes/8.xF_Dd1BO.js +1 -0
  794. package/dist/web/_app/immutable/nodes/9.Bow4UyKV.js +1 -0
  795. package/dist/web/_app/version.json +1 -1
  796. package/dist/web/favicon-16.png +0 -0
  797. package/dist/web/favicon-180.png +0 -0
  798. package/dist/web/favicon-32.png +0 -0
  799. package/dist/web/favicon-48.png +0 -0
  800. package/dist/web/favicon-512.png +0 -0
  801. package/dist/web/favicon.ico +0 -0
  802. package/dist/web/favicon.svg +8 -0
  803. package/dist/web/index.html +56 -21
  804. package/dist/web/studiograph-logomark.svg +29 -0
  805. package/package.json +35 -14
  806. package/dist/agent/skills/enrich-entities.md +0 -136
  807. package/dist/agent/skills/sync-configuration.md +0 -119
  808. package/dist/agent/skills/sync-setup.md +0 -82
  809. package/dist/agent/tools/message-tools.d.ts +0 -42
  810. package/dist/agent/tools/message-tools.js +0 -106
  811. package/dist/agent/tools/message-tools.js.map +0 -1
  812. package/dist/cli/commands/app.d.ts +0 -7
  813. package/dist/cli/commands/app.js +0 -268
  814. package/dist/cli/commands/app.js.map +0 -1
  815. package/dist/cli/commands/clear.d.ts +0 -5
  816. package/dist/cli/commands/clear.js +0 -36
  817. package/dist/cli/commands/clear.js.map +0 -1
  818. package/dist/cli/commands/collection.d.ts +0 -10
  819. package/dist/cli/commands/collection.js.map +0 -1
  820. package/dist/cli/commands/join.d.ts +0 -9
  821. package/dist/cli/commands/join.js +0 -255
  822. package/dist/cli/commands/join.js.map +0 -1
  823. package/dist/cli/commands/start.d.ts +0 -7
  824. package/dist/cli/commands/start.js +0 -584
  825. package/dist/cli/commands/start.js.map +0 -1
  826. package/dist/cli/commands/update.d.ts +0 -8
  827. package/dist/cli/commands/update.js +0 -155
  828. package/dist/cli/commands/update.js.map +0 -1
  829. package/dist/cli/commands/user.d.ts +0 -7
  830. package/dist/cli/commands/user.js +0 -175
  831. package/dist/cli/commands/user.js.map +0 -1
  832. package/dist/cli/scaffolding.d.ts +0 -12
  833. package/dist/cli/scaffolding.js +0 -397
  834. package/dist/cli/scaffolding.js.map +0 -1
  835. package/dist/integrations/registry.d.ts +0 -8
  836. package/dist/integrations/registry.js +0 -7
  837. package/dist/integrations/registry.js.map +0 -1
  838. package/dist/integrations/types.d.ts +0 -43
  839. package/dist/integrations/types.js +0 -5
  840. package/dist/integrations/types.js.map +0 -1
  841. package/dist/lib/lib/utils.d.ts +0 -2
  842. package/dist/lib/lib/utils.js +0 -6
  843. package/dist/lib/lib/utils.js.map +0 -1
  844. package/dist/server/chrome/chrome.css +0 -691
  845. package/dist/server/chrome/chrome.js +0 -374
  846. package/dist/server/commit-scheduler.d.ts +0 -39
  847. package/dist/server/commit-scheduler.js +0 -113
  848. package/dist/server/commit-scheduler.js.map +0 -1
  849. package/dist/server/plugin-loader.d.ts +0 -53
  850. package/dist/server/plugin-loader.js +0 -155
  851. package/dist/server/plugin-loader.js.map +0 -1
  852. package/dist/server/routes/meeting-ingest-api.d.ts +0 -14
  853. package/dist/server/routes/meeting-ingest-api.js.map +0 -1
  854. package/dist/server/routes/messages-api.d.ts +0 -15
  855. package/dist/server/routes/messages-api.js +0 -385
  856. package/dist/server/routes/messages-api.js.map +0 -1
  857. package/dist/services/batch-processor.d.ts +0 -49
  858. package/dist/services/batch-processor.js +0 -166
  859. package/dist/services/batch-processor.js.map +0 -1
  860. package/dist/services/briefing.d.ts +0 -26
  861. package/dist/services/briefing.js +0 -230
  862. package/dist/services/briefing.js.map +0 -1
  863. package/dist/services/heartbeat.d.ts +0 -28
  864. package/dist/services/heartbeat.js +0 -183
  865. package/dist/services/heartbeat.js.map +0 -1
  866. package/dist/services/image-import-service.d.ts +0 -24
  867. package/dist/services/image-import-service.js +0 -108
  868. package/dist/services/image-import-service.js.map +0 -1
  869. package/dist/services/insights.d.ts +0 -38
  870. package/dist/services/insights.js +0 -269
  871. package/dist/services/insights.js.map +0 -1
  872. package/dist/services/meeting-processor.js.map +0 -1
  873. package/dist/services/message-service.d.ts +0 -68
  874. package/dist/services/message-service.js +0 -220
  875. package/dist/services/message-service.js.map +0 -1
  876. package/dist/utils/workspace-config.d.ts +0 -8
  877. package/dist/utils/workspace-config.js +0 -22
  878. package/dist/utils/workspace-config.js.map +0 -1
  879. package/dist/web/_app/immutable/assets/0.uPSqtsC5.css +0 -1
  880. package/dist/web/_app/immutable/assets/11.2jzI3cZY.css +0 -1
  881. package/dist/web/_app/immutable/assets/12.CT4xL4K3.css +0 -1
  882. package/dist/web/_app/immutable/assets/13.BUrHkYry.css +0 -1
  883. package/dist/web/_app/immutable/assets/2.DpQWr6q6.css +0 -1
  884. package/dist/web/_app/immutable/assets/3.BxdqT7zi.css +0 -1
  885. package/dist/web/_app/immutable/assets/4.DdNqjd0T.css +0 -1
  886. package/dist/web/_app/immutable/assets/5.DFeGxLYf.css +0 -1
  887. package/dist/web/_app/immutable/assets/6.DXZr_rI_.css +0 -1
  888. package/dist/web/_app/immutable/assets/GraphView.D9ePpZez.css +0 -1
  889. package/dist/web/_app/immutable/assets/Toaster.rN8r_Hzv.css +0 -1
  890. package/dist/web/_app/immutable/assets/ViewToolbar.BWE03S64.css +0 -1
  891. package/dist/web/_app/immutable/assets/WorkspaceView.CgGDi_Zb.css +0 -1
  892. package/dist/web/_app/immutable/assets/wikilinks.CzMBkUMB.css +0 -1
  893. package/dist/web/_app/immutable/chunks/-jG4Obyh.js +0 -4
  894. package/dist/web/_app/immutable/chunks/2nrc8f_O.js +0 -1
  895. package/dist/web/_app/immutable/chunks/7S2LGqoP.js +0 -2
  896. package/dist/web/_app/immutable/chunks/8Q3ZJIYu.js +0 -1
  897. package/dist/web/_app/immutable/chunks/B8ydT5xX.js +0 -2
  898. package/dist/web/_app/immutable/chunks/BB_5th5W.js +0 -3383
  899. package/dist/web/_app/immutable/chunks/BEn6N5c2.js +0 -1
  900. package/dist/web/_app/immutable/chunks/BFNNbCAM.js +0 -1
  901. package/dist/web/_app/immutable/chunks/BFZZEUd5.js +0 -1
  902. package/dist/web/_app/immutable/chunks/BGk8kfOE.js +0 -2
  903. package/dist/web/_app/immutable/chunks/BIBCr278.js +0 -1
  904. package/dist/web/_app/immutable/chunks/BPCkkwqe.js +0 -1
  905. package/dist/web/_app/immutable/chunks/BhlvzGQx.js +0 -1
  906. package/dist/web/_app/immutable/chunks/BlMknQeF.js +0 -1
  907. package/dist/web/_app/immutable/chunks/BpjqrlCg.js +0 -1
  908. package/dist/web/_app/immutable/chunks/BrebtJNG.js +0 -18
  909. package/dist/web/_app/immutable/chunks/ByBZ7JIT.js +0 -1
  910. package/dist/web/_app/immutable/chunks/Bz0ZjVQE.js +0 -2
  911. package/dist/web/_app/immutable/chunks/BzJTZOK2.js +0 -1
  912. package/dist/web/_app/immutable/chunks/C0LVZhvn.js +0 -1
  913. package/dist/web/_app/immutable/chunks/C3uBrOS5.js +0 -1
  914. package/dist/web/_app/immutable/chunks/C8R1sDpW.js +0 -1
  915. package/dist/web/_app/immutable/chunks/CDRHrs0g.js +0 -1
  916. package/dist/web/_app/immutable/chunks/CN5_py1I.js +0 -1
  917. package/dist/web/_app/immutable/chunks/CPne482a.js +0 -1
  918. package/dist/web/_app/immutable/chunks/CSVbGg_b.js +0 -5
  919. package/dist/web/_app/immutable/chunks/CWyU-seV.js +0 -1
  920. package/dist/web/_app/immutable/chunks/CX_61fY8.js +0 -5
  921. package/dist/web/_app/immutable/chunks/CYrVHOHA.js +0 -1
  922. package/dist/web/_app/immutable/chunks/ChsiIm-E.js +0 -1
  923. package/dist/web/_app/immutable/chunks/CojKppbh.js +0 -1
  924. package/dist/web/_app/immutable/chunks/CqkleIqs.js +0 -1
  925. package/dist/web/_app/immutable/chunks/CtYLtD7K.js +0 -1
  926. package/dist/web/_app/immutable/chunks/D-HDy5qS.js +0 -1
  927. package/dist/web/_app/immutable/chunks/D5aIYSkd.js +0 -1
  928. package/dist/web/_app/immutable/chunks/DBBXARVf.js +0 -1
  929. package/dist/web/_app/immutable/chunks/DFYOFE3o.js +0 -1
  930. package/dist/web/_app/immutable/chunks/DKaafS50.js +0 -1
  931. package/dist/web/_app/immutable/chunks/DUMOo1Pn.js +0 -1
  932. package/dist/web/_app/immutable/chunks/DWX9f6AF.js +0 -1
  933. package/dist/web/_app/immutable/chunks/DY8-EONP.js +0 -1
  934. package/dist/web/_app/immutable/chunks/DkoHPElM.js +0 -1
  935. package/dist/web/_app/immutable/chunks/DnL9f0lc.js +0 -7
  936. package/dist/web/_app/immutable/chunks/DsnmJJEf.js +0 -1
  937. package/dist/web/_app/immutable/chunks/DujahU3W.js +0 -1
  938. package/dist/web/_app/immutable/chunks/FBn-ES5k.js +0 -1
  939. package/dist/web/_app/immutable/chunks/HQpwSOb3.js +0 -1
  940. package/dist/web/_app/immutable/chunks/KyjzlWRN.js +0 -2
  941. package/dist/web/_app/immutable/chunks/PPVm8Dsz.js +0 -1
  942. package/dist/web/_app/immutable/chunks/UwYfmrPx.js +0 -2
  943. package/dist/web/_app/immutable/chunks/W25ZVI8L.js +0 -2
  944. package/dist/web/_app/immutable/chunks/WSUKABI_.js +0 -1
  945. package/dist/web/_app/immutable/chunks/XjsuEnNE.js +0 -5
  946. package/dist/web/_app/immutable/chunks/ZD6ARAtb.js +0 -1
  947. package/dist/web/_app/immutable/chunks/pBTf4stg.js +0 -2
  948. package/dist/web/_app/immutable/chunks/rGIarcnp.js +0 -1
  949. package/dist/web/_app/immutable/chunks/vxEtkL8z.js +0 -1
  950. package/dist/web/_app/immutable/chunks/wDIGUaoU.js +0 -3
  951. package/dist/web/_app/immutable/entry/app.ZHiTM8WS.js +0 -2
  952. package/dist/web/_app/immutable/entry/start.C9-FfAVc.js +0 -1
  953. package/dist/web/_app/immutable/nodes/0.D92Orz8k.js +0 -2
  954. package/dist/web/_app/immutable/nodes/1.BMdyglM_.js +0 -1
  955. package/dist/web/_app/immutable/nodes/10.BilVDHSL.js +0 -1
  956. package/dist/web/_app/immutable/nodes/11.CS9E0Hic.js +0 -1
  957. package/dist/web/_app/immutable/nodes/12.D3BOxYzG.js +0 -1
  958. package/dist/web/_app/immutable/nodes/13.CClMKYtN.js +0 -1
  959. package/dist/web/_app/immutable/nodes/2.C8KKElPL.js +0 -267
  960. package/dist/web/_app/immutable/nodes/3.C5n5uiWU.js +0 -5
  961. package/dist/web/_app/immutable/nodes/4.DcGVvgiL.js +0 -11
  962. package/dist/web/_app/immutable/nodes/5.DxIjnFQO.js +0 -4
  963. package/dist/web/_app/immutable/nodes/6.lkh6kKLj.js +0 -1
  964. package/dist/web/_app/immutable/nodes/7.Bg7SbfhA.js +0 -1
  965. package/dist/web/_app/immutable/nodes/8.DcXWCNBE.js +0 -1
  966. package/dist/web/_app/immutable/nodes/9.Cefi8Jfm.js +0 -1
@@ -2,132 +2,64 @@
2
2
  * Graph API routes
3
3
  *
4
4
  * REST façade over WorkspaceManager.
5
- * Enforces per-collection access when users are authenticated via JWT.
5
+ * Enforces per-folder access when users are authenticated via JWT.
6
6
  * API key / open mode → full access (no filtering).
7
7
  */
8
- import { isAdminOrOwner } from '../../services/auth-service.js';
8
+ import { isWorkspaceOwner } from '../../services/auth-service.js';
9
+ import { WorkspaceAccessService } from '../../services/workspace-access.js';
9
10
  import { Workspace } from '../../core/workspace.js';
10
- import { existsSync } from 'fs';
11
- import { join } from 'path';
12
- /** Convert an AuthUser from the request to a GitUser for commit attribution. */
13
- function reqGitUser(req) {
14
- const user = req.user;
15
- if (!user)
16
- return undefined;
17
- return { id: String(user.id), name: user.displayName, email: user.email };
18
- }
19
- /** Check if a user can see a private repo (only the owner can). */
20
- function canSeePrivateRepo(repo, user) {
21
- if (!repo.private)
22
- return true;
23
- if (!user)
24
- return !repo.owner_id; // API key / open mode — only if no owner set
25
- if (!repo.owner_id)
26
- return false; // Private without owner — hidden from all users
27
- return user.id === repo.owner_id;
28
- }
29
- /** Get the list of repos the current user can access. */
30
- function getAccessibleRepos(req, workspaceManager, authService) {
31
- const allRepos = workspaceManager.getAllRepoConfigs();
32
- const user = req.user;
33
- // Filter out private repos owned by other users (applies to all roles)
34
- const visible = allRepos.filter(r => canSeePrivateRepo(r, user));
35
- if (!user)
36
- return visible; // API key / open mode → full access
37
- if (isAdminOrOwner(user.role))
38
- return visible;
39
- const allowed = new Set(authService.getUserCollections(user.id));
40
- return visible.filter(r => r.private || allowed.has(r.name));
41
- }
42
- /** Check if the current user can access a specific repo. */
43
- function hasRepoAccess(req, repoName, workspaceManager, authService) {
44
- const user = req.user;
45
- const repoConfig = workspaceManager.getRepoConfig(repoName);
46
- if (repoConfig && !canSeePrivateRepo(repoConfig, user))
47
- return false;
48
- if (!user)
49
- return true; // API key / open mode
50
- if (repoConfig?.private && repoConfig?.owner_id === user.id)
51
- return true; // Owner of private repo
52
- if (isAdminOrOwner(user.role))
53
- return true;
54
- return authService.getUserCollections(user.id).includes(repoName);
55
- }
56
- /** Check if the current user is an admin. */
57
- function isAdmin(req) {
58
- const user = req.user;
59
- if (!user)
60
- return true; // API key / open mode → admin-equivalent
61
- return isAdminOrOwner(user.role);
62
- }
63
- /**
64
- * Build a set of all entity IDs visible to the current user.
65
- * Used to filter wikilinks so users don't see links to entities
66
- * in collections they can't access.
67
- */
68
- function getAccessibleEntityIds(req, workspaceManager, authService) {
69
- const user = req.user;
70
- if (!user)
71
- return null; // API key / open mode → no filtering
72
- if (isAdminOrOwner(user.role)) {
73
- // Admins see everything except other users' private repos
74
- const allRepos = workspaceManager.getAllRepoConfigs();
75
- const hasHiddenPrivate = allRepos.some(r => r.private && r.owner_id && r.owner_id !== user.id);
76
- if (!hasHiddenPrivate)
77
- return null; // No filtering needed
78
- }
79
- const repos = getAccessibleRepos(req, workspaceManager, authService);
80
- const ids = new Set();
81
- for (const repo of repos) {
82
- try {
83
- const graph = workspaceManager.getGraph(repo.name);
84
- for (const e of graph.search({})) {
85
- ids.add(e.id);
86
- }
87
- }
88
- catch { /* skip */ }
89
- }
90
- return ids;
91
- }
92
- /** Filter wikilinks to only those pointing to accessible entities. */
93
- function filterWikilinks(wikilinks, accessibleIds) {
94
- if (!accessibleIds)
95
- return wikilinks;
96
- return wikilinks.filter(id => accessibleIds.has(id));
97
- }
11
+ import { createSharedFolder, FolderCreationError } from '../../services/folder-creation.js';
12
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
13
+ import { dirname, join } from 'path';
14
+ import { RESERVED_ENTITY_TYPES } from '../../core/validation.js';
15
+ import { validateFolderPath } from '../../core/folder-paths.js';
16
+ import { deriveAssetDisplayName, stripFrontmatter } from '../../core/embeds.js';
17
+ import { parseFrontmatterCacheFree } from '../../core/history-diff.js';
18
+ import { StaleBaseRevisionError } from '../../core/graph.js';
19
+ import { checkEagerSkillPermission, filterWikilinks, getAccessibleEntityIds, getAccessibleRepos, getContentRepos, graphForAdmin as resolveGraphForAdmin, graphForRead as resolveGraphForRead, graphForWrite as resolveGraphForWrite, isFolderAdminOrOwner, reqGitUser, reqUser, } from './graph-api-access.js';
20
+ import { BridgeError, assertCanChangeUserHandle, validateUserHandleInPayload } from '../../services/user-person-bridge.js';
21
+ import { assertEntityRefs, EntityRefError, UnknownUserRefError } from '../../services/entity-mutations.js';
22
+ import { assetDeleteDenial } from '../../services/asset-delete-authz.js';
23
+ import { clearLegacyBucketCache } from '../../services/asset-listing.js';
24
+ import { log } from '../../utils/log.js';
98
25
  export async function registerGraphApiRoutes(fastify, workspaceManager, authService, wsHub, sessionManager) {
99
- // POST /api/repos create a new collection (admin/owner only)
26
+ // ARCHITECTURAL BOUNDARY: every per-route graph resolution flows through
27
+ // this service so the act of getting a graph IS the access check. Do not
28
+ // call `workspaceManager.getGraph(repo)` from a request-scoped handler —
29
+ // use `accessService.graphFor*(reqUser(req), repo)` instead. A security
30
+ // audit (2026-05) flagged the previous opt-in pattern (route had to
31
+ // remember `hasRepoAccess()` before `getGraph()`) as an architectural
32
+ // risk; if you find yourself reaching for the raw WM here, that's the
33
+ // smell the audit was about.
34
+ const accessService = new WorkspaceAccessService(workspaceManager, authService);
35
+ const graphForRead = (req, reply, repoName) => resolveGraphForRead(accessService, req, reply, repoName);
36
+ const graphForWrite = (req, reply, repoName) => resolveGraphForWrite(accessService, req, reply, repoName);
37
+ const graphForAdmin = (req, reply, repoName) => resolveGraphForAdmin(accessService, req, reply, repoName);
38
+ // POST /api/repos — any authenticated user can create a folder.
39
+ // The creator is granted folder-admin access automatically.
40
+ // Shared logic lives in services/folder-creation.ts so the MCP tool
41
+ // create_repo behaves identically.
100
42
  fastify.post('/api/repos', async (req, reply) => {
101
- if (!isAdmin(req)) {
102
- return reply.status(403).send({ error: 'Admin access required' });
103
- }
43
+ const user = req.user;
104
44
  const { name, display_name, description } = req.body || {};
105
- if (!name || !/^[a-z0-9][a-z0-9-]*$/.test(name)) {
106
- return reply.status(400).send({ error: 'Collection name must be kebab-case' });
107
- }
108
45
  try {
109
- const workspace = new Workspace(workspaceManager['workspacePath']);
110
- const repo = await workspace.registerRepo({
111
- name,
112
- display_name: display_name || undefined,
113
- description,
114
- });
115
- workspaceManager.reloadConfig();
116
- wsHub?.broadcast({
117
- type: 'collection_created', repo: name,
118
- actor: req.user?.displayName ?? 'API',
119
- timestamp: new Date().toISOString(),
120
- });
46
+ const repo = await createSharedFolder(workspaceManager, authService, { name, display_name, description }, user, wsHub);
121
47
  return reply.status(201).send(repo);
122
48
  }
123
49
  catch (err) {
124
- return reply.status(409).send({ error: err.message });
50
+ if (err instanceof FolderCreationError) {
51
+ const status = err.code === 'invalid_name' ? 400
52
+ : err.code === 'duplicate' ? 409
53
+ : 500;
54
+ return reply.status(status).send({ error: err.message });
55
+ }
56
+ return reply.status(500).send({ error: err?.message ?? String(err) });
125
57
  }
126
58
  });
127
- // DELETE /api/repos/:repo — remove a collection and delete its files (admin only)
59
+ // DELETE /api/repos/:repo — folder admin or workspace owner only.
128
60
  fastify.delete('/api/repos/:repo', async (req, reply) => {
129
- if (!isAdmin(req)) {
130
- return reply.status(403).send({ error: 'Admin access required' });
61
+ if (!isFolderAdminOrOwner(req, req.params.repo, workspaceManager, authService)) {
62
+ return reply.status(403).send({ error: 'Folder admin access required' });
131
63
  }
132
64
  const { repo } = req.params;
133
65
  try {
@@ -135,14 +67,19 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
135
67
  const config = await workspace.loadConfig();
136
68
  const repoIndex = config.repos.findIndex(r => r.name === repo);
137
69
  if (repoIndex === -1) {
138
- return reply.status(404).send({ error: `Collection "${repo}" not found` });
70
+ return reply.status(404).send({ error: `Folder \u201C${repo}\u201D not found` });
139
71
  }
140
72
  const repoConfig = config.repos[repoIndex];
73
+ // System folders (the reserved Workspace Assets store) are an app
74
+ // invariant — undeletable, so nothing needs to recreate them on boot.
75
+ if (repoConfig.system) {
76
+ return reply.status(403).send({ error: 'This is a system folder and cannot be deleted.' });
77
+ }
141
78
  const repoPath = join(workspaceManager['workspacePath'], repoConfig.path);
142
79
  // Remove from config
143
80
  config.repos.splice(repoIndex, 1);
144
81
  workspace.writeConfig(config);
145
- workspace.pushWorkspaceConfig(`Remove collection: ${repo}`);
82
+ workspace.pushWorkspaceConfig(`Remove folder: ${repo}`);
146
83
  // Delete files
147
84
  const { rmSync } = await import('fs');
148
85
  if (existsSync(repoPath)) {
@@ -150,7 +87,7 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
150
87
  }
151
88
  workspaceManager.reloadConfig();
152
89
  wsHub?.broadcast({
153
- type: 'collection_deleted', repo,
90
+ type: 'folder_deleted', repo,
154
91
  actor: req.user?.displayName ?? 'API',
155
92
  timestamp: new Date().toISOString(),
156
93
  });
@@ -160,16 +97,16 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
160
97
  return reply.status(500).send({ error: err.message });
161
98
  }
162
99
  });
163
- // PATCH /api/repos/:repo — update a collection (rename, sync rules) (admin only)
100
+ // PATCH /api/repos/:repo — rename/update sync rules. Folder admin or workspace owner only.
164
101
  fastify.patch('/api/repos/:repo', async (req, reply) => {
165
- if (!isAdmin(req)) {
166
- return reply.status(403).send({ error: 'Admin access required' });
102
+ if (!isFolderAdminOrOwner(req, req.params.repo, workspaceManager, authService)) {
103
+ return reply.status(403).send({ error: 'Folder admin access required' });
167
104
  }
168
105
  const { repo } = req.params;
169
106
  const body = req.body || {};
170
107
  const { name: newName, display_name: newDisplayName, sync: newSync, ...rest } = body;
171
- if ('private' in rest) {
172
- return reply.status(400).send({ error: 'The private flag is immutable after creation' });
108
+ if ('personal' in rest) {
109
+ return reply.status(400).send({ error: 'The personal flag is immutable after creation' });
173
110
  }
174
111
  if (newName === undefined && newSync === undefined && newDisplayName === undefined) {
175
112
  return reply.status(400).send({ error: 'Nothing to update' });
@@ -182,12 +119,17 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
182
119
  const config = await workspace.loadConfig();
183
120
  const repoConfig = config.repos.find(r => r.name === repo);
184
121
  if (!repoConfig) {
185
- return reply.status(404).send({ error: `Collection "${repo}" not found` });
122
+ return reply.status(404).send({ error: `Folder \u201C${repo}\u201D not found` });
123
+ }
124
+ // System folders (the reserved Workspace Assets store) have a reserved
125
+ // name + fixed display; renaming/reconfiguring them is not allowed.
126
+ if (repoConfig.system) {
127
+ return reply.status(403).send({ error: 'This is a system folder and cannot be renamed or reconfigured.' });
186
128
  }
187
129
  // Handle rename
188
130
  if (newName !== undefined) {
189
131
  if (config.repos.some(r => r.name === newName)) {
190
- return reply.status(409).send({ error: `Collection "${newName}" already exists` });
132
+ return reply.status(409).send({ error: `Folder \u201C${newName}\u201D already exists` });
191
133
  }
192
134
  const wsPath = workspaceManager['workspacePath'];
193
135
  const oldPath = join(wsPath, repoConfig.path);
@@ -206,8 +148,8 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
206
148
  if (existsSync(markerPath)) {
207
149
  writeFS(markerPath, JSON.stringify({ repo_name: newName }, null, 2), 'utf-8');
208
150
  }
209
- // Update collection_access to follow the rename
210
- authService.renameCollection(repo, newName);
151
+ // Update folder_access to follow the rename
152
+ authService.renameFolder(repo, newName);
211
153
  }
212
154
  // Handle display name update (without rename)
213
155
  if (newDisplayName !== undefined && newName === undefined) {
@@ -218,7 +160,7 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
218
160
  repoConfig.sync = newSync.length > 0 ? newSync : undefined;
219
161
  }
220
162
  workspace.writeConfig(config);
221
- workspace.pushWorkspaceConfig(newName !== undefined ? `Rename collection: ${repo} → ${newName}` : `Update collection: ${repo}`);
163
+ workspace.pushWorkspaceConfig(newName !== undefined ? `Rename folder: ${repo} → ${newName}` : `Update folder: ${repo}`);
222
164
  workspaceManager.reloadConfig();
223
165
  return reply.send(repoConfig);
224
166
  }
@@ -226,50 +168,128 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
226
168
  return reply.status(500).send({ error: err.message });
227
169
  }
228
170
  });
229
- // GET /api/repos — list accessible repos (re-reads config to pick up CLI changes)
171
+ // GET /api/repos — list accessible repos (re-reads config to pick up CLI changes).
172
+ // Augments each repo with the caller's folder role ('admin' | 'editor'),
173
+ // member_count, entity_types (distinct types present), and folders (the
174
+ // flat list of nested-folder paths in the repo, used by the sidebar to
175
+ // render the active-path expansion).
230
176
  fastify.get('/api/repos', async (req, reply) => {
231
177
  workspaceManager.reloadConfig();
232
- const repos = getAccessibleRepos(req, workspaceManager, authService);
233
- return reply.send(repos);
178
+ // System folders (the reserved Workspace Assets store) are accessible but
179
+ // excluded from the sidebar — they're surfaced via the asset manager, not
180
+ // the folder list. They stay in getAccessibleRepos so authz + the asset
181
+ // manager (/api/workspace/assets) still see them.
182
+ const repos = getContentRepos(req, workspaceManager, authService);
183
+ const user = req.user;
184
+ const enriched = repos.map(r => {
185
+ const memberCount = authService.getFolderMembers(r.name).length;
186
+ const callerRole = user ? authService.getFolderRole(user.id, r.name) : null;
187
+ let entityTypes = [];
188
+ // `folders` carries the per-folder display_name override (when one
189
+ // exists in the folder's .studiograph-folder.json sidecar); the client
190
+ // falls back to toDisplayName(slug) when display_name is absent.
191
+ let folders = [];
192
+ try {
193
+ // Direct WM access OK here — `repos` came from getAccessibleRepos
194
+ // above, so every iteration is over a folder the caller can read.
195
+ const graph = workspaceManager.getGraph(r.name);
196
+ entityTypes = graph.listEntityTypes();
197
+ folders = graph.listFolders().map(f => {
198
+ const entry = { path: f.path };
199
+ if (f.display_name)
200
+ entry.display_name = f.display_name;
201
+ if (f.id)
202
+ entry.id = f.id;
203
+ return entry;
204
+ });
205
+ }
206
+ catch { /* repo not mounted */ }
207
+ return {
208
+ ...r,
209
+ caller_role: callerRole,
210
+ member_count: memberCount,
211
+ entity_types: entityTypes,
212
+ folders,
213
+ };
214
+ });
215
+ return reply.send(enriched);
216
+ });
217
+ // GET /api/admin/folders — workspace-owner-only metadata list of every folder
218
+ // (name, display_name, creator, member count). No content access implied —
219
+ // owners still need to join a folder to read its entries.
220
+ fastify.get('/api/admin/folders', async (req, reply) => {
221
+ const user = req.user;
222
+ if (user && !isWorkspaceOwner(user.role)) {
223
+ return reply.status(403).send({ error: 'Workspace owner access required' });
224
+ }
225
+ workspaceManager.reloadConfig();
226
+ const all = workspaceManager.getAllRepoConfigs();
227
+ const result = all.map(r => {
228
+ const members = authService.getFolderMembers(r.name);
229
+ const admins = members.filter(m => m.role === 'admin');
230
+ const creator = r.owner_id
231
+ ? members.find(m => m.user.id === r.owner_id)?.user
232
+ : admins[0]?.user;
233
+ let entryCount = 0;
234
+ // Direct WM access OK — this endpoint is owner-only (gated above) and
235
+ // returns metadata for every folder the workspace owner needs to see
236
+ // for admin purposes. No content is leaked: only entry counts.
237
+ try {
238
+ entryCount = workspaceManager.getGraph(r.name).search({}).length;
239
+ }
240
+ catch { /* skip */ }
241
+ return {
242
+ name: r.name,
243
+ display_name: r.display_name ?? r.name,
244
+ description: r.description,
245
+ member_count: members.length,
246
+ admin_count: admins.length,
247
+ entry_count: entryCount,
248
+ creator: creator ? { id: creator.id, displayName: creator.displayName, email: creator.email } : null,
249
+ caller_is_member: user ? members.some(m => m.user.id === user.id) : true,
250
+ };
251
+ });
252
+ return reply.send(result);
234
253
  });
254
+ // Build the lightweight summary objects used by /entities?summary=true.
255
+ // Extracted so the per-repo and bulk endpoints share the same shape.
256
+ const EXCLUDED_SUMMARY_FIELDS = new Set([
257
+ 'entity_type', 'entity_id', 'created_by', 'updated_by',
258
+ 'description', 'content', 'context', 'rationale', 'alternatives', 'consequences',
259
+ 'steps', 'guidelines', 'bio', 'agenda', 'summary', 'decision', 'action_items',
260
+ ]);
261
+ function buildSummaries(entities, accessibleIds) {
262
+ return entities.map(e => {
263
+ const extra = Object.fromEntries(Object.entries(e.data ?? {}).filter(([k, v]) => !EXCLUDED_SUMMARY_FIELDS.has(k) && k !== 'name' && k !== 'created_at' && k !== 'updated_at' &&
264
+ (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || Array.isArray(v) || v instanceof Date)));
265
+ return {
266
+ id: e.id,
267
+ entityType: e.entityType,
268
+ folder_path: e.folder_path ?? '',
269
+ data: {
270
+ name: e.data.name,
271
+ created_at: e.data.created_at,
272
+ updated_at: e.data.updated_at,
273
+ ...extra,
274
+ },
275
+ document: { wikilinks: filterWikilinks(e.document?.wikilinks ?? [], accessibleIds) },
276
+ };
277
+ });
278
+ }
235
279
  // GET /api/repos/:repo/entities — list all entities in a repo
236
280
  // ?summary=true returns lightweight objects (no content, minimal data) for sidebar/graph
237
281
  fastify.get('/api/repos/:repo/entities', async (req, reply) => {
238
282
  const { repo } = req.params;
239
- if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
240
- return reply.status(404).send({ error: `Collection "${repo}" not found` });
241
- }
242
- let graph;
243
- try {
244
- graph = workspaceManager.getGraph(repo);
245
- }
246
- catch (err) {
247
- return reply.status(404).send({ error: err.message });
248
- }
283
+ // Access enforced by graphForRead \u2014 do not call workspaceManager.getGraph
284
+ // directly here. The audit (2026-05) flagged direct WM access from routes
285
+ // as the primary architectural risk; the service is the gate.
286
+ const graph = graphForRead(req, reply, repo);
287
+ if (!graph)
288
+ return;
249
289
  const entities = graph.search({});
250
290
  const accessibleIds = getAccessibleEntityIds(req, workspaceManager, authService);
251
291
  if (req.query.summary === 'true') {
252
- const EXCLUDED_SUMMARY_FIELDS = new Set([
253
- 'entity_type', 'entity_id', 'created_by', 'updated_by',
254
- 'description', 'content', 'context', 'rationale', 'alternatives', 'consequences',
255
- 'steps', 'guidelines', 'bio', 'agenda', 'summary', 'decision', 'action_items',
256
- ]);
257
- const summaries = entities.map(e => {
258
- const extra = Object.fromEntries(Object.entries(e.data ?? {}).filter(([k, v]) => !EXCLUDED_SUMMARY_FIELDS.has(k) && k !== 'name' && k !== 'created_at' && k !== 'updated_at' &&
259
- (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || Array.isArray(v) || v instanceof Date)));
260
- return {
261
- id: e.id,
262
- entityType: e.entityType,
263
- data: {
264
- name: e.data.name,
265
- created_at: e.data.created_at,
266
- updated_at: e.data.updated_at,
267
- ...extra,
268
- },
269
- document: { wikilinks: filterWikilinks(e.document?.wikilinks ?? [], accessibleIds) },
270
- };
271
- });
272
- return reply.send(summaries);
292
+ return reply.send(buildSummaries(entities, accessibleIds));
273
293
  }
274
294
  // Full response — filter wikilinks in document
275
295
  const filtered = entities.map(e => {
@@ -279,19 +299,57 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
279
299
  });
280
300
  return reply.send(filtered);
281
301
  });
302
+ // GET /api/workspace/entities?summary=true — bulk entity summaries for ALL
303
+ // accessible repos in a single round-trip. Replaces 15+ parallel
304
+ // /api/repos/:repo/entities?summary=true calls. Important on dev where each
305
+ // request has Vite middleware overhead and the browser's HTTP/1.1 6-slot
306
+ // limit serializes them, blocking other navigation fetches.
307
+ // Response shape: `{ [repoName]: EntitySummary[] }`.
308
+ fastify.get('/api/workspace/entities', async (req, reply) => {
309
+ // Mirror the per-repo endpoint's contract: only summary=true is supported
310
+ // here. Returning full bodies for the whole workspace would be huge and
311
+ // isn't a use case we have today.
312
+ if (req.query.summary !== 'true') {
313
+ return reply.status(400).send({ error: 'summary=true is required for the bulk entities endpoint' });
314
+ }
315
+ const repos = getContentRepos(req, workspaceManager, authService);
316
+ // Single-pass: collect the per-repo entity arrays AND the accessible-ids
317
+ // set in one walk. The previous shape called graph.search({}) twice per
318
+ // repo (once via getAccessibleEntityIds, once in this loop), doubling
319
+ // disk I/O on the most-trafficked endpoint.
320
+ const user = reqUser(req);
321
+ const collectIds = user !== undefined; // unauthenticated → no wikilink filtering
322
+ const accessibleIds = collectIds ? new Set() : null;
323
+ const perRepoEntities = [];
324
+ for (const r of repos) {
325
+ let graph;
326
+ // Direct WM access OK — `repos` is the access-filtered list above.
327
+ try {
328
+ graph = workspaceManager.getGraph(r.name);
329
+ }
330
+ catch {
331
+ continue;
332
+ } // skip unmounted repos rather than failing the whole request
333
+ const entities = graph.search({});
334
+ perRepoEntities.push({ name: r.name, entities });
335
+ if (accessibleIds) {
336
+ for (const e of entities)
337
+ accessibleIds.add(e.id);
338
+ }
339
+ }
340
+ const result = {};
341
+ for (const { name, entities } of perRepoEntities) {
342
+ result[name] = buildSummaries(entities, accessibleIds);
343
+ }
344
+ return reply.send(result);
345
+ });
282
346
  // GET /api/repos/:repo/entities/:type — list entities of a type
283
347
  fastify.get('/api/repos/:repo/entities/:type', async (req, reply) => {
284
348
  const { repo, type } = req.params;
285
- if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
286
- return reply.status(404).send({ error: `Collection "${repo}" not found` });
287
- }
288
- let graph;
289
- try {
290
- graph = workspaceManager.getGraph(repo);
291
- }
292
- catch (err) {
293
- return reply.status(404).send({ error: err.message });
294
- }
349
+ // Access enforced by graphForRead (WorkspaceAccessService).
350
+ const graph = graphForRead(req, reply, repo);
351
+ if (!graph)
352
+ return;
295
353
  const entities = graph.search({ entityType: type });
296
354
  const accessibleIds = getAccessibleEntityIds(req, workspaceManager, authService);
297
355
  const filtered = entities.map(e => {
@@ -304,17 +362,20 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
304
362
  // GET /api/repos/:repo/entities/:type/:id — get single entity
305
363
  fastify.get('/api/repos/:repo/entities/:type/:id', async (req, reply) => {
306
364
  const { repo, type, id } = req.params;
307
- if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
308
- return reply.status(404).send({ error: `Collection "${repo}" not found` });
309
- }
310
- let graph;
365
+ // Access enforced by graphForRead (WorkspaceAccessService).
366
+ const graph = graphForRead(req, reply, repo);
367
+ if (!graph)
368
+ return;
369
+ let entity;
311
370
  try {
312
- graph = workspaceManager.getGraph(repo);
371
+ entity = graph.get(type, id);
313
372
  }
314
373
  catch (err) {
315
- return reply.status(404).send({ error: err.message });
374
+ return reply.status(404).send({ error: err.message ?? `Entity not found: ${type}/${id}` });
316
375
  }
317
- const entity = graph.get(type, id);
376
+ // graph.get may also return null for a missing entity without throwing
377
+ // (depends on the backing graph implementation). Treat that as 404 so
378
+ // we don't blow up on the wikilinks access below.
318
379
  if (!entity) {
319
380
  return reply.status(404).send({ error: `Entity not found: ${type}/${id}` });
320
381
  }
@@ -330,33 +391,151 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
330
391
  // GET /api/repos/:repo/entities/:type/:id/history — entity commit history
331
392
  fastify.get('/api/repos/:repo/entities/:type/:id/history', async (req, reply) => {
332
393
  const { repo, type, id } = req.params;
333
- if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
334
- return reply.status(404).send({ error: `Collection "${repo}" not found` });
394
+ // Access enforced by graphForRead (WorkspaceAccessService).
395
+ const graph = graphForRead(req, reply, repo);
396
+ if (!graph)
397
+ return;
398
+ const gitService = graph.getGitService();
399
+ if (!gitService) {
400
+ return reply.send([]);
335
401
  }
336
- let graph;
402
+ const limit = Math.min(parseInt(req.query.limit ?? '20', 10) || 20, 50);
403
+ // Resolve the actual on-disk path via graph.get() — it walks nested
404
+ // folders to find the file. The previous hardcoded `${plural}/${id}.md`
405
+ // missed any entry not at the legacy flat path (entries created in a
406
+ // sub-folder, MCP entries that landed elsewhere, etc.) and silently
407
+ // returned an empty history. Convert absolute path → repo-relative
408
+ // for git log.
409
+ let filePath;
337
410
  try {
338
- graph = workspaceManager.getGraph(repo);
411
+ const entity = graph.get(type, id);
412
+ const repoRoot = graph.getRepoPath();
413
+ filePath = entity.path.startsWith(repoRoot + '/')
414
+ ? entity.path.slice(repoRoot.length + 1)
415
+ : entity.path;
339
416
  }
340
417
  catch (err) {
341
418
  return reply.status(404).send({ error: err.message });
342
419
  }
343
- const gitService = graph.getGitService();
344
- if (!gitService) {
345
- return reply.send([]);
346
- }
347
- const limit = Math.min(parseInt(req.query.limit ?? '20', 10) || 20, 50);
348
- const { PLURAL_TO_SINGULAR } = await import('../../core/validation.js');
349
- // Reverse lookup: singular type → plural folder name
350
- const pluralDir = Object.entries(PLURAL_TO_SINGULAR).find(([, v]) => v === type)?.[0] ?? `${type}s`;
351
- const filePath = `${pluralDir}/${id}.md`;
352
420
  try {
353
421
  const history = gitService.getFileHistory(filePath, limit);
422
+ // Opt-in per-commit change summary for the History panel. Off by
423
+ // default so the eager gating fetch on every entity-page load stays
424
+ // cheap; the panel requests it only when expanded.
425
+ if (req.query.changes === 'true') {
426
+ return reply.send(history.map((c) => ({ ...c, changes: graph.getCommitChange(filePath, c.hash) })));
427
+ }
354
428
  return reply.send(history);
355
429
  }
356
430
  catch {
357
431
  return reply.send([]);
358
432
  }
359
433
  });
434
+ // GET /api/repos/:repo/entities/:type/:id/at-commit/:hash — full body content
435
+ // of an entry as it was at a past commit, for the History "version preview"
436
+ // (read-only Live Preview render). Same read gate as /history.
437
+ //
438
+ // Reuses graph.getEntityAtCommit, which walks the commit tree by IDENTITY
439
+ // (entityType, entityId) — so a rename/move between then and now resolves to
440
+ // the right historical file without the route duplicating path-slicing, and a
441
+ // commit where the entry didn't yet exist at this identity resolves to null.
442
+ // null → typed 404 VERSION_NOT_AT_PATH so the UI can show an honest "this
443
+ // version isn't viewable here" message instead of a misleading generic error.
444
+ fastify.get('/api/repos/:repo/entities/:type/:id/at-commit/:hash', async (req, reply) => {
445
+ const { repo, type, id, hash } = req.params;
446
+ // Defense-in-depth: the hash always comes from our own history list, but
447
+ // validate the shape before it reaches git.
448
+ if (!/^[0-9a-f]{7,40}$/.test(hash)) {
449
+ return reply.status(400).send({ error: 'Invalid commit hash' });
450
+ }
451
+ // Access enforced by graphForRead (WorkspaceAccessService).
452
+ const graph = graphForRead(req, reply, repo);
453
+ if (!graph)
454
+ return;
455
+ const hit = graph.getEntityAtCommit(type, id, hash);
456
+ if (hit === null) {
457
+ return reply.status(404).send({ error: 'VERSION_NOT_AT_PATH' });
458
+ }
459
+ // Editor content is body-only (frontmatter stripped) — match it so the
460
+ // preview renders exactly what the live editor would. The parsed
461
+ // frontmatter rides along so the preview can also show the title + fields
462
+ // as they were at this commit. parseFrontmatterCacheFree (NOT the shared
463
+ // gray-matter MarkdownService) is required for historical content — the
464
+ // shared parser's module-level cache returns stale values for past commits.
465
+ return reply.send({
466
+ body: stripFrontmatter(hit.content),
467
+ frontmatter: parseFrontmatterCacheFree(hit.content),
468
+ });
469
+ });
470
+ // POST /api/repos/:repo/entities/:type/:id/restore — restore an entry to
471
+ // its content at a past commit. Single-entity scope. Editor-or-admin role
472
+ // required (canWriteRepo). Returns 200 with the restore result, 404 if
473
+ // the entry doesn't exist at that commit, 409 PATH_CONFLICT if a different
474
+ // entry now occupies the target path, 403 if the caller lacks write access.
475
+ fastify.post('/api/repos/:repo/entities/:type/:id/restore', async (req, reply) => {
476
+ const { repo, type, id } = req.params;
477
+ const { commitHash } = req.body ?? {};
478
+ if (!commitHash || typeof commitHash !== 'string') {
479
+ return reply.status(400).send({ error: 'commitHash is required' });
480
+ }
481
+ // Access enforced by graphForWrite: 404 if no read access, 403 if no write.
482
+ const graph = graphForWrite(req, reply, repo);
483
+ if (!graph)
484
+ return;
485
+ // Flush any deferred autosave for this repo first — otherwise a restore
486
+ // could overwrite a pending (uncommitted) edit on disk and commit only
487
+ // the restored version, silently discarding the in-flight work. Matches
488
+ // rename/move/change-type, which all flush before mutating.
489
+ if (sessionManager)
490
+ await sessionManager.flush(repo);
491
+ try {
492
+ // restoreFile reconciles any live editor Y.Doc against the restored disk
493
+ // content internally (via the BodyWriteCoordinator's per-doc lock), so an
494
+ // open editor shows the restored version instead of syncing stale content
495
+ // back over it. That lives in graph.restoreFile so every restore caller
496
+ // (this route, the agent tool, the MCP tool) gets it.
497
+ const result = await graph.restoreFile(type, id, commitHash, reqGitUser(req));
498
+ wsHub?.broadcast({
499
+ type: 'entity_change',
500
+ action: result.action === 'recreate' ? 'created' : 'updated',
501
+ repo,
502
+ entityType: type,
503
+ entityId: id,
504
+ actor: reqUser(req)?.displayName ?? 'API',
505
+ source: 'api',
506
+ timestamp: new Date().toISOString(),
507
+ });
508
+ return reply.send({
509
+ success: true,
510
+ action: result.action,
511
+ path: result.path,
512
+ });
513
+ }
514
+ catch (err) {
515
+ // PathConflictError carries an error code distinct from generic
516
+ // failures so the UI can render an actionable conflict message.
517
+ if (err?.code === 'PATH_CONFLICT' && err?.occupiedBy) {
518
+ return reply.status(409).send({
519
+ error: 'PATH_CONFLICT',
520
+ path: err.path,
521
+ occupiedBy: err.occupiedBy,
522
+ });
523
+ }
524
+ const message = err instanceof Error ? err.message : 'Restore failed';
525
+ // "not found at commit" / "did not exist at commit" → 404 so the
526
+ // client can show "that version isn't available" instead of a
527
+ // generic 500.
528
+ if (/not found at commit|did not exist at commit/i.test(message)) {
529
+ return reply.status(404).send({ error: message });
530
+ }
531
+ // Type-mismatch refusal (restoring would silently change entity_type)
532
+ // is a client-actionable conflict, not a server fault → 409.
533
+ if (/would change its type|was type '/i.test(message)) {
534
+ return reply.status(409).send({ error: message });
535
+ }
536
+ return reply.status(500).send({ error: message });
537
+ }
538
+ });
360
539
  // GET /api/schema/:entityType/fields — field metadata for an entity type
361
540
  fastify.get('/api/schema/:entityType/fields', async (req, reply) => {
362
541
  const { entityType } = req.params;
@@ -364,20 +543,25 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
364
543
  const fields = registry.getFieldMetadata(entityType);
365
544
  return reply.send(fields);
366
545
  });
546
+ // GET /api/formats — the format registry, serialized for the client.
547
+ //
548
+ // Single source of truth for ingestion: the web client builds its accept
549
+ // lists, disposition matrix, and supported-types copy from this instead of
550
+ // maintaining parallel (and drifting) constants. Mirrors the
551
+ // GET /api/schema/:type/fields pattern. Workspace-invariant + auth-gated
552
+ // by the global middleware; no per-repo access needed.
553
+ fastify.get('/api/formats', async (_req, reply) => {
554
+ const { serializeForClient } = await import('../../core/format-registry.js');
555
+ return reply.send(serializeForClient());
556
+ });
367
557
  // GET /api/repos/:repo/search?q=&type=&tags= — search within a repo
368
558
  fastify.get('/api/repos/:repo/search', async (req, reply) => {
369
559
  const { repo } = req.params;
370
- if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
371
- return reply.status(404).send({ error: `Collection "${repo}" not found` });
372
- }
560
+ // Access enforced by graphForRead (WorkspaceAccessService).
561
+ const graph = graphForRead(req, reply, repo);
562
+ if (!graph)
563
+ return;
373
564
  const { q, type, tags } = req.query;
374
- let graph;
375
- try {
376
- graph = workspaceManager.getGraph(repo);
377
- }
378
- catch (err) {
379
- return reply.status(404).send({ error: err.message });
380
- }
381
565
  const results = graph.search({
382
566
  query: q,
383
567
  entityType: type,
@@ -393,19 +577,18 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
393
577
  });
394
578
  // GET /api/graph — full workspace graph topology (nodes + edges)
395
579
  fastify.get('/api/graph', async (req, reply) => {
396
- const repos = getAccessibleRepos(req, workspaceManager, authService);
580
+ const repos = getContentRepos(req, workspaceManager, authService);
397
581
  const nodes = [];
398
582
  const edgeSet = new Set();
399
583
  const edges = [];
400
584
  const entityIds = new Set();
401
585
  for (const repoConfig of repos) {
402
586
  try {
587
+ // Direct WM access OK — `repos` came from getAccessibleRepos above.
403
588
  const graph = workspaceManager.getGraph(repoConfig.name);
404
589
  const entities = graph.search({});
405
590
  for (const entity of entities) {
406
591
  const nodeId = entity.id;
407
- if (nodeId.toLowerCase().startsWith('readme'))
408
- continue;
409
592
  if (!entityIds.has(nodeId)) {
410
593
  entityIds.add(nodeId);
411
594
  nodes.push({
@@ -433,10 +616,10 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
433
616
  const validEdges = edges.filter(e => entityIds.has(e.source) && entityIds.has(e.target));
434
617
  return reply.send({ nodes, edges: validEdges });
435
618
  });
436
- // GET /api/search?q=&type=&tags=&repos= — cross-repo search
619
+ // GET /api/search?q=&type=&tags=&repos=&mode= — cross-repo search
437
620
  fastify.get('/api/search', async (req, reply) => {
438
- const { q, type, tags, repos } = req.query;
439
- const accessibleNames = new Set(getAccessibleRepos(req, workspaceManager, authService).map(r => r.name));
621
+ const { q, type, tags, repos, mode } = req.query;
622
+ const accessibleNames = new Set(getContentRepos(req, workspaceManager, authService).map(r => r.name));
440
623
  // Filter requested repos to only accessible ones
441
624
  let repoNames = repos ? repos.split(',').map(r => r.trim()) : undefined;
442
625
  if (repoNames) {
@@ -445,12 +628,35 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
445
628
  else {
446
629
  repoNames = [...accessibleNames];
447
630
  }
631
+ const keywordMode = mode === 'keyword';
448
632
  const results = await workspaceManager.search({
449
633
  query: q,
450
634
  entityType: type,
451
635
  tags: tags ? tags.split(',').map(t => t.trim()) : undefined,
452
636
  repoNames,
637
+ mode: keywordMode ? 'keyword' : undefined,
638
+ // Autosuggest dropdown caps at 20 visible rows; capping server-side
639
+ // avoids serializing a long tail the client throws away.
640
+ limit: keywordMode ? 20 : undefined,
453
641
  });
642
+ // Autosuggest doesn't need the markdown body or wikilinks — the
643
+ // dropdown renders {name, type, repo} only. Returning a slim shape
644
+ // cuts response size from ~hundreds of KB to a few KB on big
645
+ // workspaces, and skips the per-entity wikilink visibility filter.
646
+ if (keywordMode) {
647
+ const slim = results.map(e => ({
648
+ id: e.id,
649
+ path: e.path,
650
+ folder_path: e.folder_path,
651
+ entityType: e.entityType,
652
+ repoName: e.repoName,
653
+ data: {
654
+ name: e.data?.name,
655
+ updated_at: e.data?.updated_at,
656
+ },
657
+ }));
658
+ return reply.send(slim);
659
+ }
454
660
  const accessibleIds = getAccessibleEntityIds(req, workspaceManager, authService);
455
661
  const filtered = results.map((e) => {
456
662
  if (!e.document?.wikilinks?.length)
@@ -462,20 +668,29 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
462
668
  // PUT /api/repos/:repo/entities/:type/:id — update entity (data and/or content)
463
669
  fastify.put('/api/repos/:repo/entities/:type/:id', async (req, reply) => {
464
670
  const { repo, type, id } = req.params;
465
- if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
466
- return reply.status(404).send({ error: `Collection "${repo}" not found` });
467
- }
468
- let graph;
469
- try {
470
- graph = workspaceManager.getGraph(repo);
471
- }
472
- catch (err) {
473
- return reply.status(404).send({ error: err.message });
474
- }
671
+ // Access enforced by graphForWrite \u2014 PUT updates an entity, write required.
672
+ const graph = graphForWrite(req, reply, repo);
673
+ if (!graph)
674
+ return;
675
+ const eagerError = checkEagerSkillPermission(req, type, req.body.data, repo, workspaceManager, authService);
676
+ if (eagerError)
677
+ return reply.status(403).send({ error: eagerError });
475
678
  try {
476
- // Optimistic concurrency: reject if entity was modified after client last fetched it
679
+ // Optimistic concurrency. Two preconditions:
680
+ // - `X-Studiograph-Base-Body-Hash` (Phase 3a): sha256 hex of the
681
+ // body the client last loaded. Enforced INSIDE the per-doc lock
682
+ // by graph.update via `baseRevision`, so it composes with
683
+ // serialization — two body-writing PUTs from the same baseline
684
+ // can't both pass and back-stomp each other.
685
+ // - `If-Unmodified-Since` (legacy): kept for non-web clients;
686
+ // RFC-7231 second-precision so aliases sub-millisecond writes,
687
+ // which is why the body-hash form is preferred.
688
+ // Body-hash header is IGNORED on metadata-only PUTs — a stale body
689
+ // baseline is irrelevant when the PUT doesn't touch the body, and
690
+ // enforcing it would cause spurious 412s after an agent body-write.
691
+ const baseBodyHash = req.headers['x-studiograph-base-body-hash'];
477
692
  const ifUnmodifiedSince = req.headers['if-unmodified-since'];
478
- if (ifUnmodifiedSince) {
693
+ if (ifUnmodifiedSince && !baseBodyHash) {
479
694
  const current = graph.get(type, id);
480
695
  const entityUpdatedAt = current?.data?.updated_at;
481
696
  if (entityUpdatedAt && new Date(entityUpdatedAt) > new Date(ifUnmodifiedSince)) {
@@ -483,6 +698,40 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
483
698
  }
484
699
  }
485
700
  const gitUser = reqGitUser(req);
701
+ // Workspace-wide uniqueness check for `user_handle` on person entities.
702
+ // Skip the entity being updated so we don't reject it for clashing
703
+ // with itself. Also gate WHO can change the bridge: only workspace
704
+ // owners may stamp/clear/rewrite user_handle. Other folder-writers
705
+ // see the field but can't edit it (defense against impersonation
706
+ // through the agent's Current User prompt).
707
+ if (type === 'person' && req.body.data) {
708
+ try {
709
+ validateUserHandleInPayload(workspaceManager, req.body.data, { repo, entityId: id });
710
+ if (Object.prototype.hasOwnProperty.call(req.body.data, 'user_handle')) {
711
+ const current = graph.get('person', id);
712
+ const currentHandle = (current?.data?.user_handle ?? '');
713
+ assertCanChangeUserHandle(reqUser(req), currentHandle, req.body.data.user_handle);
714
+ }
715
+ }
716
+ catch (err) {
717
+ if (err instanceof BridgeError) {
718
+ const status = err.code === 'OWNER_REQUIRED' ? 403 : 409;
719
+ return reply.status(status).send({ error: err.message, code: err.code, ...err.details });
720
+ }
721
+ throw err;
722
+ }
723
+ }
724
+ // Scope ref fields (today: task.assignee → person). Rejects a wikilink
725
+ // that resolves to a non-person; forward references are allowed.
726
+ try {
727
+ assertEntityRefs(workspaceManager, type, req.body.data, { userExists: (id) => !!authService.findUserById(Number(id)) });
728
+ }
729
+ catch (err) {
730
+ if (err instanceof EntityRefError || err instanceof UnknownUserRefError) {
731
+ return reply.status(400).send({ error: err.message, code: err.code, field: err.field });
732
+ }
733
+ throw err;
734
+ }
486
735
  const updated = await graph.update({
487
736
  entityType: type,
488
737
  entityId: id,
@@ -491,6 +740,12 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
491
740
  commitMessage: `Update ${type}/${id} from web UI`,
492
741
  user: gitUser,
493
742
  skipCommit: !!sessionManager, // defer commit if scheduler is active
743
+ source: 'autosave',
744
+ // Forward the body-hash precondition into graph.update so the
745
+ // check runs INSIDE the per-doc lock — see Phase 3 P1 fix.
746
+ // Only meaningful for body writes; ignored when content is
747
+ // undefined (graph.update gates on content !== undefined too).
748
+ baseRevision: req.body.content !== undefined ? baseBodyHash : undefined,
494
749
  });
495
750
  if (sessionManager && gitUser) {
496
751
  sessionManager.markDirty(repo, gitUser, type, id, updated.path);
@@ -503,6 +758,19 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
503
758
  return reply.send(updated);
504
759
  }
505
760
  catch (err) {
761
+ // Translate the locked-section baseRevision mismatch into the
762
+ // same 412 shape the route used to emit pre-lock. Preserves the
763
+ // client-side contract (code: STALE_BODY_HASH + currentBodyHash
764
+ // + entity).
765
+ if (err instanceof StaleBaseRevisionError) {
766
+ const current = graph.get(type, id);
767
+ return reply.status(412).send({
768
+ error: 'Precondition Failed: body was modified',
769
+ code: 'STALE_BODY_HASH',
770
+ currentBodyHash: err.actual,
771
+ entity: current,
772
+ });
773
+ }
506
774
  return reply.status(400).send({ error: err.message });
507
775
  }
508
776
  });
@@ -513,16 +781,10 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
513
781
  if (!type || !id || !newType) {
514
782
  return reply.status(400).send({ error: 'entityType, entityId, and newType are required' });
515
783
  }
516
- if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
517
- return reply.status(404).send({ error: `Collection "${repo}" not found` });
518
- }
519
- let graph;
520
- try {
521
- graph = workspaceManager.getGraph(repo);
522
- }
523
- catch (err) {
524
- return reply.status(404).send({ error: err.message });
525
- }
784
+ // Access enforced by graphForRead (WorkspaceAccessService).
785
+ const graph = graphForRead(req, reply, repo);
786
+ if (!graph)
787
+ return;
526
788
  try {
527
789
  // Flush any pending session saves before moving the file
528
790
  if (sessionManager) {
@@ -547,45 +809,121 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
547
809
  return reply.status(status).send({ error: err.message });
548
810
  }
549
811
  });
550
- // POST /api/repos/:repo/transfermove or copy entity to another collection
551
- fastify.post('/api/repos/:repo/transfer', async (req, reply) => {
552
- const sourceRepo = req.params.repo;
553
- const { entityType, entityId, targetRepo, mode } = req.body ?? {};
554
- if (!entityType || !entityId || !targetRepo || !mode) {
555
- return reply.status(400).send({ error: 'entityType, entityId, targetRepo, and mode are required' });
812
+ // POST /api/repos/:repo/renamerename an entity (changes its id/filename)
813
+ fastify.post('/api/repos/:repo/rename', async (req, reply) => {
814
+ const { repo } = req.params;
815
+ const { entityType: type, entityId: id, newId } = req.body ?? {};
816
+ if (!type || !id || !newId) {
817
+ return reply.status(400).send({ error: 'entityType, entityId, and newId are required' });
556
818
  }
557
- if (sourceRepo === targetRepo) {
558
- return reply.status(400).send({ error: 'Source and target collections must be different' });
819
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(newId)) {
820
+ return reply.status(400).send({ error: 'New ID must be kebab-case (lowercase letters, numbers, hyphens, underscores)' });
559
821
  }
560
- if (!hasRepoAccess(req, sourceRepo, workspaceManager, authService)) {
561
- return reply.status(404).send({ error: `Collection "${sourceRepo}" not found` });
822
+ if (id === newId) {
823
+ return reply.status(400).send({ error: 'New ID must differ from the current ID' });
562
824
  }
563
- if (!hasRepoAccess(req, targetRepo, workspaceManager, authService)) {
564
- return reply.status(404).send({ error: `Collection "${targetRepo}" not found` });
825
+ // Access enforced by graphForWrite (WorkspaceAccessService).
826
+ const graph = graphForWrite(req, reply, repo);
827
+ if (!graph)
828
+ return;
829
+ // Workspace-wide uniqueness — ids must be unique across all folders
830
+ const conflict = workspaceManager.findEntityById(newId);
831
+ if (conflict) {
832
+ return reply.status(409).send({ error: `An entry with id "${newId}" already exists in folder "${conflict.repoName}".` });
565
833
  }
566
- let sourceGraph, targetGraph;
567
834
  try {
568
- sourceGraph = workspaceManager.getGraph(sourceRepo);
569
- targetGraph = workspaceManager.getGraph(targetRepo);
835
+ // Flush pending session saves so we don't PUT to the old path after rename
836
+ if (sessionManager) {
837
+ await sessionManager.flush(repo);
838
+ }
839
+ const gitUser = reqGitUser(req);
840
+ const renamed = await graph.rename(type, id, newId, undefined, gitUser);
841
+ // Rewrite any [[wikilinks]] that still point at the old id, across
842
+ // every folder the caller can access. Per-repo — cross-folder links
843
+ // are rare but supported.
844
+ const refactorMsg = `Refactor wikilinks: ${id} \u2192 ${newId}`;
845
+ for (const other of getAccessibleRepos(req, workspaceManager, authService)) {
846
+ try {
847
+ // Direct WM access OK — iterating the access-filtered list.
848
+ const otherGraph = workspaceManager.getGraph(other.name);
849
+ await otherGraph.refactorWikilink(id, newId, refactorMsg);
850
+ }
851
+ catch { /* skip unloadable repo */ }
852
+ }
853
+ wsHub?.broadcast({
854
+ type: 'entity_change', action: 'deleted', repo, entityType: type, entityId: id,
855
+ actor: req.user?.displayName ?? 'API',
856
+ source: 'api', timestamp: new Date().toISOString(),
857
+ });
858
+ wsHub?.broadcast({
859
+ type: 'entity_change', action: 'created', repo, entityType: type, entityId: newId,
860
+ actor: req.user?.displayName ?? 'API',
861
+ source: 'api', timestamp: new Date().toISOString(),
862
+ });
863
+ return reply.send({ success: true, entity: renamed, newUrl: `/${repo}/${type}/${newId}` });
570
864
  }
571
865
  catch (err) {
572
- return reply.status(404).send({ error: err.message });
866
+ const status = err.message?.includes('already exists') ? 409 : 400;
867
+ return reply.status(status).send({ error: err.message });
868
+ }
869
+ });
870
+ // POST /api/repos/:repo/transfer — move or copy entity to another folder
871
+ fastify.post('/api/repos/:repo/transfer', async (req, reply) => {
872
+ const sourceRepo = req.params.repo;
873
+ const { entityType, entityId, targetRepo, mode, targetFolderPath } = req.body ?? {};
874
+ if (!entityType || !entityId || !targetRepo || !mode) {
875
+ return reply.status(400).send({ error: 'entityType, entityId, targetRepo, and mode are required' });
573
876
  }
877
+ if (sourceRepo === targetRepo) {
878
+ return reply.status(400).send({ error: 'Source and target folders must be different' });
879
+ }
880
+ // Workspace Assets store ownership gate (codex P1 / D2). Moving an `asset`
881
+ // OUT of a workspace_wide store reassigns its index repo, after which the
882
+ // uploader/owner delete rule no longer applies — so the move itself must
883
+ // honor that rule. assetDeleteDenial is a no-op for non-workspace_wide
884
+ // sources, so normal folder→folder moves are unaffected.
885
+ if (entityType === 'asset') {
886
+ const denial = assetDeleteDenial(workspaceManager.getAssetIndex?.(), workspaceManager.getRepoConfig(sourceRepo), reqUser(req), sourceRepo, entityType, entityId);
887
+ if (denial)
888
+ return reply.status(403).send({ error: denial });
889
+ }
890
+ // Access enforced by graphForWrite — both source (delete on move)
891
+ // and target (create) require write access. Resolved up-front so a
892
+ // target-side denial never surfaces only after source has been
893
+ // mutated. Audit (2026-05) flagged the previous opt-in pattern.
894
+ const sourceGraph = graphForWrite(req, reply, sourceRepo);
895
+ if (!sourceGraph)
896
+ return;
897
+ const targetGraph = graphForWrite(req, reply, targetRepo);
898
+ if (!targetGraph)
899
+ return;
574
900
  try {
575
- // Flush pending saves
576
- if (sessionManager)
901
+ // Phase 0 — prevalidate. Audit (2026-05) required this: previously
902
+ // the route created in target, broadcast `created`, and only then
903
+ // tried to delete from source. A delete failure left a duplicate
904
+ // entry the client had already been told about.
905
+ if (sessionManager) {
577
906
  await sessionManager.flush(sourceRepo);
578
- // Read source entity
907
+ if (targetRepo !== sourceRepo)
908
+ await sessionManager.flush(targetRepo);
909
+ }
910
+ // Source must exist; target must not collide. Both checks run BEFORE
911
+ // any mutation so we never enter phase 2 unless phase 1 will succeed.
579
912
  const source = sourceGraph.get(entityType, entityId);
580
- // Check for conflicts in target
913
+ const sourcePath = source.path;
914
+ const sourceRawMarkdown = readFileSync(sourcePath, 'utf-8');
581
915
  try {
582
916
  targetGraph.get(entityType, entityId);
583
917
  return reply.status(409).send({ error: `"${entityId}" already exists in ${targetRepo}` });
584
918
  }
585
919
  catch { /* not found = good */ }
586
- // Create in target
587
920
  const gitUser = reqGitUser(req);
588
921
  const { entity_type, entity_id, ...dataFields } = source.data;
922
+ // Phase 1 — create in target. No broadcast yet: a half-completed
923
+ // move (created in target, source still present) must NOT be
924
+ // observable to clients, or they will display two copies until the
925
+ // next refresh. `targetFolderPath` lets the caller drop the entry
926
+ // straight into a subfolder of the target repo.
589
927
  const created = await targetGraph.create({
590
928
  entityType: entityType,
591
929
  entityId,
@@ -593,21 +931,123 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
593
931
  content: source.document?.content ?? '',
594
932
  commitMessage: `${mode === 'move' ? 'Move' : 'Copy'} ${entityType}: ${entityId} from ${sourceRepo}`,
595
933
  user: gitUser,
934
+ folderPath: targetFolderPath,
596
935
  });
936
+ // Phase 2 — for `move` only: delete from source. If this fails, run
937
+ // a compensating delete on target so we don't leave a duplicate.
938
+ // The compensating delete is best-effort; if it also fails, surface
939
+ // a 500 with both error messages so the residue is visible (the
940
+ // previous behavior silently succeeded and left orphans).
941
+ if (mode === 'move') {
942
+ try {
943
+ // keepAssets: the moved entity's immutable-keyed blobs are shared
944
+ // (same assetId in both repos' bodies during the window); the
945
+ // source delete must not destroy them.
946
+ await sourceGraph.delete(entityType, entityId, undefined, gitUser, { keepAssets: true });
947
+ // Re-point ownership to the target repo ONLY after the source
948
+ // delete succeeds. Doing it after means a failed source delete
949
+ // (handled below) leaves the index pointing at the source repo,
950
+ // which is correct for the entity we're about to restore there.
951
+ // Blobs/URLs are unchanged (immutable keys); only the index's
952
+ // repo column moves, which is what serve-time authz reads.
953
+ workspaceManager.getAssetIndex?.()?.reassignOwner(sourceRepo, entityType, entityId, { repo: targetRepo });
954
+ }
955
+ catch (deleteErr) {
956
+ const deleteMsg = deleteErr instanceof Error ? deleteErr.message : String(deleteErr);
957
+ let recoveryMsg = null;
958
+ try {
959
+ let restoreSource = !existsSync(sourcePath);
960
+ if (!restoreSource) {
961
+ try {
962
+ restoreSource = readFileSync(sourcePath, 'utf-8') !== sourceRawMarkdown;
963
+ }
964
+ catch {
965
+ restoreSource = true;
966
+ }
967
+ }
968
+ if (restoreSource) {
969
+ mkdirSync(dirname(sourcePath), { recursive: true });
970
+ writeFileSync(sourcePath, sourceRawMarkdown, 'utf-8');
971
+ const recoveryPaths = [sourcePath];
972
+ const folderMarkerPath = join(dirname(sourcePath), '.gitkeep');
973
+ if (existsSync(folderMarkerPath)) {
974
+ rmSync(folderMarkerPath, { force: true });
975
+ recoveryPaths.push(folderMarkerPath);
976
+ }
977
+ const sourceAny = sourceGraph;
978
+ const recoveryGit = sourceAny.gitService;
979
+ const recoveryUser = gitUser ?? sourceAny.gitUser;
980
+ if (!recoveryGit || !recoveryUser) {
981
+ throw new Error('source graph git service or git user unavailable');
982
+ }
983
+ recoveryGit.commitFiles(recoveryPaths, `Recover ${entityType}: ${entityId} after failed move to ${targetRepo}`, recoveryUser);
984
+ }
985
+ }
986
+ catch (recoveryErr) {
987
+ recoveryMsg = recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr);
988
+ log.error('[critical] cross-repo move source recovery failed', {
989
+ sourceRepo,
990
+ targetRepo,
991
+ entityType,
992
+ entityId,
993
+ sourcePath,
994
+ deleteError: deleteMsg,
995
+ recoveryError: recoveryMsg,
996
+ capturedContent: sourceRawMarkdown,
997
+ });
998
+ }
999
+ try {
1000
+ // keepAssets: ownership was NOT reassigned (that happens only
1001
+ // after a successful source delete), so the blobs still belong
1002
+ // to the source entity we just restored. Destroying them here
1003
+ // would leave the recovered source pointing at dead URLs.
1004
+ await targetGraph.delete(entityType, entityId, undefined, gitUser, { keepAssets: true });
1005
+ if (recoveryMsg) {
1006
+ return reply.status(500).send({
1007
+ error: `Move failed: source delete of ${entityType}/${entityId} failed (${deleteMsg}). Target rolled back, but source recovery commit failed (${recoveryMsg}); manual source repo recovery is required.`,
1008
+ code: 'MOVE_SOURCE_RECOVERY_FAILED',
1009
+ sourceRepo,
1010
+ targetRepo,
1011
+ });
1012
+ }
1013
+ return reply.status(500).send({
1014
+ error: `Move failed: source delete of ${entityType}/${entityId} failed (${deleteMsg}). Target rolled back; folders are back to their pre-move state.`,
1015
+ });
1016
+ }
1017
+ catch (compErr) {
1018
+ const compMsg = compErr instanceof Error ? compErr.message : String(compErr);
1019
+ if (recoveryMsg) {
1020
+ return reply.status(500).send({
1021
+ error: `Move failed and could not be rolled back. Source delete failed (${deleteMsg}); source recovery commit failed (${recoveryMsg}); compensating target delete also failed (${compMsg}). Captured source content was logged for manual recovery.`,
1022
+ code: 'MOVE_RECOVERY_AND_ROLLBACK_FAILED',
1023
+ sourceRepo,
1024
+ targetRepo,
1025
+ });
1026
+ }
1027
+ return reply.status(500).send({
1028
+ error: `Move failed and could not be rolled back. Source delete failed (${deleteMsg}); compensating target delete also failed (${compMsg}). Entry now exists in BOTH ${sourceRepo} and ${targetRepo} — manual cleanup required.`,
1029
+ code: 'MOVE_DUPLICATE',
1030
+ sourceRepo,
1031
+ targetRepo,
1032
+ });
1033
+ }
1034
+ }
1035
+ }
1036
+ // Phase 3 — both phases succeeded; only now broadcast. Order:
1037
+ // source-delete first so subscribers don't briefly observe the
1038
+ // entity in both folders during the network race.
597
1039
  const actor = req.user?.displayName ?? 'API';
598
1040
  const ts = new Date().toISOString();
599
- wsHub?.broadcast({
600
- type: 'entity_change', action: 'created', repo: targetRepo, entityType, entityId,
601
- actor, source: 'api', timestamp: ts,
602
- });
603
- // Delete from source if moving
604
1041
  if (mode === 'move') {
605
- await sourceGraph.delete(entityType, entityId, undefined, gitUser);
606
1042
  wsHub?.broadcast({
607
1043
  type: 'entity_change', action: 'deleted', repo: sourceRepo, entityType, entityId,
608
1044
  actor, source: 'api', timestamp: ts,
609
1045
  });
610
1046
  }
1047
+ wsHub?.broadcast({
1048
+ type: 'entity_change', action: 'created', repo: targetRepo, entityType, entityId,
1049
+ actor, source: 'api', timestamp: ts,
1050
+ });
611
1051
  return reply.send({
612
1052
  success: true,
613
1053
  entity: created,
@@ -618,27 +1058,208 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
618
1058
  return reply.status(400).send({ error: err.message });
619
1059
  }
620
1060
  });
621
- // DELETE /api/repos/:repo/entities/:type/:iddelete an entity
622
- fastify.delete('/api/repos/:repo/entities/:type/:id', async (req, reply) => {
623
- const { repo, type, id } = req.params;
624
- if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
625
- return reply.status(404).send({ error: `Collection "${repo}" not found` });
1061
+ // POST /api/repos/:repo/transfer-entities — bulk move a list of entries
1062
+ // into a single target folder (in this or another repo). The web UI's
1063
+ // multi-select Move flow used to loop and POST per entry; this collapses
1064
+ // to one round-trip per source repo and one commit per repo touched.
1065
+ fastify.post('/api/repos/:repo/transfer-entities', async (req, reply) => {
1066
+ const sourceRepo = req.params.repo;
1067
+ const { entries, targetRepo, targetFolder } = req.body ?? {};
1068
+ if (!Array.isArray(entries) || !targetRepo || targetFolder === undefined) {
1069
+ return reply.status(400).send({ error: 'entries, targetRepo, and targetFolder are required' });
1070
+ }
1071
+ // Workspace Assets store ownership gate (codex P1 / D2): same rule as the
1072
+ // single /transfer route, applied per asset entry. No-op when the source
1073
+ // isn't workspace_wide.
1074
+ for (const e of entries) {
1075
+ if (e.entityType !== 'asset')
1076
+ continue;
1077
+ const denial = assetDeleteDenial(workspaceManager.getAssetIndex?.(), workspaceManager.getRepoConfig(sourceRepo), reqUser(req), sourceRepo, e.entityType, e.entityId);
1078
+ if (denial)
1079
+ return reply.status(403).send({ error: denial });
1080
+ }
1081
+ // Access enforced by graphForWrite — both source (mutate) and
1082
+ // target (write) require write access. Resolved up-front so a
1083
+ // target-side denial never surfaces only after source mutation.
1084
+ const sourceGraph = graphForWrite(req, reply, sourceRepo);
1085
+ if (!sourceGraph)
1086
+ return;
1087
+ const targetGraph = graphForWrite(req, reply, targetRepo);
1088
+ if (!targetGraph)
1089
+ return;
1090
+ try {
1091
+ if (sessionManager) {
1092
+ await sessionManager.flush(sourceRepo);
1093
+ if (targetRepo !== sourceRepo)
1094
+ await sessionManager.flush(targetRepo);
1095
+ }
1096
+ const result = await sourceGraph.moveEntitiesToRepo(entries.map(e => ({ entityType: e.entityType, entityId: e.entityId })), targetGraph, targetFolder, { user: reqGitUser(req) });
1097
+ const actor = req.user?.displayName ?? 'API';
1098
+ const ts = new Date().toISOString();
1099
+ wsHub?.broadcast({ type: 'repo_sync', repo: sourceRepo, source: 'api', actor, timestamp: ts });
1100
+ if (targetRepo !== sourceRepo) {
1101
+ wsHub?.broadcast({ type: 'repo_sync', repo: targetRepo, source: 'api', actor, timestamp: ts });
1102
+ }
1103
+ return reply.send({ success: true, ...result });
1104
+ }
1105
+ catch (err) {
1106
+ const status = err?.name === 'InvalidFolderPathError' ? 400
1107
+ : err.message?.includes('not found') ? 404
1108
+ : err.message?.includes('already occupied') ? 409
1109
+ : 400;
1110
+ return reply.status(status).send({ error: err.message });
626
1111
  }
627
- let graph;
1112
+ });
1113
+ // POST /api/repos/:repo/transfer-folder — move an entire folder (with all
1114
+ // descendants) into another repo in one shot. Replaces the per-entry loop
1115
+ // the web UI used to run, which produced 2 git commits per entry; this
1116
+ // route performs a single FS-level rename and one commit per repo.
1117
+ fastify.post('/api/repos/:repo/transfer-folder', async (req, reply) => {
1118
+ const sourceRepo = req.params.repo;
1119
+ const { sourceFolder, targetRepo, targetFolder } = req.body ?? {};
1120
+ if (!sourceFolder || !targetRepo || targetFolder === undefined) {
1121
+ return reply.status(400).send({ error: 'sourceFolder, targetRepo, and targetFolder are required' });
1122
+ }
1123
+ if (sourceRepo === targetRepo && sourceFolder === targetFolder) {
1124
+ return reply.status(400).send({ error: 'Source and target are the same' });
1125
+ }
1126
+ // Workspace Assets store gate (codex P1 / D2). Moving a folder OUT of a
1127
+ // workspace_wide store relocates+reassigns every asset under it, escaping
1128
+ // the per-asset uploader/owner delete rule. A folder-level bulk move in
1129
+ // the shared store is an admin action — restrict it to a workspace owner.
1130
+ // (No-op for normal folders; the manager never folder-moves the store.)
1131
+ if (workspaceManager.getRepoConfig(sourceRepo)?.workspace_wide && !isWorkspaceOwner(reqUser(req)?.role ?? '')) {
1132
+ return reply.status(403).send({ error: 'Only a workspace owner can move folders out of the shared Assets store.' });
1133
+ }
1134
+ // Access enforced by graphForWrite — both source (mutate) and
1135
+ // target (write) require write access. Resolved up-front so a
1136
+ // target-side denial never surfaces only after source mutation.
1137
+ const sourceGraph = graphForWrite(req, reply, sourceRepo);
1138
+ if (!sourceGraph)
1139
+ return;
1140
+ const targetGraph = graphForWrite(req, reply, targetRepo);
1141
+ if (!targetGraph)
1142
+ return;
628
1143
  try {
629
- graph = workspaceManager.getGraph(repo);
1144
+ // Flush any deferred saves on either side so we don't strand a
1145
+ // pending edit in the source folder mid-move.
1146
+ if (sessionManager) {
1147
+ await sessionManager.flush(sourceRepo);
1148
+ if (targetRepo !== sourceRepo)
1149
+ await sessionManager.flush(targetRepo);
1150
+ }
1151
+ const stats = await sourceGraph.moveFolderToRepo(sourceFolder, targetGraph, targetFolder, { user: reqGitUser(req) });
1152
+ const actor = req.user?.displayName ?? 'API';
1153
+ const ts = new Date().toISOString();
1154
+ // One refresh-trigger per repo. The receiving and sending lists
1155
+ // both need to update; using `repo_sync` keeps the realtime
1156
+ // handler simple — it already calls workspace.refreshEntities
1157
+ // on this event type. Avoids emitting 2× entries-moved entity_change
1158
+ // broadcasts (one created, one deleted) per file.
1159
+ wsHub?.broadcast({ type: 'repo_sync', repo: sourceRepo, source: 'api', actor, timestamp: ts });
1160
+ if (targetRepo !== sourceRepo) {
1161
+ wsHub?.broadcast({ type: 'repo_sync', repo: targetRepo, source: 'api', actor, timestamp: ts });
1162
+ }
1163
+ return reply.send({ success: true, ...stats });
630
1164
  }
631
1165
  catch (err) {
632
- return reply.status(404).send({ error: err.message });
1166
+ const status = err?.name === 'InvalidFolderPathError' ? 400
1167
+ : err.message?.includes('not found') ? 404
1168
+ : err.message?.includes('not empty') ? 409
1169
+ : 400;
1170
+ return reply.status(status).send({ error: err.message });
633
1171
  }
1172
+ });
1173
+ // Workspace Assets store delete gate (codex P1 / D2) — uploader/owner only.
1174
+ // Pure decision lives in services/asset-delete-authz.ts (unit-tested); this
1175
+ // closure binds it to the route's workspaceManager.
1176
+ const denyAssetDelete = (user, repo, entityType, entityId) => assetDeleteDenial(workspaceManager.getAssetIndex?.(), workspaceManager.getRepoConfig(repo), user, repo, entityType, entityId);
1177
+ // DELETE /api/repos/:repo/entities/:type/:id — delete an entity
1178
+ fastify.delete('/api/repos/:repo/entities/:type/:id', async (req, reply) => {
1179
+ const { repo, type, id } = req.params;
1180
+ const denial = denyAssetDelete(reqUser(req), repo, type, id);
1181
+ if (denial)
1182
+ return reply.status(403).send({ error: denial });
1183
+ // Access enforced by graphForRead (WorkspaceAccessService).
1184
+ const graph = graphForRead(req, reply, repo);
1185
+ if (!graph)
1186
+ return;
634
1187
  try {
1188
+ // Soft bridge-awareness: deleting a person that's linked to an auth
1189
+ // account orphans that user (their "My Tasks"/@mentions stop
1190
+ // resolving). We don't BLOCK the delete — the link is a soft
1191
+ // relationship — but we surface a warning so the caller knows, and the
1192
+ // orphan is then caught by the Profile panel's unlinked affordance.
1193
+ let bridgeWarning;
1194
+ if (type === 'person') {
1195
+ try {
1196
+ const handle = graph.get('person', id)?.data?.user_handle;
1197
+ if (typeof handle === 'string' && handle.length > 0) {
1198
+ bridgeWarning = `This record was linked to the "${handle}" account. Deleting it unlinks that user — their "My Tasks", @mentions, and task assignments stop resolving until they link a new record (Settings → Profile → "Create & link").`;
1199
+ log.warn(`[bridge] deleting linked person ${repo}/${id} (was user ${handle})`);
1200
+ }
1201
+ }
1202
+ catch { /* not readable / already gone — let graph.delete surface it */ }
1203
+ }
635
1204
  await graph.delete(type, id, `Delete ${type}/${id} from web UI`, reqGitUser(req));
1205
+ // Deleting an asset entity removes its blob(s); drop the cached bucket
1206
+ // walk so a legacy twin doesn't linger in the manager (codex P2).
1207
+ if (type === 'asset')
1208
+ clearLegacyBucketCache();
636
1209
  wsHub?.broadcast({
637
1210
  type: 'entity_change', action: 'deleted', repo, entityType: type, entityId: id,
638
1211
  actor: req.user?.displayName ?? 'API',
639
1212
  source: 'api', timestamp: new Date().toISOString(),
640
1213
  });
641
- return reply.send({ ok: true });
1214
+ return reply.send({ ok: true, ...(bridgeWarning ? { warning: bridgeWarning } : {}) });
1215
+ }
1216
+ catch (err) {
1217
+ return reply.status(400).send({ error: err.message });
1218
+ }
1219
+ });
1220
+ // POST /api/repos/:repo/entities/bulk-delete — delete many entities in
1221
+ // one git transaction + one WS broadcast. Replaces N parallel
1222
+ // DELETE /entities/:type/:id calls, which on a 500-entry bulk delete
1223
+ // produced 500 git commits + 500 entity_change broadcasts and froze
1224
+ // the UI for minutes.
1225
+ fastify.post('/api/repos/:repo/entities/bulk-delete', async (req, reply) => {
1226
+ const { repo } = req.params;
1227
+ // Access enforced by graphForWrite (WorkspaceAccessService).
1228
+ const graph = graphForWrite(req, reply, repo);
1229
+ if (!graph)
1230
+ return;
1231
+ const body = req.body;
1232
+ if (!body?.entities || !Array.isArray(body.entities)) {
1233
+ return reply.status(400).send({ error: 'Expected { entities: [{ entityType, entityId }] }' });
1234
+ }
1235
+ // Apply the workspace-Assets delete gate per entity (codex P1 / D2):
1236
+ // uploader/owner-only. Denied entries are reported, not silently dropped.
1237
+ const deleteUser = reqUser(req);
1238
+ const denied = [];
1239
+ const allowed = body.entities.filter(e => {
1240
+ const d = denyAssetDelete(deleteUser, repo, e.entityType, e.entityId);
1241
+ if (d) {
1242
+ denied.push({ entityType: e.entityType, entityId: e.entityId, error: d });
1243
+ return false;
1244
+ }
1245
+ return true;
1246
+ });
1247
+ try {
1248
+ const result = await graph.bulkDelete(allowed.map(e => ({ entityType: e.entityType, entityId: e.entityId })), {
1249
+ user: reqGitUser(req),
1250
+ commitMessage: `Bulk delete ${allowed.length} ${allowed.length === 1 ? 'entry' : 'entries'} from web UI`,
1251
+ });
1252
+ if (result.deleted.length > 0) {
1253
+ wsHub?.broadcast({
1254
+ type: 'repo_sync', repo,
1255
+ actor: req.user?.displayName ?? 'API',
1256
+ source: 'api', timestamp: new Date().toISOString(),
1257
+ });
1258
+ }
1259
+ return reply.send({
1260
+ deleted: result.deleted.length,
1261
+ errors: [...result.errors, ...denied],
1262
+ });
642
1263
  }
643
1264
  catch (err) {
644
1265
  return reply.status(400).send({ error: err.message });
@@ -647,78 +1268,418 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
647
1268
  // POST /api/repos/:repo/entities — create a new entity
648
1269
  fastify.post('/api/repos/:repo/entities', async (req, reply) => {
649
1270
  const { repo } = req.params;
650
- if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
651
- return reply.status(404).send({ error: `Collection "${repo}" not found` });
652
- }
653
- const { entityType, entityId, data = {}, content = '' } = req.body;
1271
+ // Access enforced by graphForWrite (WorkspaceAccessService).
1272
+ const graph = graphForWrite(req, reply, repo);
1273
+ if (!graph)
1274
+ return;
1275
+ const { entityType, entityId, data = {}, content = '', folder_path } = req.body;
654
1276
  if (!entityType || !entityId) {
655
1277
  return reply.status(400).send({ error: 'entityType and entityId are required' });
656
1278
  }
657
- let graph;
1279
+ // Refuse to re-mint a retired type (`asset`, Phase F). Otherwise an
1280
+ // unknown entityType falls back to EntityBaseSchema and writes a
1281
+ // base-only stub of exactly the type asset-as-embed removed.
1282
+ if (RESERVED_ENTITY_TYPES.has(entityType)) {
1283
+ return reply.status(400).send({ error: `Entity type '${entityType}' was retired and can no longer be created.` });
1284
+ }
1285
+ const eagerError = checkEagerSkillPermission(req, entityType, data, repo, workspaceManager, authService);
1286
+ if (eagerError)
1287
+ return reply.status(403).send({ error: eagerError });
1288
+ // Person entities can carry a `user_handle` pointer to an auth account;
1289
+ // enforce workspace-wide uniqueness AND owner-only authz before
1290
+ // delegating to the per-repo create path. Stamping a handle on a new
1291
+ // entity is the same trust action as stamping it on an existing one,
1292
+ // so non-owners can't create a pre-linked person entity either.
1293
+ if (entityType === 'person') {
1294
+ try {
1295
+ validateUserHandleInPayload(workspaceManager, data);
1296
+ if (Object.prototype.hasOwnProperty.call(data, 'user_handle')) {
1297
+ assertCanChangeUserHandle(reqUser(req), '', data.user_handle);
1298
+ }
1299
+ }
1300
+ catch (err) {
1301
+ if (err instanceof BridgeError) {
1302
+ const status = err.code === 'OWNER_REQUIRED' ? 403 : 409;
1303
+ return reply.status(status).send({ error: err.message, code: err.code, ...err.details });
1304
+ }
1305
+ throw err;
1306
+ }
1307
+ }
1308
+ // Scope ref fields (today: task.assignee → person). Rejects a wikilink
1309
+ // that resolves to a non-person; forward references are allowed.
658
1310
  try {
659
- graph = workspaceManager.getGraph(repo);
1311
+ assertEntityRefs(workspaceManager, entityType, data, { userExists: (id) => !!authService.findUserById(Number(id)) });
660
1312
  }
661
1313
  catch (err) {
662
- return reply.status(404).send({ error: err.message });
1314
+ if (err instanceof EntityRefError || err instanceof UnknownUserRefError) {
1315
+ return reply.status(400).send({ error: err.message, code: err.code, field: err.field });
1316
+ }
1317
+ throw err;
663
1318
  }
664
1319
  try {
1320
+ // Detect implicit subfolder creation: if the entry lands in a folder
1321
+ // that didn't exist yet, the entity_change below only refreshes the
1322
+ // entry list — clients need a _folder event to refetch the folder tree
1323
+ // (sidebar + list/gallery folder rows). Mirrors the MCP create_entity
1324
+ // path; normalize the same way graph.create does so the existence
1325
+ // check matches the real on-disk path.
1326
+ const normFolder = folder_path ? validateFolderPath(folder_path) : '';
1327
+ const folderExisted = !normFolder || existsSync(join(graph.getRepoPath(), normFolder));
1328
+ const now = new Date().toISOString();
1329
+ const actorId = reqGitUser(req)?.id ?? 'web';
1330
+ const stampedData = {
1331
+ ...data,
1332
+ entity_type: entityType,
1333
+ entity_id: entityId,
1334
+ created_at: data.created_at ?? now,
1335
+ updated_at: data.updated_at ?? now,
1336
+ created_by: data.created_by ?? actorId,
1337
+ updated_by: data.updated_by ?? actorId,
1338
+ };
665
1339
  const entity = await graph.create({
666
1340
  entityType: entityType,
667
1341
  entityId,
668
- data: { entity_type: entityType, entity_id: entityId, ...data },
1342
+ data: stampedData,
669
1343
  content,
1344
+ folderPath: folder_path,
670
1345
  commitMessage: `Create ${entityType}/${entityId} from web UI`,
671
1346
  user: reqGitUser(req),
672
1347
  });
1348
+ const actor = req.user?.displayName ?? 'API';
1349
+ const ts = new Date().toISOString();
673
1350
  wsHub?.broadcast({
674
1351
  type: 'entity_change', action: 'created', repo, entityType, entityId,
1352
+ actor, source: 'api', timestamp: ts,
1353
+ });
1354
+ if (!folderExisted) {
1355
+ wsHub?.broadcast({
1356
+ type: 'entity_change', action: 'created', repo, entityType: '_folder', entityId: normFolder,
1357
+ actor, source: 'api', timestamp: ts,
1358
+ });
1359
+ }
1360
+ return reply.status(201).send(entity);
1361
+ }
1362
+ catch (err) {
1363
+ return reply.status(400).send({ error: err.message });
1364
+ }
1365
+ });
1366
+ // POST /api/repos/:repo/move — move entry to a different folder within the
1367
+ // same repo. Distinct from /transfer (cross-repo) and /rename (id change).
1368
+ // The entry's type and id are unchanged; only its folder_path moves.
1369
+ fastify.post('/api/repos/:repo/move', async (req, reply) => {
1370
+ const { repo } = req.params;
1371
+ const { entityType, entityId, folder_path } = req.body ?? {};
1372
+ if (!entityType || !entityId || folder_path === undefined) {
1373
+ return reply.status(400).send({ error: 'entityType, entityId, and folder_path are required' });
1374
+ }
1375
+ // Access enforced by graphForWrite (WorkspaceAccessService).
1376
+ const graph = graphForWrite(req, reply, repo);
1377
+ if (!graph)
1378
+ return;
1379
+ try {
1380
+ if (sessionManager)
1381
+ await sessionManager.flush(repo);
1382
+ // If the destination folder doesn't exist yet, the move creates it —
1383
+ // emit a _folder event (alongside the entry update) so clients refetch
1384
+ // the folder tree, not just the entry list. Mirrors MCP move_entity.
1385
+ const normTarget = folder_path ? validateFolderPath(folder_path) : '';
1386
+ const targetExisted = !normTarget || existsSync(join(graph.getRepoPath(), normTarget));
1387
+ const moved = await graph.move(entityType, entityId, folder_path, undefined, reqGitUser(req));
1388
+ const actor = req.user?.displayName ?? 'API';
1389
+ const ts = new Date().toISOString();
1390
+ wsHub?.broadcast({
1391
+ type: 'entity_change', action: 'updated', repo, entityType, entityId,
1392
+ actor, source: 'api', timestamp: ts,
1393
+ });
1394
+ if (!targetExisted) {
1395
+ wsHub?.broadcast({
1396
+ type: 'entity_change', action: 'created', repo, entityType: '_folder', entityId: normTarget,
1397
+ actor, source: 'api', timestamp: ts,
1398
+ });
1399
+ }
1400
+ return reply.send({ success: true, entity: moved });
1401
+ }
1402
+ catch (err) {
1403
+ const status = err?.name === 'InvalidFolderPathError' ? 400
1404
+ : err.message?.includes('already occupied') ? 409
1405
+ : err.message?.includes('not found') ? 404
1406
+ : 400;
1407
+ return reply.status(status).send({ error: err.message });
1408
+ }
1409
+ });
1410
+ // POST /api/repos/:repo/folders — create a folder (with .gitkeep). Auto-creates parents.
1411
+ // Optional display_name is written to the folder's .studiograph-folder.json
1412
+ // sidecar when set; otherwise the client falls back to toDisplayName(slug).
1413
+ fastify.post('/api/repos/:repo/folders', async (req, reply) => {
1414
+ const { repo } = req.params;
1415
+ const { path, display_name } = req.body ?? {};
1416
+ if (!path || typeof path !== 'string') {
1417
+ return reply.status(400).send({ error: 'path is required' });
1418
+ }
1419
+ // Access enforced by graphForWrite (WorkspaceAccessService).
1420
+ const graph = graphForWrite(req, reply, repo);
1421
+ if (!graph)
1422
+ return;
1423
+ try {
1424
+ await graph.createFolder(path, undefined, reqGitUser(req), {
1425
+ displayName: display_name,
1426
+ });
1427
+ // Emit the _folder sentinel so OTHER connected clients refetch the
1428
+ // folder tree (sidebar + list/gallery folder rows). The local creator
1429
+ // already updated optimistically via workspace.addFolder.
1430
+ wsHub?.broadcast({
1431
+ type: 'entity_change', action: 'created', repo, entityType: '_folder', entityId: validateFolderPath(path),
675
1432
  actor: req.user?.displayName ?? 'API',
676
1433
  source: 'api', timestamp: new Date().toISOString(),
677
1434
  });
678
- return reply.status(201).send(entity);
1435
+ return reply.status(201).send({ success: true, path });
679
1436
  }
680
1437
  catch (err) {
681
1438
  return reply.status(400).send({ error: err.message });
682
1439
  }
683
1440
  });
1441
+ // PATCH /api/repos/:repo/folders — rename / move a folder within the repo,
1442
+ // optionally updating its display_name in the same commit. Three shapes:
1443
+ // { from, to } — slug-only rename / move
1444
+ // { from, to, display_name } — slug change + display update
1445
+ // { from, to: from, display_name } — display-only update (no FS move)
1446
+ // `display_name: ""` (or null) clears the override and reverts to the slug
1447
+ // fallback.
1448
+ fastify.patch('/api/repos/:repo/folders', async (req, reply) => {
1449
+ const { repo } = req.params;
1450
+ const { from, to, display_name } = req.body ?? {};
1451
+ if (!from || !to) {
1452
+ return reply.status(400).send({ error: 'from and to are required' });
1453
+ }
1454
+ // Access enforced by graphForWrite (WorkspaceAccessService).
1455
+ const graph = graphForWrite(req, reply, repo);
1456
+ if (!graph)
1457
+ return;
1458
+ try {
1459
+ if (sessionManager)
1460
+ await sessionManager.flush(repo);
1461
+ await graph.renameFolder(from, to, undefined, reqGitUser(req), {
1462
+ displayName: display_name === undefined ? undefined : display_name,
1463
+ });
1464
+ // Rename/move relocates entries on disk, so the _folder sentinel's
1465
+ // refreshRepoAfterFolderMutation (tree + entity summaries) is exactly
1466
+ // what other clients need. Carries the new path as the entityId.
1467
+ wsHub?.broadcast({
1468
+ type: 'entity_change', action: 'updated', repo, entityType: '_folder', entityId: validateFolderPath(to),
1469
+ actor: req.user?.displayName ?? 'API',
1470
+ source: 'api', timestamp: new Date().toISOString(),
1471
+ });
1472
+ return reply.send({ success: true, from, to });
1473
+ }
1474
+ catch (err) {
1475
+ const status = err?.name === 'InvalidFolderPathError' ? 400
1476
+ : err.message?.includes('not found') ? 404
1477
+ : err.message?.includes('already exists') ? 409
1478
+ : 400;
1479
+ return reply.status(status).send({ error: err.message });
1480
+ }
1481
+ });
1482
+ // DELETE /api/repos/:repo/folders?path=X&recursive=true — delete a folder.
1483
+ // Without recursive=true, non-empty folders return 409 with blast-radius
1484
+ // counts so the UI can confirm before retrying.
1485
+ fastify.delete('/api/repos/:repo/folders', async (req, reply) => {
1486
+ const { repo } = req.params;
1487
+ const { path, recursive } = req.query;
1488
+ if (!path) {
1489
+ return reply.status(400).send({ error: 'path query parameter is required' });
1490
+ }
1491
+ // Access enforced by graphForWrite (WorkspaceAccessService).
1492
+ const graph = graphForWrite(req, reply, repo);
1493
+ if (!graph)
1494
+ return;
1495
+ // Workspace Assets store gate (codex P1 / D2). A recursive delete in a
1496
+ // workspace_wide store destroys every asset under the folder, bypassing
1497
+ // the per-asset uploader/owner rule — restrict it to a workspace owner.
1498
+ // (Non-recursive delete is blocked on non-empty folders by FOLDER_NOT_EMPTY,
1499
+ // so it can't take assets with it.)
1500
+ if (recursive === 'true' && workspaceManager.getRepoConfig(repo)?.workspace_wide && !isWorkspaceOwner(reqUser(req)?.role ?? '')) {
1501
+ return reply.status(403).send({ error: 'Only a workspace owner can recursively delete folders in the shared Assets store.' });
1502
+ }
1503
+ try {
1504
+ if (sessionManager)
1505
+ await sessionManager.flush(repo);
1506
+ const result = await graph.deleteFolder(path, {
1507
+ recursive: recursive === 'true',
1508
+ user: reqGitUser(req),
1509
+ });
1510
+ // Emit the _folder sentinel so other clients drop the folder (and any
1511
+ // entries it held, via the entity-summary refresh) from their view.
1512
+ wsHub?.broadcast({
1513
+ type: 'entity_change', action: 'deleted', repo, entityType: '_folder', entityId: validateFolderPath(path),
1514
+ actor: req.user?.displayName ?? 'API',
1515
+ source: 'api', timestamp: new Date().toISOString(),
1516
+ });
1517
+ return reply.send({ success: true, ...result });
1518
+ }
1519
+ catch (err) {
1520
+ if (err?.code === 'FOLDER_NOT_EMPTY') {
1521
+ return reply.status(409).send({
1522
+ error: err.message,
1523
+ code: 'FOLDER_NOT_EMPTY',
1524
+ blast_radius: err.blastRadius,
1525
+ });
1526
+ }
1527
+ const status = err?.name === 'InvalidFolderPathError' ? 400
1528
+ : err.message?.includes('not found') ? 404
1529
+ : 400;
1530
+ return reply.status(status).send({ error: err.message });
1531
+ }
1532
+ });
684
1533
  // POST /api/repos/:repo/commit — flush deferred commits (navigate-away signal)
685
1534
  fastify.post('/api/repos/:repo/commit', async (req, reply) => {
686
1535
  if (!sessionManager) {
687
1536
  return reply.send({ committed: false });
688
1537
  }
689
1538
  const { repo } = req.params;
690
- if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
691
- return reply.status(404).send({ error: `Collection "${repo}" not found` });
1539
+ // Access enforced via accessService.canRead \u2014 flush operates on the
1540
+ // SessionManager (no graph instance needed), so a boolean check is
1541
+ // sufficient. Pending commits were already authored by an authenticated
1542
+ // writer; this endpoint just flushes them on navigate-away.
1543
+ if (!accessService.canRead(reqUser(req), repo)) {
1544
+ return reply.status(404).send({ error: `Folder \u201C${repo}\u201D not found` });
692
1545
  }
693
1546
  const committed = await sessionManager.flush(repo);
694
1547
  return reply.send({ committed });
695
1548
  });
696
- // POST /api/repos/:repo/import — import files via agent processing
1549
+ // POST /api/repos/:repo/import — import files into a folder.
1550
+ //
1551
+ // Routing by extension:
1552
+ // .md / .txt / .csv → direct write via importFile() — no LLM
1553
+ // round-trip, preserves the source faithfully. Markdown frontmatter
1554
+ // (entity_type, entity_id) is honored when present; otherwise
1555
+ // sensible defaults.
1556
+ // .pdf + opaque binaries → asset wrapper via uploadAsset() —
1557
+ // creates an `asset` entity, stores the binary in R2 or the local
1558
+ // volume, writes a PDF text sidecar for vector indexing.
1559
+ // .docx → text extraction + agent capture
1560
+ // pipeline (until the mammoth → markdown workstream lands).
697
1561
  fastify.post('/api/repos/:repo/import', async (req, reply) => {
698
1562
  const { repo } = req.params;
699
- if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
700
- return reply.status(403).send({ error: 'Access denied' });
701
- }
1563
+ // Access enforced by graphForWrite — import creates entities, so write
1564
+ // is required. Graph captured here is reused by the inner closures so
1565
+ // they don't need to re-resolve (which would also bypass the gate).
1566
+ const repoGraph = graphForWrite(req, reply, repo);
1567
+ if (!repoGraph)
1568
+ return;
702
1569
  const body = req.body;
703
1570
  if (!body?.files || !Array.isArray(body.files)) {
704
1571
  return reply.status(400).send({ error: 'Expected { files: [{ filename, content }] }' });
705
1572
  }
1573
+ // Reject paths with traversal segments; importFile slugifies the rest.
1574
+ // Empty / undefined = repo root (existing behavior).
1575
+ const targetFolderPath = (body.targetFolderPath ?? '').trim();
1576
+ if (targetFolderPath.split('/').some(seg => seg === '..' || seg === '.')) {
1577
+ return reply.status(400).send({ error: 'Invalid targetFolderPath: contains traversal segments' });
1578
+ }
706
1579
  const captureRepo = body.agentDecides ? undefined : repo;
1580
+ const gitUser = reqGitUser(req);
1581
+ // Cancel support (CANCEL): the web client aborts the fetch via an
1582
+ // AbortController. Listen on the RESPONSE stream's `close` — it fires when
1583
+ // the connection drops, and `writableEnded` is false iff we haven't
1584
+ // finished sending the response yet, i.e. a genuine mid-import client
1585
+ // abort (vs the normal post-response close, where writableEnded is true).
1586
+ // `reply.raw` is the ServerResponse, the canonical place to detect that
1587
+ // the peer went away while we're still working; `req.raw` (the parsed
1588
+ // request) is a less reliable signal for an abort during processing.
1589
+ // Best-effort: the batch runner only reads this between batches, so a
1590
+ // single-batch import still commits its completed prefix (additive +
1591
+ // collision-safe). The listener is on this request's per-request
1592
+ // ServerResponse, so it's GC'd with the request — no cross-request leak.
1593
+ let clientGone = false;
1594
+ reply.raw.on('close', () => {
1595
+ if (!reply.raw.writableEnded)
1596
+ clientGone = true;
1597
+ });
707
1598
  try {
708
- const { extractText, isSupportedFile } = await import('../../services/import-service.js');
1599
+ const { extractText, importFile } = await import('../../services/import-service.js');
1600
+ const { importAssetAsEntity } = await import('../../services/asset-import-service.js');
1601
+ const { resolveFormat, isIndexable, FALLBACK_ASSET_ENTRY } = await import('../../core/format-registry.js');
709
1602
  const results = [];
710
1603
  const CONCURRENCY = 3;
711
- async function processFile(file) {
712
- if (!file.filename || !isSupportedFile(file.filename)) {
713
- return { filename: file.filename, status: 'error', error: `Unsupported file: ${file.filename}` };
1604
+ async function processDirect(file) {
1605
+ // Reuse the access-checked graph captured in the outer scope. Do
1606
+ // NOT re-resolve via workspaceManager.getGraph here the audit's
1607
+ // boundary is the outer graphForWrite call.
1608
+ const graph = repoGraph;
1609
+ const result = await importFile(graph, file.filename, file.content, {
1610
+ user: gitUser,
1611
+ // Caller-chosen destination subfolder; importFile combines it
1612
+ // with each file's own relative directory.
1613
+ folderPath: targetFolderPath || undefined,
1614
+ // Defer commits — we batch them into one transaction at the
1615
+ // end of the request so a 500-file import is one git commit
1616
+ // instead of 500. Per-file commits dominated wall time
1617
+ // (~50-200ms each) before this; batching collapses that to
1618
+ // one commit for the whole batch.
1619
+ skipCommit: true,
1620
+ });
1621
+ if (result.status === 'created' && result.path) {
1622
+ return {
1623
+ filename: file.filename,
1624
+ status: 'created',
1625
+ entities: [{ entityId: result.entityId, entityType: result.entityType }],
1626
+ entry: {
1627
+ operation: 'create',
1628
+ entityType: result.entityType,
1629
+ entityId: result.entityId,
1630
+ filePath: result.path,
1631
+ },
1632
+ sidecarPaths: result.sidecarPaths,
1633
+ };
714
1634
  }
1635
+ return { filename: file.filename, status: result.status, error: result.error };
1636
+ }
1637
+ async function processAsset(file) {
1638
+ // Reuse the outer access-checked graph; re-resolving would bypass
1639
+ // the architectural boundary even though the closure captures repo.
1640
+ const graph = repoGraph;
1641
+ try {
1642
+ const buffer = Buffer.from(file.content, 'base64');
1643
+ // Asset-as-embed (Phase B): store the binary folder-owned + create a
1644
+ // properly-TYPED entity (document / dataset×N / material) that embeds
1645
+ // it via asset_ref. No self-owning `asset` entity, no text sidecar
1646
+ // (the seeded body is indexed natively). xlsx fans out to N datasets,
1647
+ // so this returns 1..N entities — `commitEntries` carries them all.
1648
+ const result = await importAssetAsEntity({
1649
+ graph,
1650
+ filename: file.filename,
1651
+ buffer,
1652
+ folderPath: targetFolderPath || undefined,
1653
+ user: gitUser,
1654
+ uploaderUserId: reqUser(req)?.id != null ? String(reqUser(req).id) : null,
1655
+ // Defer commits — the dispatcher's batched commitPending call at
1656
+ // the bottom of the route covers every entry in one transaction.
1657
+ skipCommit: true,
1658
+ });
1659
+ return {
1660
+ filename: file.filename,
1661
+ status: 'created',
1662
+ entities: result.entities.map(e => ({ entityId: e.entityId, entityType: e.entityType })),
1663
+ commitEntries: result.entities.map(e => ({
1664
+ operation: 'create',
1665
+ entityType: e.entityType,
1666
+ entityId: e.entityId,
1667
+ filePath: e.filePath,
1668
+ })),
1669
+ };
1670
+ }
1671
+ catch (err) {
1672
+ return { filename: file.filename, status: 'error', error: err.message ?? 'Asset import failed' };
1673
+ }
1674
+ }
1675
+ async function processViaAgent(file) {
715
1676
  try {
716
1677
  const text = await extractText(file.filename, file.content);
717
1678
  if (!text) {
718
- console.warn(`[import] No text extracted from ${file.filename}`);
1679
+ log.warn(`[import] No text extracted from ${file.filename}`);
719
1680
  return { filename: file.filename, status: 'error', error: 'No text content extracted' };
720
1681
  }
721
- console.log(`[import] Extracted ${text.length} chars from ${file.filename}`);
1682
+ log.info(`[import] Extracted ${text.length} chars from ${file.filename}`);
722
1683
  const captureRes = await fastify.inject({
723
1684
  method: 'POST',
724
1685
  url: '/api/capture',
@@ -731,7 +1692,7 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
731
1692
  });
732
1693
  const captureBody = JSON.parse(captureRes.body);
733
1694
  if (captureRes.statusCode !== 200 || !captureBody.success) {
734
- console.warn(`[import] Capture failed for ${file.filename}: ${captureRes.statusCode} ${captureBody.error ?? JSON.stringify(captureBody).slice(0, 200)}`);
1695
+ log.warn(`[import] Capture failed for ${file.filename}: ${captureRes.statusCode} ${captureBody.error ?? JSON.stringify(captureBody).slice(0, 200)}`);
735
1696
  return { filename: file.filename, status: 'error', error: captureBody.error ?? 'Agent processing failed' };
736
1697
  }
737
1698
  return { filename: file.filename, status: 'created', entities: captureBody.entities };
@@ -740,21 +1701,156 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
740
1701
  return { filename: file.filename, status: 'error', error: err.message ?? 'Processing failed' };
741
1702
  }
742
1703
  }
743
- // Process files in batches of CONCURRENCY
744
- for (let i = 0; i < body.files.length; i += CONCURRENCY) {
745
- const batch = body.files.slice(i, i + CONCURRENCY);
746
- const batchResults = await Promise.all(batch.map(processFile));
747
- results.push(...batchResults);
1704
+ // Route a single file off the format registry — the one source of
1705
+ // truth for every ingestion surface. P0 is a parity refactor: the
1706
+ // routing below reproduces today's behavior exactly. The only
1707
+ // intentional follow-ups are flagged inline (docx/svg transitional
1708
+ // paths; the FALLBACK reject that P0b flips to lenient).
1709
+ async function routeByFormat(file, entry) {
1710
+ // Transitional formats keep the current agent-extraction path until
1711
+ // their real handler lands. After P2 this is svg only — it routes
1712
+ // here until sanitize-html is wired (P3); routing it to uploadAsset
1713
+ // before then would store unsanitized svg (stored-XSS). docx flipped
1714
+ // to the asset path in P2 (uploads as asset, converts on demand).
1715
+ if (entry.transitional === 'agent-extract') {
1716
+ return processViaAgent(file);
1717
+ }
1718
+ switch (entry.handler) {
1719
+ case 'importDirect':
1720
+ return processDirect(file);
1721
+ case 'uploadAsset':
1722
+ // P0 parity: genuinely-unknown types are still rejected. The
1723
+ // lenient generic-asset fallback (accept unknowns as assets) is
1724
+ // P0b — a deliberate behavior change made by removing exactly
1725
+ // this guard.
1726
+ if (entry === FALLBACK_ASSET_ENTRY) {
1727
+ return { filename: file.filename, status: 'error', error: `Unsupported file: ${file.filename}` };
1728
+ }
1729
+ return processAsset(file);
1730
+ default:
1731
+ // importBookmarks is not routed through the dispatcher until
1732
+ // P8 (content-sniff); bookmark exports still use the dedicated
1733
+ // POST /api/repos/:repo/import-bookmarks endpoint, so a bare
1734
+ // .html lands here and is rejected — matching today.
1735
+ return { filename: file.filename, status: 'error', error: `Unsupported file: ${file.filename}` };
1736
+ }
1737
+ }
1738
+ async function processFile(file) {
1739
+ if (!file.filename) {
1740
+ return { filename: file.filename, status: 'error', error: 'Missing filename' };
1741
+ }
1742
+ // Resolve the format once and thread it through routing.
1743
+ // `searchable` (OV3 server contract) reflects the registry's
1744
+ // DECLARED indexability for the file — the client (P5) renders the
1745
+ // honest "stored as-is / not searchable" callouts from it.
1746
+ // KNOWN LIMITATION: this is declared, not actual. A scanned/encrypted
1747
+ // PDF declares searchable:true (indexAs:'sidecar') but yields no text,
1748
+ // so it won't be flagged "stored as-is" even though its content is
1749
+ // effectively filename-only. Closing that gap needs the extractor to
1750
+ // report actual text-yield back into this flag (P3-indexing scope);
1751
+ // the negative claim stays accurate, the positive one is best-effort.
1752
+ const entry = resolveFormat(file.filename);
1753
+ const searchable = isIndexable(entry);
1754
+ const result = await routeByFormat(file, entry);
1755
+ return { ...result, searchable };
1756
+ }
1757
+ // Process files in cancellable batches of CONCURRENCY. The runner
1758
+ // checks the abort flag between batches: if the client disconnected
1759
+ // (web AbortController), it stops dispatching the remainder, lets the
1760
+ // in-flight batch settle, and returns the completed prefix — which the
1761
+ // commitPending step below commits in one transaction (no uncommitted
1762
+ // files left behind). Cancel is partial + honest: import is additive
1763
+ // and collision-safe.
1764
+ const { runImportBatches } = await import('./import-batch.js');
1765
+ const { results: batched, cancelled } = await runImportBatches(body.files, processFile, { concurrency: CONCURRENCY, isAborted: () => clientGone });
1766
+ results.push(...batched);
1767
+ // Direct-write path deferred git commits — flush them all in one
1768
+ // transaction now. Collapses N per-file commits into one batch
1769
+ // commit (~50ms vs N×50ms). Agent-path files committed inline via
1770
+ // /api/capture so they're not in this set.
1771
+ const pendingEntries = results.flatMap(r => {
1772
+ const pf = r;
1773
+ // Asset-import files carry commitEntries (1..N); direct-write files
1774
+ // carry a single entry. Prefer the plural, fall back to the singular.
1775
+ if (pf.commitEntries && pf.commitEntries.length > 0)
1776
+ return pf.commitEntries;
1777
+ return pf.entry ? [pf.entry] : [];
1778
+ });
1779
+ // Collect every sidecar path produced by the import — these stage in
1780
+ // the same commit as the entity files, so the folder display name
1781
+ // and the entries it labels land atomically. De-dup because two
1782
+ // imported files inside the same Schema_OS/widgets/ would otherwise
1783
+ // produce duplicate sidecar entries.
1784
+ const sidecarPaths = Array.from(new Set(results.flatMap(r => r.sidecarPaths ?? [])));
1785
+ // commitPending failure used to be swallowed and the route returned
1786
+ // success=true even though the files were never committed. The audit
1787
+ // (2026-05) flagged this: a 500-file import that failed mid-commit
1788
+ // looked successful to the client but left N untracked files. Now
1789
+ // the failure propagates as 500 with a partial-failure shape so the
1790
+ // caller knows which entries are stranded.
1791
+ let commitError = null;
1792
+ if (pendingEntries.length > 0) {
1793
+ try {
1794
+ await repoGraph.commitPending(pendingEntries, {
1795
+ commitMessage: `Import ${pendingEntries.length} ${pendingEntries.length === 1 ? 'entry' : 'entries'} from web UI`,
1796
+ user: gitUser,
1797
+ extraPaths: sidecarPaths.length > 0 ? sidecarPaths : undefined,
1798
+ });
1799
+ }
1800
+ catch (err) {
1801
+ commitError = err instanceof Error ? err.message : String(err);
1802
+ log.warn(`[import] batch commit failed for ${repo}: ${commitError}`);
1803
+ }
748
1804
  }
749
1805
  const created = results.filter(r => r.status === 'created').length;
750
1806
  const entityCount = results.reduce((sum, r) => sum + (r.entities?.length ?? 0), 0);
1807
+ // Single repo_sync broadcast for the whole import. The realtime
1808
+ // store's repo_sync handler refreshes the repo's entity list once
1809
+ // — vs. one entity_change per file, which would do N re-renders
1810
+ // on every connected client and freeze the UI for large imports.
1811
+ // Skip when commit failed: there's nothing for clients to refresh
1812
+ // beyond stranded uncommitted files, and the broadcast would lie
1813
+ // about persistence.
1814
+ if (created > 0 && !commitError) {
1815
+ wsHub?.broadcast({
1816
+ type: 'repo_sync', repo,
1817
+ actor: req.user?.displayName ?? 'API',
1818
+ source: 'api', timestamp: new Date().toISOString(),
1819
+ });
1820
+ // Asset caption/extract enqueue is no longer wired here — it fires
1821
+ // from the single VectorService.onAssetPending chokepoint when the
1822
+ // asset's index state is seeded (covers every upload path uniformly).
1823
+ }
1824
+ const errors = results.filter(r => r.status === 'error').map(r => r.error);
1825
+ if (commitError) {
1826
+ // Files are on disk but uncommitted — partial failure. Surface as
1827
+ // 500 with details so the client doesn't show "imported N entries"
1828
+ // for entries that won't survive a server restart.
1829
+ return reply.status(500).send({
1830
+ success: false,
1831
+ error: `Import wrote ${pendingEntries.length} entries to disk but the git commit failed: ${commitError}`,
1832
+ commitError,
1833
+ uncommittedEntries: pendingEntries.length,
1834
+ imported: created,
1835
+ entityCount,
1836
+ skipped: errors.length,
1837
+ errors,
1838
+ results,
1839
+ });
1840
+ }
751
1841
  return {
752
1842
  success: true,
753
1843
  imported: created,
754
1844
  entityCount,
755
- skipped: results.filter(r => r.status === 'error').length,
756
- errors: results.filter(r => r.status === 'error').map(r => r.error),
1845
+ skipped: errors.length,
1846
+ errors,
757
1847
  results,
1848
+ // Cancel summary (CANCEL): when the client disconnected mid-import we
1849
+ // committed the completed prefix above and report N of M honestly.
1850
+ // The aborting client typically won't read this (its fetch rejected),
1851
+ // but it keeps the contract honest for any caller that does.
1852
+ cancelled,
1853
+ total: body.files.length,
758
1854
  };
759
1855
  }
760
1856
  catch (err) {
@@ -767,9 +1863,10 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
767
1863
  // POST /api/repos/:repo/import-bookmarks — import browser bookmark exports as reference entities
768
1864
  fastify.post('/api/repos/:repo/import-bookmarks', async (req, reply) => {
769
1865
  const { repo } = req.params;
770
- if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
771
- return reply.status(403).send({ error: 'Access denied' });
772
- }
1866
+ // Access enforced by graphForWrite — bookmark import creates entities.
1867
+ const graph = graphForWrite(req, reply, repo);
1868
+ if (!graph)
1869
+ return;
773
1870
  const body = req.body;
774
1871
  if (!body?.content || typeof body.content !== 'string') {
775
1872
  return reply.status(400).send({ error: 'Expected { content: string }' });
@@ -781,7 +1878,6 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
781
1878
  error: "This doesn't appear to be a browser bookmark export. Export bookmarks from your browser's bookmark manager to generate the correct format.",
782
1879
  });
783
1880
  }
784
- const graph = workspaceManager.getGraph(repo);
785
1881
  const user = reqGitUser(req);
786
1882
  const result = await importBookmarks(graph, body.content, user);
787
1883
  // Single git commit for all created entities
@@ -806,41 +1902,75 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
806
1902
  });
807
1903
  }
808
1904
  });
809
- // POST /api/repos/:repo/import-imagesimport images as reference entities
810
- fastify.post('/api/repos/:repo/import-images', { bodyLimit: 50 * 1024 * 1024 }, async (req, reply) => {
1905
+ // POST /api/repos/:repo/assetsgeneric asset upload.
1906
+ //
1907
+ // Multipart `file` field. Asset-as-embed (B2): stores a FREE-STANDING,
1908
+ // folder-owned asset (assets.db row + immutable-key blob) — NO wrapper
1909
+ // entity. Returns { assetId, asset_url, ... } for the caller to embed.
1910
+ // For an IMAGE, graph.uploadAsset fires the caption/OCR trigger (assetId-
1911
+ // keyed), and the caption folds into any entry that embeds the asset. See
1912
+ // docs/asset-architecture.md.
1913
+ fastify.post('/api/repos/:repo/assets', async (req, reply) => {
811
1914
  const { repo } = req.params;
812
- if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
813
- return reply.status(403).send({ error: 'Access denied' });
1915
+ // Access enforced by graphForWrite (WorkspaceAccessService).
1916
+ const graph = graphForWrite(req, reply, repo);
1917
+ if (!graph)
1918
+ return;
1919
+ // Per-route fileSize override — the global multipart limit (10MB) is
1920
+ // tuned for inline image embeds; full asset uploads accept up to the
1921
+ // 100MB cap enforced inside AssetService.upload().
1922
+ let file;
1923
+ try {
1924
+ file = await req.file({ limits: { fileSize: 100 * 1024 * 1024 } });
814
1925
  }
815
- const body = req.body;
816
- if (!body?.files || !Array.isArray(body.files)) {
817
- return reply.status(400).send({ error: 'Expected { files: [{ filename, content }] }' });
1926
+ catch (err) {
1927
+ return reply.status(400).send({ error: err?.message ?? 'Invalid multipart upload' });
818
1928
  }
1929
+ if (!file)
1930
+ return reply.status(400).send({ error: 'No file uploaded' });
1931
+ const buffer = await file.toBuffer();
819
1932
  try {
820
- const { importImages } = await import('../../services/image-import-service.js');
821
- const graph = workspaceManager.getGraph(repo);
822
- const user = reqGitUser(req);
823
- const result = await importImages(graph, body.files, user);
824
- // Single git commit for all created entities
825
- if (result.created > 0) {
826
- const git = graph.getGitService();
827
- if (git) {
828
- const commitUser = user ?? { id: 'import', name: 'Import', email: 'import@studiograph' };
829
- git.commitAll(`Import ${result.created} image${result.created !== 1 ? 's' : ''} as references`, commitUser);
830
- }
831
- }
832
- return {
1933
+ // Asset-as-embed (B2): uploading to the Assets store produces a
1934
+ // FREE-STANDING, folder-owned asset — NOT an `asset` entity. The asset
1935
+ // manager lists it directly; the homepage capture embeds its URL into
1936
+ // the entry it creates and deletes it by assetId if unused. Folder-owned
1937
+ // assets are repo-scoped, so the `folder` subpath doesn't apply here.
1938
+ const displayName = req.query.name?.trim()
1939
+ || deriveAssetDisplayName(file.filename)
1940
+ || file.filename;
1941
+ const stored = await graph.uploadAsset('material', // ignored for ownership when `owner` is set
1942
+ 'asset', // ignored
1943
+ file.filename, buffer, undefined, {
1944
+ owner: { type: 'folder', id: repo },
1945
+ uploaderUserId: reqUser(req)?.id != null ? String(reqUser(req).id) : null,
1946
+ name: displayName,
1947
+ });
1948
+ wsHub?.broadcast({
1949
+ type: 'repo_sync',
1950
+ repo,
1951
+ actor: req.user?.displayName ?? 'API',
1952
+ source: 'api',
1953
+ timestamp: new Date().toISOString(),
1954
+ });
1955
+ // NOTE: image caption/OCR indexing is NOT derived for this
1956
+ // free-standing asset — the caption sweep + live drain key on `asset`
1957
+ // ENTITIES, which this no longer creates. It stays filename-only in
1958
+ // vector search until the caption pipeline is rewired to be
1959
+ // assets.db-row-keyed (tracked follow-up).
1960
+ return reply.send({
833
1961
  success: true,
834
- created: result.created,
835
- skipped: result.skipped,
836
- errors: result.errors,
837
- };
1962
+ assetId: stored.assetId,
1963
+ asset_url: stored.publicUrl,
1964
+ asset_kind: stored.mediaType,
1965
+ filename: stored.filename,
1966
+ });
838
1967
  }
839
1968
  catch (err) {
840
- return reply.status(400).send({
841
- success: false,
842
- error: err instanceof Error ? err.message : 'Unknown error',
843
- });
1969
+ const msg = err instanceof Error ? err.message : String(err);
1970
+ const status = msg.includes('not configured') ? 501
1971
+ : msg.includes('Unsupported asset extension') ? 415
1972
+ : 400;
1973
+ return reply.status(status).send({ success: false, error: msg });
844
1974
  }
845
1975
  });
846
1976
  }