@bevel-software/platform-core-backend 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (720) hide show
  1. package/LICENSE +202 -0
  2. package/dist/assets.d.ts +5 -0
  3. package/dist/assets.d.ts.map +1 -0
  4. package/dist/assets.js +23 -0
  5. package/dist/assets.js.map +1 -0
  6. package/dist/core/core-ports.d.ts +83 -0
  7. package/dist/core/core-ports.d.ts.map +1 -0
  8. package/dist/core/core-ports.js +12 -0
  9. package/dist/core/core-ports.js.map +1 -0
  10. package/dist/core/create-core-server.d.ts +62 -0
  11. package/dist/core/create-core-server.d.ts.map +1 -0
  12. package/dist/core/create-core-server.js +204 -0
  13. package/dist/core/create-core-server.js.map +1 -0
  14. package/dist/core/create-core-services.d.ts +95 -0
  15. package/dist/core/create-core-services.d.ts.map +1 -0
  16. package/dist/core/create-core-services.js +352 -0
  17. package/dist/core/create-core-services.js.map +1 -0
  18. package/dist/core-config.d.ts +119 -0
  19. package/dist/core-config.d.ts.map +1 -0
  20. package/dist/core-config.js +240 -0
  21. package/dist/core-config.js.map +1 -0
  22. package/dist/index.d.ts +36 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +36 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/modules/access/access-control.interface.d.ts +348 -0
  27. package/dist/modules/access/access-control.interface.d.ts.map +1 -0
  28. package/dist/modules/access/access-control.interface.js +2 -0
  29. package/dist/modules/access/access-control.interface.js.map +1 -0
  30. package/dist/modules/access/access-control.service.d.ts +323 -0
  31. package/dist/modules/access/access-control.service.d.ts.map +1 -0
  32. package/dist/modules/access/access-control.service.js +1431 -0
  33. package/dist/modules/access/access-control.service.js.map +1 -0
  34. package/dist/modules/access/access-errors.d.ts +34 -0
  35. package/dist/modules/access/access-errors.d.ts.map +1 -0
  36. package/dist/modules/access/access-errors.js +40 -0
  37. package/dist/modules/access/access-errors.js.map +1 -0
  38. package/dist/modules/access/access-mutation.service.d.ts +176 -0
  39. package/dist/modules/access/access-mutation.service.d.ts.map +1 -0
  40. package/dist/modules/access/access-mutation.service.js +307 -0
  41. package/dist/modules/access/access-mutation.service.js.map +1 -0
  42. package/dist/modules/access/access-splice.d.ts +98 -0
  43. package/dist/modules/access/access-splice.d.ts.map +1 -0
  44. package/dist/modules/access/access-splice.js +346 -0
  45. package/dist/modules/access/access-splice.js.map +1 -0
  46. package/dist/modules/access/access.routes.d.ts +10 -0
  47. package/dist/modules/access/access.routes.d.ts.map +1 -0
  48. package/dist/modules/access/access.routes.js +757 -0
  49. package/dist/modules/access/access.routes.js.map +1 -0
  50. package/dist/modules/access/creator-access.d.ts +113 -0
  51. package/dist/modules/access/creator-access.d.ts.map +1 -0
  52. package/dist/modules/access/creator-access.js +212 -0
  53. package/dist/modules/access/creator-access.js.map +1 -0
  54. package/dist/modules/access/kb-read-filter.d.ts +41 -0
  55. package/dist/modules/access/kb-read-filter.d.ts.map +1 -0
  56. package/dist/modules/access/kb-read-filter.js +60 -0
  57. package/dist/modules/access/kb-read-filter.js.map +1 -0
  58. package/dist/modules/access/roles-admin.service.d.ts +240 -0
  59. package/dist/modules/access/roles-admin.service.d.ts.map +1 -0
  60. package/dist/modules/access/roles-admin.service.js +630 -0
  61. package/dist/modules/access/roles-admin.service.js.map +1 -0
  62. package/dist/modules/access/roles-edit.d.ts +91 -0
  63. package/dist/modules/access/roles-edit.d.ts.map +1 -0
  64. package/dist/modules/access/roles-edit.js +232 -0
  65. package/dist/modules/access/roles-edit.js.map +1 -0
  66. package/dist/modules/access/roles-yaml-guard.d.ts +56 -0
  67. package/dist/modules/access/roles-yaml-guard.d.ts.map +1 -0
  68. package/dist/modules/access/roles-yaml-guard.js +79 -0
  69. package/dist/modules/access/roles-yaml-guard.js.map +1 -0
  70. package/dist/modules/admin/admin-access.routes.d.ts +18 -0
  71. package/dist/modules/admin/admin-access.routes.d.ts.map +1 -0
  72. package/dist/modules/admin/admin-access.routes.js +26 -0
  73. package/dist/modules/admin/admin-access.routes.js.map +1 -0
  74. package/dist/modules/admin/admin-access.service.d.ts +24 -0
  75. package/dist/modules/admin/admin-access.service.d.ts.map +1 -0
  76. package/dist/modules/admin/admin-access.service.js +41 -0
  77. package/dist/modules/admin/admin-access.service.js.map +1 -0
  78. package/dist/modules/admin/admin.interface.d.ts +11 -0
  79. package/dist/modules/admin/admin.interface.d.ts.map +1 -0
  80. package/dist/modules/admin/admin.interface.js +2 -0
  81. package/dist/modules/admin/admin.interface.js.map +1 -0
  82. package/dist/modules/auth/account-erasure.service.d.ts +80 -0
  83. package/dist/modules/auth/account-erasure.service.d.ts.map +1 -0
  84. package/dist/modules/auth/account-erasure.service.js +91 -0
  85. package/dist/modules/auth/account-erasure.service.js.map +1 -0
  86. package/dist/modules/auth/auth.middleware.d.ts +23 -0
  87. package/dist/modules/auth/auth.middleware.d.ts.map +1 -0
  88. package/dist/modules/auth/auth.middleware.js +68 -0
  89. package/dist/modules/auth/auth.middleware.js.map +1 -0
  90. package/dist/modules/auth/auth.routes.d.ts +25 -0
  91. package/dist/modules/auth/auth.routes.d.ts.map +1 -0
  92. package/dist/modules/auth/auth.routes.js +72 -0
  93. package/dist/modules/auth/auth.routes.js.map +1 -0
  94. package/dist/modules/auth/auth.service.d.ts +76 -0
  95. package/dist/modules/auth/auth.service.d.ts.map +1 -0
  96. package/dist/modules/auth/auth.service.js +168 -0
  97. package/dist/modules/auth/auth.service.js.map +1 -0
  98. package/dist/modules/code-mode/code-mode-names.d.ts +16 -0
  99. package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -0
  100. package/dist/modules/code-mode/code-mode-names.js +30 -0
  101. package/dist/modules/code-mode/code-mode-names.js.map +1 -0
  102. package/dist/modules/code-mode/code-mode.tool.d.ts +13 -0
  103. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -0
  104. package/dist/modules/code-mode/code-mode.tool.js +112 -0
  105. package/dist/modules/code-mode/code-mode.tool.js.map +1 -0
  106. package/dist/modules/code-mode/index.d.ts +3 -0
  107. package/dist/modules/code-mode/index.d.ts.map +1 -0
  108. package/dist/modules/code-mode/index.js +3 -0
  109. package/dist/modules/code-mode/index.js.map +1 -0
  110. package/dist/modules/database/connection.d.ts +6 -0
  111. package/dist/modules/database/connection.d.ts.map +1 -0
  112. package/dist/modules/database/connection.js +12 -0
  113. package/dist/modules/database/connection.js.map +1 -0
  114. package/dist/modules/database/core-schema.d.ts +2273 -0
  115. package/dist/modules/database/core-schema.d.ts.map +1 -0
  116. package/dist/modules/database/core-schema.js +413 -0
  117. package/dist/modules/database/core-schema.js.map +1 -0
  118. package/dist/modules/database/migrate.d.ts +10 -0
  119. package/dist/modules/database/migrate.d.ts.map +1 -0
  120. package/dist/modules/database/migrate.js +40 -0
  121. package/dist/modules/database/migrate.js.map +1 -0
  122. package/dist/modules/database/schema.d.ts +12 -0
  123. package/dist/modules/database/schema.d.ts.map +1 -0
  124. package/dist/modules/database/schema.js +12 -0
  125. package/dist/modules/database/schema.js.map +1 -0
  126. package/dist/modules/diff/diff-paths.d.ts +2 -0
  127. package/dist/modules/diff/diff-paths.d.ts.map +1 -0
  128. package/dist/modules/diff/diff-paths.js +9 -0
  129. package/dist/modules/diff/diff-paths.js.map +1 -0
  130. package/dist/modules/diff/diff.config.d.ts +8 -0
  131. package/dist/modules/diff/diff.config.d.ts.map +1 -0
  132. package/dist/modules/diff/diff.config.js +13 -0
  133. package/dist/modules/diff/diff.config.js.map +1 -0
  134. package/dist/modules/diff/diff.interface.d.ts +73 -0
  135. package/dist/modules/diff/diff.interface.d.ts.map +1 -0
  136. package/dist/modules/diff/diff.interface.js +2 -0
  137. package/dist/modules/diff/diff.interface.js.map +1 -0
  138. package/dist/modules/diff/diff.routes.d.ts +35 -0
  139. package/dist/modules/diff/diff.routes.d.ts.map +1 -0
  140. package/dist/modules/diff/diff.routes.js +228 -0
  141. package/dist/modules/diff/diff.routes.js.map +1 -0
  142. package/dist/modules/diff/diff.service.d.ts +51 -0
  143. package/dist/modules/diff/diff.service.d.ts.map +1 -0
  144. package/dist/modules/diff/diff.service.js +489 -0
  145. package/dist/modules/diff/diff.service.js.map +1 -0
  146. package/dist/modules/diff/line-diff.d.ts +16 -0
  147. package/dist/modules/diff/line-diff.d.ts.map +1 -0
  148. package/dist/modules/diff/line-diff.js +67 -0
  149. package/dist/modules/diff/line-diff.js.map +1 -0
  150. package/dist/modules/mcp/mcp-auth.middleware.d.ts +27 -0
  151. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -0
  152. package/dist/modules/mcp/mcp-auth.middleware.js +116 -0
  153. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -0
  154. package/dist/modules/mcp/mcp-session-store.d.ts +74 -0
  155. package/dist/modules/mcp/mcp-session-store.d.ts.map +1 -0
  156. package/dist/modules/mcp/mcp-session-store.js +131 -0
  157. package/dist/modules/mcp/mcp-session-store.js.map +1 -0
  158. package/dist/modules/mcp/mcp.routes.d.ts +24 -0
  159. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -0
  160. package/dist/modules/mcp/mcp.routes.js +229 -0
  161. package/dist/modules/mcp/mcp.routes.js.map +1 -0
  162. package/dist/modules/mcp/mcp.service.d.ts +198 -0
  163. package/dist/modules/mcp/mcp.service.d.ts.map +1 -0
  164. package/dist/modules/mcp/mcp.service.js +840 -0
  165. package/dist/modules/mcp/mcp.service.js.map +1 -0
  166. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +76 -0
  167. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -0
  168. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +291 -0
  169. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -0
  170. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts +18 -0
  171. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts.map +1 -0
  172. package/dist/modules/mcp/oauth/oauth-consent.routes.js +52 -0
  173. package/dist/modules/mcp/oauth/oauth-consent.routes.js.map +1 -0
  174. package/dist/modules/mcp/oauth/oauth-state.d.ts +32 -0
  175. package/dist/modules/mcp/oauth/oauth-state.d.ts.map +1 -0
  176. package/dist/modules/mcp/oauth/oauth-state.js +34 -0
  177. package/dist/modules/mcp/oauth/oauth-state.js.map +1 -0
  178. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +92 -0
  179. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -0
  180. package/dist/modules/secrets-vault/db-secrets-vault.service.js +728 -0
  181. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -0
  182. package/dist/modules/secrets-vault/index.d.ts +7 -0
  183. package/dist/modules/secrets-vault/index.d.ts.map +1 -0
  184. package/dist/modules/secrets-vault/index.js +6 -0
  185. package/dist/modules/secrets-vault/index.js.map +1 -0
  186. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +63 -0
  187. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -0
  188. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +269 -0
  189. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -0
  190. package/dist/modules/secrets-vault/secrets-variable-loader.d.ts +46 -0
  191. package/dist/modules/secrets-vault/secrets-variable-loader.d.ts.map +1 -0
  192. package/dist/modules/secrets-vault/secrets-variable-loader.js +87 -0
  193. package/dist/modules/secrets-vault/secrets-variable-loader.js.map +1 -0
  194. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +210 -0
  195. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -0
  196. package/dist/modules/secrets-vault/secrets-vault.contract.js +90 -0
  197. package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -0
  198. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +30 -0
  199. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -0
  200. package/dist/modules/secrets-vault/secrets-vault.routes.js +490 -0
  201. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -0
  202. package/dist/modules/skills/index.d.ts +5 -0
  203. package/dist/modules/skills/index.d.ts.map +1 -0
  204. package/dist/modules/skills/index.js +4 -0
  205. package/dist/modules/skills/index.js.map +1 -0
  206. package/dist/modules/skills/skills.contract.d.ts +58 -0
  207. package/dist/modules/skills/skills.contract.d.ts.map +1 -0
  208. package/dist/modules/skills/skills.contract.js +10 -0
  209. package/dist/modules/skills/skills.contract.js.map +1 -0
  210. package/dist/modules/skills/skills.routes.d.ts +15 -0
  211. package/dist/modules/skills/skills.routes.d.ts.map +1 -0
  212. package/dist/modules/skills/skills.routes.js +34 -0
  213. package/dist/modules/skills/skills.routes.js.map +1 -0
  214. package/dist/modules/skills/skills.service.d.ts +21 -0
  215. package/dist/modules/skills/skills.service.d.ts.map +1 -0
  216. package/dist/modules/skills/skills.service.js +202 -0
  217. package/dist/modules/skills/skills.service.js.map +1 -0
  218. package/dist/modules/skills/skills.tools.d.ts +15 -0
  219. package/dist/modules/skills/skills.tools.d.ts.map +1 -0
  220. package/dist/modules/skills/skills.tools.js +96 -0
  221. package/dist/modules/skills/skills.tools.js.map +1 -0
  222. package/dist/modules/tool-auth/external-api-key.errors.d.ts +14 -0
  223. package/dist/modules/tool-auth/external-api-key.errors.d.ts.map +1 -0
  224. package/dist/modules/tool-auth/external-api-key.errors.js +23 -0
  225. package/dist/modules/tool-auth/external-api-key.errors.js.map +1 -0
  226. package/dist/modules/tool-auth/external-api-key.interface.d.ts +90 -0
  227. package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -0
  228. package/dist/modules/tool-auth/external-api-key.interface.js +2 -0
  229. package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -0
  230. package/dist/modules/tool-auth/external-api-key.service.d.ts +25 -0
  231. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -0
  232. package/dist/modules/tool-auth/external-api-key.service.js +164 -0
  233. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -0
  234. package/dist/modules/tool-auth/internal-token.service.d.ts +62 -0
  235. package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -0
  236. package/dist/modules/tool-auth/internal-token.service.js +85 -0
  237. package/dist/modules/tool-auth/internal-token.service.js.map +1 -0
  238. package/dist/modules/tool-auth/llm-usage-meter.d.ts +16 -0
  239. package/dist/modules/tool-auth/llm-usage-meter.d.ts.map +1 -0
  240. package/dist/modules/tool-auth/llm-usage-meter.js +6 -0
  241. package/dist/modules/tool-auth/llm-usage-meter.js.map +1 -0
  242. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +101 -0
  243. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -0
  244. package/dist/modules/tool-auth/tool-auth.middleware.js +166 -0
  245. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -0
  246. package/dist/modules/tool-helpers/index.d.ts +19 -0
  247. package/dist/modules/tool-helpers/index.d.ts.map +1 -0
  248. package/dist/modules/tool-helpers/index.js +18 -0
  249. package/dist/modules/tool-helpers/index.js.map +1 -0
  250. package/dist/modules/tool-helpers/tool-context.d.ts +35 -0
  251. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -0
  252. package/dist/modules/tool-helpers/tool-context.js +66 -0
  253. package/dist/modules/tool-helpers/tool-context.js.map +1 -0
  254. package/dist/modules/tool-helpers/tool-def.d.ts +33 -0
  255. package/dist/modules/tool-helpers/tool-def.d.ts.map +1 -0
  256. package/dist/modules/tool-helpers/tool-def.js +57 -0
  257. package/dist/modules/tool-helpers/tool-def.js.map +1 -0
  258. package/dist/modules/tool-helpers/tool-handler.d.ts +19 -0
  259. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -0
  260. package/dist/modules/tool-helpers/tool-handler.js +84 -0
  261. package/dist/modules/tool-helpers/tool-handler.js.map +1 -0
  262. package/dist/modules/tool-helpers/tool.contract.d.ts +64 -0
  263. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -0
  264. package/dist/modules/tool-helpers/tool.contract.js +24 -0
  265. package/dist/modules/tool-helpers/tool.contract.js.map +1 -0
  266. package/dist/modules/tool-helpers/validate-token.d.ts +31 -0
  267. package/dist/modules/tool-helpers/validate-token.d.ts.map +1 -0
  268. package/dist/modules/tool-helpers/validate-token.js +20 -0
  269. package/dist/modules/tool-helpers/validate-token.js.map +1 -0
  270. package/dist/modules/tool-manuals/index.d.ts +5 -0
  271. package/dist/modules/tool-manuals/index.d.ts.map +1 -0
  272. package/dist/modules/tool-manuals/index.js +5 -0
  273. package/dist/modules/tool-manuals/index.js.map +1 -0
  274. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +182 -0
  275. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -0
  276. package/dist/modules/tool-manuals/tool-manuals.contract.js +19 -0
  277. package/dist/modules/tool-manuals/tool-manuals.contract.js.map +1 -0
  278. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +24 -0
  279. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -0
  280. package/dist/modules/tool-manuals/tool-manuals.routes.js +102 -0
  281. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -0
  282. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +108 -0
  283. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -0
  284. package/dist/modules/tool-manuals/tool-manuals.service.js +710 -0
  285. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -0
  286. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +35 -0
  287. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -0
  288. package/dist/modules/tool-manuals/tool-manuals.tools.js +157 -0
  289. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -0
  290. package/dist/modules/tool-registry/manual.routes.d.ts +6 -0
  291. package/dist/modules/tool-registry/manual.routes.d.ts.map +1 -0
  292. package/dist/modules/tool-registry/manual.routes.js +76 -0
  293. package/dist/modules/tool-registry/manual.routes.js.map +1 -0
  294. package/dist/modules/tool-registry/tool-registry.d.ts +23 -0
  295. package/dist/modules/tool-registry/tool-registry.d.ts.map +1 -0
  296. package/dist/modules/tool-registry/tool-registry.js +46 -0
  297. package/dist/modules/tool-registry/tool-registry.js.map +1 -0
  298. package/dist/modules/tool-registry/tool.contract.d.ts +48 -0
  299. package/dist/modules/tool-registry/tool.contract.d.ts.map +1 -0
  300. package/dist/modules/tool-registry/tool.contract.js +2 -0
  301. package/dist/modules/tool-registry/tool.contract.js.map +1 -0
  302. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +14 -0
  303. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -0
  304. package/dist/modules/workflow/agent-tools/workflow.tools.js +437 -0
  305. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -0
  306. package/dist/modules/workflow/event-bus.d.ts +107 -0
  307. package/dist/modules/workflow/event-bus.d.ts.map +1 -0
  308. package/dist/modules/workflow/event-bus.js +224 -0
  309. package/dist/modules/workflow/event-bus.js.map +1 -0
  310. package/dist/modules/workflow/events.routes.d.ts +80 -0
  311. package/dist/modules/workflow/events.routes.d.ts.map +1 -0
  312. package/dist/modules/workflow/events.routes.js +216 -0
  313. package/dist/modules/workflow/events.routes.js.map +1 -0
  314. package/dist/modules/workflow/file-change-notifier.d.ts +38 -0
  315. package/dist/modules/workflow/file-change-notifier.d.ts.map +1 -0
  316. package/dist/modules/workflow/file-change-notifier.js +22 -0
  317. package/dist/modules/workflow/file-change-notifier.js.map +1 -0
  318. package/dist/modules/workflow/file-lock.service.d.ts +57 -0
  319. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -0
  320. package/dist/modules/workflow/file-lock.service.js +202 -0
  321. package/dist/modules/workflow/file-lock.service.js.map +1 -0
  322. package/dist/modules/workflow/git/branch-name.d.ts +10 -0
  323. package/dist/modules/workflow/git/branch-name.d.ts.map +1 -0
  324. package/dist/modules/workflow/git/branch-name.js +76 -0
  325. package/dist/modules/workflow/git/branch-name.js.map +1 -0
  326. package/dist/modules/workflow/git/clone-config.d.ts +61 -0
  327. package/dist/modules/workflow/git/clone-config.d.ts.map +1 -0
  328. package/dist/modules/workflow/git/clone-config.js +69 -0
  329. package/dist/modules/workflow/git/clone-config.js.map +1 -0
  330. package/dist/modules/workflow/git/git-version.d.ts +34 -0
  331. package/dist/modules/workflow/git/git-version.d.ts.map +1 -0
  332. package/dist/modules/workflow/git/git-version.js +63 -0
  333. package/dist/modules/workflow/git/git-version.js.map +1 -0
  334. package/dist/modules/workflow/git/git.service.d.ts +427 -0
  335. package/dist/modules/workflow/git/git.service.d.ts.map +1 -0
  336. package/dist/modules/workflow/git/git.service.js +1987 -0
  337. package/dist/modules/workflow/git/git.service.js.map +1 -0
  338. package/dist/modules/workflow/git/mutex.d.ts +11 -0
  339. package/dist/modules/workflow/git/mutex.d.ts.map +1 -0
  340. package/dist/modules/workflow/git/mutex.js +23 -0
  341. package/dist/modules/workflow/git/mutex.js.map +1 -0
  342. package/dist/modules/workflow/git/pull-request.service.d.ts +124 -0
  343. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -0
  344. package/dist/modules/workflow/git/pull-request.service.js +328 -0
  345. package/dist/modules/workflow/git/pull-request.service.js.map +1 -0
  346. package/dist/modules/workflow/locking-filesystem.d.ts +133 -0
  347. package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -0
  348. package/dist/modules/workflow/locking-filesystem.js +400 -0
  349. package/dist/modules/workflow/locking-filesystem.js.map +1 -0
  350. package/dist/modules/workflow/pending-commits.service.d.ts +221 -0
  351. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -0
  352. package/dist/modules/workflow/pending-commits.service.js +364 -0
  353. package/dist/modules/workflow/pending-commits.service.js.map +1 -0
  354. package/dist/modules/workflow/pending-commits.worker.d.ts +148 -0
  355. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -0
  356. package/dist/modules/workflow/pending-commits.worker.js +245 -0
  357. package/dist/modules/workflow/pending-commits.worker.js.map +1 -0
  358. package/dist/modules/workflow/read-only-filesystem.d.ts +24 -0
  359. package/dist/modules/workflow/read-only-filesystem.d.ts.map +1 -0
  360. package/dist/modules/workflow/read-only-filesystem.js +39 -0
  361. package/dist/modules/workflow/read-only-filesystem.js.map +1 -0
  362. package/dist/modules/workflow/recovery-bot.d.ts +31 -0
  363. package/dist/modules/workflow/recovery-bot.d.ts.map +1 -0
  364. package/dist/modules/workflow/recovery-bot.js +64 -0
  365. package/dist/modules/workflow/recovery-bot.js.map +1 -0
  366. package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts +137 -0
  367. package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts.map +1 -0
  368. package/dist/modules/workflow/review-workflow/review-workflow.interface.js +2 -0
  369. package/dist/modules/workflow/review-workflow/review-workflow.interface.js.map +1 -0
  370. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts +26 -0
  371. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -0
  372. package/dist/modules/workflow/review-workflow/review-workflow.service.js +636 -0
  373. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -0
  374. package/dist/modules/workflow/sanitize-error.d.ts +29 -0
  375. package/dist/modules/workflow/sanitize-error.d.ts.map +1 -0
  376. package/dist/modules/workflow/sanitize-error.js +60 -0
  377. package/dist/modules/workflow/sanitize-error.js.map +1 -0
  378. package/dist/modules/workflow/session-ontology.policy.d.ts +52 -0
  379. package/dist/modules/workflow/session-ontology.policy.d.ts.map +1 -0
  380. package/dist/modules/workflow/session-ontology.policy.js +62 -0
  381. package/dist/modules/workflow/session-ontology.policy.js.map +1 -0
  382. package/dist/modules/workflow/session-ontology.service.d.ts +100 -0
  383. package/dist/modules/workflow/session-ontology.service.d.ts.map +1 -0
  384. package/dist/modules/workflow/session-ontology.service.js +143 -0
  385. package/dist/modules/workflow/session-ontology.service.js.map +1 -0
  386. package/dist/modules/workflow/workflow-hooks.d.ts +87 -0
  387. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -0
  388. package/dist/modules/workflow/workflow-hooks.js +35 -0
  389. package/dist/modules/workflow/workflow-hooks.js.map +1 -0
  390. package/dist/modules/workflow/workflow.errors.d.ts +171 -0
  391. package/dist/modules/workflow/workflow.errors.d.ts.map +1 -0
  392. package/dist/modules/workflow/workflow.errors.js +260 -0
  393. package/dist/modules/workflow/workflow.errors.js.map +1 -0
  394. package/dist/modules/workflow/workflow.routes.d.ts +26 -0
  395. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -0
  396. package/dist/modules/workflow/workflow.routes.js +804 -0
  397. package/dist/modules/workflow/workflow.routes.js.map +1 -0
  398. package/dist/modules/workflow/workflow.service.d.ts +385 -0
  399. package/dist/modules/workflow/workflow.service.d.ts.map +1 -0
  400. package/dist/modules/workflow/workflow.service.js +1155 -0
  401. package/dist/modules/workflow/workflow.service.js.map +1 -0
  402. package/dist/modules/workspace/bevel-ignore.d.ts +25 -0
  403. package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -0
  404. package/dist/modules/workspace/bevel-ignore.js +57 -0
  405. package/dist/modules/workspace/bevel-ignore.js.map +1 -0
  406. package/dist/modules/workspace/kb-seed.interface.d.ts +36 -0
  407. package/dist/modules/workspace/kb-seed.interface.d.ts.map +1 -0
  408. package/dist/modules/workspace/kb-seed.interface.js +2 -0
  409. package/dist/modules/workspace/kb-seed.interface.js.map +1 -0
  410. package/dist/modules/workspace/kb-seed.service.d.ts +70 -0
  411. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -0
  412. package/dist/modules/workspace/kb-seed.service.js +325 -0
  413. package/dist/modules/workspace/kb-seed.service.js.map +1 -0
  414. package/dist/modules/workspace/routine-write-policy.d.ts +49 -0
  415. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -0
  416. package/dist/modules/workspace/routine-write-policy.js +46 -0
  417. package/dist/modules/workspace/routine-write-policy.js.map +1 -0
  418. package/dist/modules/workspace/session-ontology.gate.d.ts +112 -0
  419. package/dist/modules/workspace/session-ontology.gate.d.ts.map +1 -0
  420. package/dist/modules/workspace/session-ontology.gate.js +161 -0
  421. package/dist/modules/workspace/session-ontology.gate.js.map +1 -0
  422. package/dist/modules/workspace/session-sink.d.ts +25 -0
  423. package/dist/modules/workspace/session-sink.d.ts.map +1 -0
  424. package/dist/modules/workspace/session-sink.js +8 -0
  425. package/dist/modules/workspace/session-sink.js.map +1 -0
  426. package/dist/modules/workspace/spill-store.d.ts +24 -0
  427. package/dist/modules/workspace/spill-store.d.ts.map +1 -0
  428. package/dist/modules/workspace/spill-store.js +87 -0
  429. package/dist/modules/workspace/spill-store.js.map +1 -0
  430. package/dist/modules/workspace/workspace.routes.d.ts +25 -0
  431. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -0
  432. package/dist/modules/workspace/workspace.routes.js +1046 -0
  433. package/dist/modules/workspace/workspace.routes.js.map +1 -0
  434. package/dist/modules/workspace/workspace.service.d.ts +339 -0
  435. package/dist/modules/workspace/workspace.service.d.ts.map +1 -0
  436. package/dist/modules/workspace/workspace.service.js +1221 -0
  437. package/dist/modules/workspace/workspace.service.js.map +1 -0
  438. package/dist/modules/workspace/workspace.tools.d.ts +19 -0
  439. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -0
  440. package/dist/modules/workspace/workspace.tools.js +767 -0
  441. package/dist/modules/workspace/workspace.tools.js.map +1 -0
  442. package/dist/shared/frontmatter-id.d.ts +35 -0
  443. package/dist/shared/frontmatter-id.d.ts.map +1 -0
  444. package/dist/shared/frontmatter-id.js +68 -0
  445. package/dist/shared/frontmatter-id.js.map +1 -0
  446. package/dist/shared/fs-walk.d.ts +9 -0
  447. package/dist/shared/fs-walk.d.ts.map +1 -0
  448. package/dist/shared/fs-walk.js +34 -0
  449. package/dist/shared/fs-walk.js.map +1 -0
  450. package/dist/shared/hash-email.d.ts +11 -0
  451. package/dist/shared/hash-email.d.ts.map +1 -0
  452. package/dist/shared/hash-email.js +14 -0
  453. package/dist/shared/hash-email.js.map +1 -0
  454. package/dist/shared/kb-layout.d.ts +38 -0
  455. package/dist/shared/kb-layout.d.ts.map +1 -0
  456. package/dist/shared/kb-layout.js +102 -0
  457. package/dist/shared/kb-layout.js.map +1 -0
  458. package/dist/shared/kb-layout.test.d.ts +2 -0
  459. package/dist/shared/kb-layout.test.d.ts.map +1 -0
  460. package/dist/shared/kb-layout.test.js +67 -0
  461. package/dist/shared/kb-layout.test.js.map +1 -0
  462. package/dist/shared/ssrf.d.ts +20 -0
  463. package/dist/shared/ssrf.d.ts.map +1 -0
  464. package/dist/shared/ssrf.js +89 -0
  465. package/dist/shared/ssrf.js.map +1 -0
  466. package/dist/shared/token-crypto.d.ts +16 -0
  467. package/dist/shared/token-crypto.d.ts.map +1 -0
  468. package/dist/shared/token-crypto.js +49 -0
  469. package/dist/shared/token-crypto.js.map +1 -0
  470. package/dist/shared/ttl-cache.d.ts +16 -0
  471. package/dist/shared/ttl-cache.d.ts.map +1 -0
  472. package/dist/shared/ttl-cache.js +27 -0
  473. package/dist/shared/ttl-cache.js.map +1 -0
  474. package/dist/shared/utcp-namespace.d.ts +46 -0
  475. package/dist/shared/utcp-namespace.d.ts.map +1 -0
  476. package/dist/shared/utcp-namespace.js +82 -0
  477. package/dist/shared/utcp-namespace.js.map +1 -0
  478. package/dist/version.d.ts +17 -0
  479. package/dist/version.d.ts.map +1 -0
  480. package/dist/version.js +19 -0
  481. package/dist/version.js.map +1 -0
  482. package/kb-template/.bevelignore +21 -0
  483. package/kb-template/Agents/.gitkeep +0 -0
  484. package/kb-template/Agents/README.md +38 -0
  485. package/kb-template/CLAUDE.md +419 -0
  486. package/kb-template/Data/.gitkeep +0 -0
  487. package/kb-template/Data/README.md +39 -0
  488. package/kb-template/KnowledgeBase/.gitkeep +0 -0
  489. package/kb-template/KnowledgeBase/README.md +37 -0
  490. package/kb-template/Pipelines/.gitkeep +0 -0
  491. package/kb-template/Pipelines/README.md +38 -0
  492. package/kb-template/Skills/.gitkeep +0 -0
  493. package/kb-template/Skills/README.md +32 -0
  494. package/kb-template/Tools/.gitkeep +0 -0
  495. package/kb-template/Tools/README.md +42 -0
  496. package/kb-template/access.md +36 -0
  497. package/migrations/0000_core_init.sql +243 -0
  498. package/migrations/meta/0000_snapshot.json +1480 -0
  499. package/migrations/meta/_journal.json +13 -0
  500. package/package.json +80 -0
  501. package/src/assets.ts +25 -0
  502. package/src/core/core-ports.ts +91 -0
  503. package/src/core/create-core-server.ts +338 -0
  504. package/src/core/create-core-services.ts +501 -0
  505. package/src/core-config.ts +257 -0
  506. package/src/index.ts +69 -0
  507. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -0
  508. package/src/modules/access/__tests__/access-control.read-batch.test.ts +132 -0
  509. package/src/modules/access/__tests__/access-control.service.test.ts +1011 -0
  510. package/src/modules/access/__tests__/access-mutation.service.test.ts +439 -0
  511. package/src/modules/access/__tests__/access-splice.test.ts +247 -0
  512. package/src/modules/access/__tests__/access.routes.revoke.test.ts +407 -0
  513. package/src/modules/access/__tests__/creator-access.test.ts +217 -0
  514. package/src/modules/access/__tests__/grant-sources.test.ts +204 -0
  515. package/src/modules/access/__tests__/kb-read-filter.test.ts +70 -0
  516. package/src/modules/access/__tests__/roles-admin.service.test.ts +469 -0
  517. package/src/modules/access/__tests__/roles-edit.test.ts +138 -0
  518. package/src/modules/access/__tests__/roles-yaml-guard.test.ts +70 -0
  519. package/src/modules/access/__tests__/roles.routes.test.ts +234 -0
  520. package/src/modules/access/access-control.interface.ts +383 -0
  521. package/src/modules/access/access-control.service.ts +1794 -0
  522. package/src/modules/access/access-errors.ts +60 -0
  523. package/src/modules/access/access-mutation.service.ts +369 -0
  524. package/src/modules/access/access-splice.ts +413 -0
  525. package/src/modules/access/access.routes.ts +894 -0
  526. package/src/modules/access/creator-access.ts +270 -0
  527. package/src/modules/access/kb-read-filter.ts +76 -0
  528. package/src/modules/access/roles-admin.service.ts +741 -0
  529. package/src/modules/access/roles-edit.ts +248 -0
  530. package/src/modules/access/roles-yaml-guard.ts +89 -0
  531. package/src/modules/admin/admin-access.routes.ts +29 -0
  532. package/src/modules/admin/admin-access.service.ts +38 -0
  533. package/src/modules/admin/admin.interface.ts +10 -0
  534. package/src/modules/auth/account-erasure.service.ts +183 -0
  535. package/src/modules/auth/auth.middleware.ts +78 -0
  536. package/src/modules/auth/auth.routes.ts +97 -0
  537. package/src/modules/auth/auth.service.ts +199 -0
  538. package/src/modules/code-mode/code-mode-names.ts +38 -0
  539. package/src/modules/code-mode/code-mode.tool.ts +117 -0
  540. package/src/modules/code-mode/index.ts +6 -0
  541. package/src/modules/database/connection.ts +15 -0
  542. package/src/modules/database/core-schema.ts +431 -0
  543. package/src/modules/database/migrate.ts +43 -0
  544. package/src/modules/database/schema.ts +11 -0
  545. package/src/modules/diff/__tests__/diff.routes.read-gate.test.ts +87 -0
  546. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -0
  547. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +184 -0
  548. package/src/modules/diff/__tests__/diff.service.test.ts +202 -0
  549. package/src/modules/diff/diff-paths.ts +9 -0
  550. package/src/modules/diff/diff.config.ts +12 -0
  551. package/src/modules/diff/diff.interface.ts +78 -0
  552. package/src/modules/diff/diff.routes.ts +277 -0
  553. package/src/modules/diff/diff.service.ts +508 -0
  554. package/src/modules/diff/line-diff.ts +79 -0
  555. package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +287 -0
  556. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +249 -0
  557. package/src/modules/mcp/__tests__/mcp-proxy-helpers.test.ts +253 -0
  558. package/src/modules/mcp/__tests__/mcp-session-store.test.ts +151 -0
  559. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +150 -0
  560. package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +92 -0
  561. package/src/modules/mcp/__tests__/mcp.service.test.ts +414 -0
  562. package/src/modules/mcp/mcp-auth.middleware.ts +126 -0
  563. package/src/modules/mcp/mcp-session-store.ts +162 -0
  564. package/src/modules/mcp/mcp.routes.ts +263 -0
  565. package/src/modules/mcp/mcp.service.ts +973 -0
  566. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +401 -0
  567. package/src/modules/mcp/oauth/oauth-consent.routes.ts +60 -0
  568. package/src/modules/mcp/oauth/oauth-state.ts +61 -0
  569. package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +277 -0
  570. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +206 -0
  571. package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +178 -0
  572. package/src/modules/secrets-vault/__tests__/scopes-covered.test.ts +101 -0
  573. package/src/modules/secrets-vault/__tests__/secrets-variable-loader.test.ts +54 -0
  574. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -0
  575. package/src/modules/secrets-vault/db-secrets-vault.service.ts +792 -0
  576. package/src/modules/secrets-vault/index.ts +25 -0
  577. package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +289 -0
  578. package/src/modules/secrets-vault/secrets-variable-loader.ts +104 -0
  579. package/src/modules/secrets-vault/secrets-vault.contract.ts +270 -0
  580. package/src/modules/secrets-vault/secrets-vault.routes.ts +526 -0
  581. package/src/modules/skills/__tests__/skills.service.test.ts +176 -0
  582. package/src/modules/skills/index.ts +10 -0
  583. package/src/modules/skills/skills.contract.ts +54 -0
  584. package/src/modules/skills/skills.routes.ts +38 -0
  585. package/src/modules/skills/skills.service.ts +245 -0
  586. package/src/modules/skills/skills.tools.ts +121 -0
  587. package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +358 -0
  588. package/src/modules/tool-auth/__tests__/internal-token.service.test.ts +79 -0
  589. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +88 -0
  590. package/src/modules/tool-auth/external-api-key.errors.ts +25 -0
  591. package/src/modules/tool-auth/external-api-key.interface.ts +99 -0
  592. package/src/modules/tool-auth/external-api-key.service.ts +209 -0
  593. package/src/modules/tool-auth/internal-token.service.ts +124 -0
  594. package/src/modules/tool-auth/llm-usage-meter.ts +19 -0
  595. package/src/modules/tool-auth/tool-auth.middleware.ts +220 -0
  596. package/src/modules/tool-helpers/__tests__/phase0-loopback-spike.test.ts +129 -0
  597. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +95 -0
  598. package/src/modules/tool-helpers/__tests__/tools-surface.test.ts +146 -0
  599. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +75 -0
  600. package/src/modules/tool-helpers/index.ts +18 -0
  601. package/src/modules/tool-helpers/tool-context.ts +98 -0
  602. package/src/modules/tool-helpers/tool-def.ts +73 -0
  603. package/src/modules/tool-helpers/tool-handler.ts +91 -0
  604. package/src/modules/tool-helpers/tool.contract.ts +85 -0
  605. package/src/modules/tool-helpers/validate-token.ts +50 -0
  606. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +200 -0
  607. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +499 -0
  608. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -0
  609. package/src/modules/tool-manuals/index.ts +12 -0
  610. package/src/modules/tool-manuals/tool-manuals.contract.ts +183 -0
  611. package/src/modules/tool-manuals/tool-manuals.routes.ts +110 -0
  612. package/src/modules/tool-manuals/tool-manuals.service.ts +781 -0
  613. package/src/modules/tool-manuals/tool-manuals.tools.ts +202 -0
  614. package/src/modules/tool-registry/__tests__/tool-registry.test.ts +64 -0
  615. package/src/modules/tool-registry/manual.routes.ts +87 -0
  616. package/src/modules/tool-registry/tool-registry.ts +55 -0
  617. package/src/modules/tool-registry/tool.contract.ts +52 -0
  618. package/src/modules/workflow/__tests__/event-bus.test.ts +322 -0
  619. package/src/modules/workflow/__tests__/file-change-notifier.test.ts +51 -0
  620. package/src/modules/workflow/__tests__/format-affected-owners.test.ts +67 -0
  621. package/src/modules/workflow/__tests__/locking-filesystem.test.ts +445 -0
  622. package/src/modules/workflow/__tests__/pending-commits.canonical.test.ts +36 -0
  623. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +243 -0
  624. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +308 -0
  625. package/src/modules/workflow/__tests__/sanitize-error.test.ts +67 -0
  626. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +62 -0
  627. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +200 -0
  628. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +232 -0
  629. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +380 -0
  630. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +340 -0
  631. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +192 -0
  632. package/src/modules/workflow/agent-tools/workflow.tools.ts +502 -0
  633. package/src/modules/workflow/event-bus.ts +262 -0
  634. package/src/modules/workflow/events.routes.ts +266 -0
  635. package/src/modules/workflow/file-change-notifier.ts +54 -0
  636. package/src/modules/workflow/file-lock.service.ts +269 -0
  637. package/src/modules/workflow/git/__tests__/branch-name.test.ts +86 -0
  638. package/src/modules/workflow/git/__tests__/branchAuthor.test.ts +80 -0
  639. package/src/modules/workflow/git/__tests__/clone-config.test.ts +15 -0
  640. package/src/modules/workflow/git/__tests__/git-test-helpers.ts +47 -0
  641. package/src/modules/workflow/git/__tests__/git-version.failure.test.ts +24 -0
  642. package/src/modules/workflow/git/__tests__/git-version.test.ts +49 -0
  643. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +389 -0
  644. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +124 -0
  645. package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +105 -0
  646. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +211 -0
  647. package/src/modules/workflow/git/__tests__/git.service.createBranch.test.ts +154 -0
  648. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +320 -0
  649. package/src/modules/workflow/git/__tests__/git.service.diffFileAtCommit.test.ts +202 -0
  650. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +453 -0
  651. package/src/modules/workflow/git/__tests__/git.service.list-branches.test.ts +198 -0
  652. package/src/modules/workflow/git/__tests__/git.service.listTrackedFiles.test.ts +125 -0
  653. package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +219 -0
  654. package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +145 -0
  655. package/src/modules/workflow/git/__tests__/git.service.pendingChanges.test.ts +240 -0
  656. package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +272 -0
  657. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +232 -0
  658. package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +101 -0
  659. package/src/modules/workflow/git/__tests__/git.service.status.test.ts +127 -0
  660. package/src/modules/workflow/git/__tests__/mutex.test.ts +78 -0
  661. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +225 -0
  662. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +101 -0
  663. package/src/modules/workflow/git/branch-name.ts +70 -0
  664. package/src/modules/workflow/git/clone-config.ts +71 -0
  665. package/src/modules/workflow/git/git-version.ts +70 -0
  666. package/src/modules/workflow/git/git.service.ts +2248 -0
  667. package/src/modules/workflow/git/mutex.ts +22 -0
  668. package/src/modules/workflow/git/pull-request.service.ts +447 -0
  669. package/src/modules/workflow/locking-filesystem.ts +489 -0
  670. package/src/modules/workflow/pending-commits.service.ts +444 -0
  671. package/src/modules/workflow/pending-commits.worker.ts +361 -0
  672. package/src/modules/workflow/read-only-filesystem.ts +54 -0
  673. package/src/modules/workflow/recovery-bot.ts +73 -0
  674. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +560 -0
  675. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +176 -0
  676. package/src/modules/workflow/review-workflow/__tests__/merge-gate.test.ts +258 -0
  677. package/src/modules/workflow/review-workflow/review-workflow.interface.ts +199 -0
  678. package/src/modules/workflow/review-workflow/review-workflow.service.ts +806 -0
  679. package/src/modules/workflow/sanitize-error.ts +62 -0
  680. package/src/modules/workflow/session-ontology.policy.ts +70 -0
  681. package/src/modules/workflow/session-ontology.service.ts +178 -0
  682. package/src/modules/workflow/workflow-hooks.ts +101 -0
  683. package/src/modules/workflow/workflow.errors.ts +285 -0
  684. package/src/modules/workflow/workflow.routes.ts +858 -0
  685. package/src/modules/workflow/workflow.service.ts +1555 -0
  686. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +213 -0
  687. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +68 -0
  688. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -0
  689. package/src/modules/workspace/__tests__/spill-store.test.ts +43 -0
  690. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +233 -0
  691. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +176 -0
  692. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +303 -0
  693. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +276 -0
  694. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +120 -0
  695. package/src/modules/workspace/__tests__/workspace.service.test.ts +565 -0
  696. package/src/modules/workspace/__tests__/workspace.tools.test.ts +504 -0
  697. package/src/modules/workspace/bevel-ignore.ts +62 -0
  698. package/src/modules/workspace/kb-seed.interface.ts +36 -0
  699. package/src/modules/workspace/kb-seed.service.ts +361 -0
  700. package/src/modules/workspace/routine-write-policy.ts +79 -0
  701. package/src/modules/workspace/session-ontology.gate.ts +189 -0
  702. package/src/modules/workspace/session-sink.ts +25 -0
  703. package/src/modules/workspace/spill-store.ts +87 -0
  704. package/src/modules/workspace/workspace.routes.ts +1114 -0
  705. package/src/modules/workspace/workspace.service.ts +1343 -0
  706. package/src/modules/workspace/workspace.tools.ts +885 -0
  707. package/src/shared/__tests__/frontmatter-id.test.ts +88 -0
  708. package/src/shared/__tests__/fs-walk.test.ts +42 -0
  709. package/src/shared/__tests__/ssrf.test.ts +53 -0
  710. package/src/shared/__tests__/utcp-namespace.test.ts +19 -0
  711. package/src/shared/frontmatter-id.ts +72 -0
  712. package/src/shared/fs-walk.ts +30 -0
  713. package/src/shared/hash-email.ts +14 -0
  714. package/src/shared/kb-layout.test.ts +88 -0
  715. package/src/shared/kb-layout.ts +101 -0
  716. package/src/shared/ssrf.ts +78 -0
  717. package/src/shared/token-crypto.ts +54 -0
  718. package/src/shared/ttl-cache.ts +27 -0
  719. package/src/shared/utcp-namespace.ts +88 -0
  720. package/src/version.ts +19 -0
@@ -0,0 +1,1114 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import express from 'express';
4
+ import type { AuthUser, IWorkflowService } from '@bevel-software/platform-shared';
5
+ import {
6
+ AGENTS_DIR,
7
+ DATA_DIR,
8
+ DEFAULT_BRANCH,
9
+ KNOWLEDGE_BASE_DIR,
10
+ KNOWLEDGE_DIR,
11
+ PIPELINES_DIR,
12
+ SKILLS_DIR,
13
+ TOOLS_DIR,
14
+ } from '@bevel-software/platform-shared';
15
+ import { branchForWorkspaceId, FolderTooLargeError, type ReadTreeFilter } from './workspace.service.js';
16
+ import type { WorkspaceService } from './workspace.service.js';
17
+ import type { AuthService } from '../auth/auth.service.js';
18
+ import type { IAccessControl } from '../access/access-control.interface.js';
19
+ import { resolveReadableMap, toKbRelative } from '../access/kb-read-filter.js';
20
+ import type { ICreatorAccess } from '../access/creator-access.js';
21
+ import { isRolesYamlPath, assertRolesYamlParsable } from '../access/roles-yaml-guard.js';
22
+ import type { WorkflowEventBus } from '../workflow/event-bus.js';
23
+ import { WorkflowDomainError } from '../workflow/workflow.errors.js';
24
+ import '../auth/auth.middleware.js'; // Express Request augmentation
25
+
26
+ const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
27
+
28
+ /**
29
+ * Workspaces are per-branch (PLAN §3). Any authenticated user can access
30
+ * any branch's workspace; coordination of concurrent edits lives in the
31
+ * file-lock service, not in giving each user their own clone. We therefore
32
+ * gate every handler on authentication only — there is no "this is my
33
+ * workspace" ownership check.
34
+ *
35
+ * Every file-mutating route on this surface — `PUT /file`, `DELETE /file`,
36
+ * `PATCH /file` (move), `POST /directory`, `POST /upload`, `POST /unzip` —
37
+ * routes its write through `withLock(path, op)`: acquire the per-file
38
+ * workflow lock, perform the op, then release. The release commits the
39
+ * file as a one-file change attributed to the user and pushes it to origin,
40
+ * exactly like the lock-aware filesystem the agent writes through. There
41
+ * is no separate "share" step; save and share are fused.
42
+ */
43
+ export function createWorkspaceRoutes(
44
+ workspaceService: WorkspaceService,
45
+ authService: AuthService,
46
+ workflowService: IWorkflowService,
47
+ eventBus: WorkflowEventBus,
48
+ accessControl: IAccessControl,
49
+ kbDirName: string,
50
+ creatorAccess: ICreatorAccess,
51
+ ): express.Router {
52
+ const router = express.Router();
53
+
54
+ function authenticated(
55
+ req: express.Request,
56
+ res: express.Response,
57
+ ): string | null {
58
+ if (!req.userId) {
59
+ res.status(401).json({ error: 'Unauthenticated' });
60
+ return null;
61
+ }
62
+ const id = req.params.id;
63
+ if (id !== undefined && typeof id !== 'string') {
64
+ res.status(400).json({ error: 'Invalid workspace id' });
65
+ return null;
66
+ }
67
+ return typeof id === 'string' ? id : '';
68
+ }
69
+
70
+ /**
71
+ * Resolve `req.userId` to the full `AuthUser` record. Mirrors the helper
72
+ * in `workflow.routes.ts` so file-mutation routes can pass the user into
73
+ * `acquireLock`/`releaseLock` (which need name + email for commit
74
+ * attribution). Returns `null` and writes the response if unauthenticated.
75
+ */
76
+ async function requireUser(
77
+ req: express.Request,
78
+ res: express.Response,
79
+ ): Promise<AuthUser | null> {
80
+ if (!req.userId) {
81
+ res.status(401).json({ error: 'Unauthenticated' });
82
+ return null;
83
+ }
84
+ try {
85
+ const user = await authService.getUserById(req.userId);
86
+ if (!user) {
87
+ res.status(401).json({ error: 'User not found' });
88
+ return null;
89
+ }
90
+ return user;
91
+ } catch (err) {
92
+ console.error('[workspace.routes] requireUser failed:', err);
93
+ res.status(500).json({ error: 'Internal server error' });
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Acquire the workflow lock for `(workspaceId, branch, targetPath)`, run
100
+ * `op`, then release. Release commits + pushes the file as a one-file
101
+ * change attributed to `user` — same pipeline the lock-aware filesystem
102
+ * uses for the agent. Contention surfaces immediately as a 409-style
103
+ * error rather than retrying: human-initiated routes should feel
104
+ * responsive, and the caller will see "locked by X" and can decide what
105
+ * to do next.
106
+ *
107
+ * The release runs in a `finally` so the op's own failure (e.g. validator
108
+ * 422, access denied, path traversal) still releases the lock — otherwise
109
+ * a single bad upload would hold a file hostage. When the op succeeded
110
+ * but the file wasn't actually dirty (e.g. mkdir of an already-populated
111
+ * dir), the release commits nothing and returns `null`.
112
+ */
113
+ async function withLock<T>(
114
+ workspaceId: string,
115
+ user: AuthUser,
116
+ targetPath: string,
117
+ op: () => Promise<T>,
118
+ /**
119
+ * Batch-mode option used by the recursive folder-delete branch: skip
120
+ * the per-file `fs-tree-changed` so the caller can emit a single
121
+ * tree-refresh signal at the end instead of N noisy ones. (The old
122
+ * `skipPush` option is gone — under the queue model commits +
123
+ * pushes happen out of band in the worker, not inline here.)
124
+ */
125
+ options?: { skipFsTreeEvent?: boolean },
126
+ ): Promise<T> {
127
+ const branch = branchForWorkspaceId(workspaceId);
128
+ // If the caller already holds the lock, do NOT acquire-and-release
129
+ // here. The editor's lock-aware path (frontend `saveAndRelease`)
130
+ // explicitly manages the lock lifecycle — acquire on Edit, release
131
+ // on Save — and calls PUT /file in between to persist bytes. If
132
+ // this route auto-released after the write, the subsequent explicit
133
+ // `releaseLockApi` would find the lock gone and 400. Detect that
134
+ // case by reading the lock first; when the caller is the holder,
135
+ // run the op without touching the lock and let the caller release
136
+ // it on their own schedule (which is what fuses commit + push into
137
+ // one atomic step the user explicitly triggered).
138
+ const existing = await workflowService.getLock(workspaceId, branch, targetPath);
139
+ if (existing && existing.holderUserId === user.id) {
140
+ const result = await op();
141
+ // Disk write landed but no commit yet (caller will release later,
142
+ // at which point releaseLock fires its own `file-changed` with a
143
+ // real `newSha`). Emit a null-sha file-changed now so any other
144
+ // session tailing this file refetches the latest bytes immediately
145
+ // instead of waiting for the eventual release.
146
+ eventBus.emit({
147
+ kind: 'file-changed',
148
+ workspaceId,
149
+ branch,
150
+ path: targetPath,
151
+ newSha: null,
152
+ byUserId: user.id,
153
+ byUserName: user.name,
154
+ });
155
+ // Also signal "the file tree may have changed" — covers create /
156
+ // delete / rename through this same path so sidebars refresh even
157
+ // when no specific open tab matches the path.
158
+ eventBus.emit({ kind: 'fs-tree-changed', workspaceId, branch });
159
+ return result;
160
+ }
161
+ const acquired = await workflowService.acquireLock(workspaceId, branch, targetPath, user);
162
+ if (!acquired.acquired) {
163
+ const holder = acquired.lock.holderName || 'another user';
164
+ const err: Error & { status?: number } = new Error(
165
+ `"${targetPath}" is being edited by ${holder}. Try again in a moment.`,
166
+ );
167
+ err.status = 409;
168
+ throw err;
169
+ }
170
+ // Two release modes, depending on whether the op succeeded:
171
+ //
172
+ // - op() FAILED → drop the lock WITHOUT enqueueing a commit. The
173
+ // op may have written partial bytes to disk before throwing
174
+ // (write that errored mid-stream, etc.). A normal `releaseLock`
175
+ // would enqueue a commit for whatever's on disk and the worker
176
+ // would silently persist that partial state as a real committed
177
+ // change. `releaseLockNoCommit` drops the lock row only —
178
+ // partial disk state stays where it is but never becomes a
179
+ // committed change with the user's name on it.
180
+ //
181
+ // - op() SUCCEEDED → release. The new releaseLock enqueues a
182
+ // pending-commit row (the actual `commitFile + push` runs out
183
+ // of band in the worker) and drops the lock immediately. We
184
+ // return success once disk + lock + enqueue are all done —
185
+ // the user-visible "save = share" guarantee is satisfied by
186
+ // the shared per-branch workspace, not by waiting on git.
187
+ let opSucceeded = false;
188
+ let result: T;
189
+ try {
190
+ result = await op();
191
+ opSucceeded = true;
192
+ } catch (err) {
193
+ try {
194
+ await workflowService.releaseLockNoCommit(workspaceId, branch, targetPath, user);
195
+ } catch (releaseErr) {
196
+ console.warn(
197
+ `[workspace.routes] releaseLockNoCommit failed after op error for "${targetPath}":`,
198
+ releaseErr instanceof Error ? releaseErr.message : releaseErr,
199
+ );
200
+ }
201
+ throw err;
202
+ }
203
+ if (!opSucceeded) {
204
+ // Unreachable — `opSucceeded` is set unconditionally after `op()`
205
+ // returns and the catch arm above always rethrows. Keeping the
206
+ // explicit branch makes the intent obvious to readers.
207
+ throw new Error('unreachable: opSucceeded false after try');
208
+ }
209
+ await workflowService.releaseLock(workspaceId, branch, targetPath, user);
210
+ // Signal sidebar / explorer to refresh even when no open tab matched
211
+ // the changed path (e.g. someone else just created a new file).
212
+ // `releaseLock` already fires `file-changed` for the path, but
213
+ // file-changed's frontend handler short-circuits when no matching tab
214
+ // exists. fs-tree-changed is the dedicated structural-change signal.
215
+ //
216
+ // The folder-delete batch suppresses this per-file emit and fires a
217
+ // single `fs-tree-changed` after the loop, cutting ~30 redundant tree
218
+ // refreshes down to 1 for a 30-file folder delete.
219
+ if (!options?.skipFsTreeEvent) {
220
+ eventBus.emit({ kind: 'fs-tree-changed', workspaceId, branch });
221
+ }
222
+ return result;
223
+ }
224
+
225
+ /**
226
+ * Map any thrown error to an HTTP response. Centralises the response
227
+ * shape so each route handler stays focused on its own logic.
228
+ * - `WorkflowDomainError` → its `.status` + `.payload`
229
+ * - Path traversal → 403
230
+ * - Invalid path / zip → 400 (Only .zip is also 400)
231
+ * - Could not read zip → 422
232
+ * - Anything else with a `.status` → that status
233
+ * - Default → 500
234
+ */
235
+ function sendError(res: express.Response, err: unknown): void {
236
+ if (err instanceof WorkflowDomainError) {
237
+ res.status(err.status).json({ error: err.message, ...(err.payload ?? {}) });
238
+ return;
239
+ }
240
+ const msg = err instanceof Error ? err.message : 'Unknown error';
241
+ const status = (err as { status?: number } | null)?.status;
242
+ if (typeof status === 'number') {
243
+ res.status(status).json({ error: msg });
244
+ return;
245
+ }
246
+ if (msg === 'Path traversal detected') {
247
+ res.status(403).json({ error: msg });
248
+ return;
249
+ }
250
+ if (msg.startsWith('Invalid path') || msg.startsWith('Only .zip')) {
251
+ res.status(400).json({ error: msg });
252
+ return;
253
+ }
254
+ if (msg.startsWith('Could not read zip file')) {
255
+ res.status(422).json({ error: msg });
256
+ return;
257
+ }
258
+ res.status(500).json({ error: msg });
259
+ }
260
+
261
+ // GET /api/workspace[?branch=<branch>]
262
+ // Bootstrap (or fetch) the workspace for a branch. Defaults to the
263
+ // platform's working branch when no `branch` query param is given —
264
+ // matches the previous "just give me a workspace" behavior except now
265
+ // the workspace is keyed by branch instead of by user.
266
+ router.get('/workspace', async (req, res) => {
267
+ try {
268
+ if (!req.userId) {
269
+ res.status(401).json({ error: 'Unauthenticated' });
270
+ return;
271
+ }
272
+ const user = await authService.getUserById(req.userId);
273
+ if (!user) {
274
+ res.status(401).json({ error: 'User not found' });
275
+ return;
276
+ }
277
+ const branch = typeof req.query.branch === 'string' && req.query.branch.length > 0
278
+ ? req.query.branch
279
+ : DEFAULT_BRANCH;
280
+ const workspace = await workspaceService.getOrCreateForBranch(branch);
281
+ // Bootstrap must use the same read-filtered tree as GET /workspace/:id/files.
282
+ // Otherwise the first sidebar render can expose restricted nodes until the
283
+ // next explicit tree refresh, and clicking one then correctly 403s.
284
+ const fileTree = await workspaceService.listFiles(
285
+ workspace.id,
286
+ buildTreeReadFilter(workspace.id, user.email),
287
+ );
288
+ res.json({ workspace, fileTree });
289
+ } catch (error) {
290
+ // Log the full stack so the next 500 isn't a guessing game — the
291
+ // bare `error.message` we returned before lost most diagnostic
292
+ // signal (cause chain, stack frames, error class).
293
+ console.error(
294
+ `[workspace.routes] GET /workspace failed branch=${req.query.branch ?? '(default)'}:`,
295
+ error instanceof Error ? error.stack ?? error.message : error,
296
+ );
297
+ const msg = error instanceof Error ? error.message : 'Unknown error';
298
+ res.status(500).json({ error: msg });
299
+ }
300
+ });
301
+
302
+ router.delete('/workspace/:id', async (req, res) => {
303
+ const id = authenticated(req, res);
304
+ if (id === null) return;
305
+ try {
306
+ await workspaceService.deleteWorkspace(id);
307
+ res.json({ status: 'deleted' });
308
+ } catch (error) {
309
+ const msg = error instanceof Error ? error.message : 'Unknown error';
310
+ res.status(500).json({ error: msg });
311
+ }
312
+ });
313
+
314
+ router.get('/workspace/:id/files', async (req, res) => {
315
+ const id = authenticated(req, res);
316
+ if (id === null) return;
317
+ const user = await requireUser(req, res);
318
+ if (!user) return;
319
+ try {
320
+ const fileTree = await workspaceService.listFiles(id, buildTreeReadFilter(id, user.email));
321
+ res.json(fileTree);
322
+ } catch (error) {
323
+ const msg = error instanceof Error ? error.message : 'Unknown error';
324
+ res.status(500).json({ error: msg });
325
+ }
326
+ });
327
+
328
+ // Every `.md` under the KB repo, keyed by repo-root path, in one response.
329
+ // The HTML renderer's `buildGraph()` bridge uses this instead of one fetch
330
+ // per file (the KB is thousands of files — per-file fetching exhausts the
331
+ // browser's connection pool: net::ERR_INSUFFICIENT_RESOURCES).
332
+ router.get('/workspace/:id/kb-files', async (req, res) => {
333
+ const id = authenticated(req, res);
334
+ if (id === null) return;
335
+ const user = await requireUser(req, res);
336
+ if (!user) return;
337
+ try {
338
+ const files = await workspaceService.readAllKbFiles(id);
339
+ // Keys are repo-relative KB-node paths — gate them with the FULL read
340
+ // check (per-node frontmatter honoured, matching the embed graph). The
341
+ // graph is fetched rarely, so the per-file cost is acceptable here. Drop
342
+ // every node the caller can't read; fail-closed on anything not allowed.
343
+ const keys = Object.keys(files);
344
+ const verdict = await accessControl.canReadBatch(id, user.email, keys);
345
+ const filtered: Record<string, string> = {};
346
+ for (const k of keys) {
347
+ if (verdict.get(k) === true) filtered[k] = files[k]!;
348
+ }
349
+ res.json({ files: filtered });
350
+ } catch (error) {
351
+ const msg = error instanceof Error ? error.message : 'Unknown error';
352
+ res.status(500).json({ error: msg });
353
+ }
354
+ });
355
+
356
+ // (The KB-graph read routes — `/workspace/:id/graph`, `/resolve-id`,
357
+ // `/resolve-path` — moved to `modules/kb-graph/kb-graph.routes.ts`: they
358
+ // are enterprise surfaces over the kb-graph service, mounted by the
359
+ // enterprise server extension. Same paths, so the frontend is unchanged.)
360
+
361
+ /**
362
+ * Gate a download request on the dedicated `Download` role from roles.yaml.
363
+ * Returns `true` if the request was authorised (the caller proceeds);
364
+ * returns `false` if a response was already sent (401 / 403 / 500) and the
365
+ * caller should bail. Centralised so the file and folder download routes
366
+ * can't drift on the check, the role name, or the error wording.
367
+ *
368
+ * Orthogonal to admin: a user listed in `Admin` does NOT implicitly get
369
+ * Download. The two roles are independent grants in roles.yaml.
370
+ *
371
+ * `hasRole` can throw `AccessConfigError` when `roles.yaml` / `access.md`
372
+ * fails to load (missing role refs, malformed YAML). Without a catch the
373
+ * async rejection bubbles past Express and the request hangs. Route it
374
+ * through `sendError` so the rich `WorkflowDomainError` payload (status +
375
+ * `accessConfigErrors`) surfaces to the caller — same shape the
376
+ * `/access/has-role` endpoint returns.
377
+ */
378
+ async function requireDownloadPermission(
379
+ req: express.Request,
380
+ res: express.Response,
381
+ workspaceId: string,
382
+ relativePath: string,
383
+ ): Promise<boolean> {
384
+ const user = await requireUser(req, res);
385
+ if (!user) return false;
386
+ let allowed: boolean;
387
+ try {
388
+ // `download` is a per-path verb in access.md, resolved via the same
389
+ // chain walk as `write`. Strip the kbDirName prefix so the resolver
390
+ // receives a repo-relative path (mirrors the lock-acquire gate's
391
+ // strip in workflow.service.ts's assertCanWriteAtPath).
392
+ const repoRelative = relativePath.startsWith(`${kbDirName}/`)
393
+ ? relativePath.slice(kbDirName.length + 1)
394
+ : relativePath;
395
+ allowed = await accessControl.canDownload(workspaceId, user.email, repoRelative);
396
+ } catch (err) {
397
+ sendError(res, err);
398
+ return false;
399
+ }
400
+ if (!allowed) {
401
+ res.status(403).json({ error: 'Download permission required' });
402
+ return false;
403
+ }
404
+ return true;
405
+ }
406
+
407
+ /**
408
+ * Gate a content read on the `read:` verb. Returns `true` if the caller may
409
+ * read `relativePath`; otherwise sends a 403 and returns `false`. Uses the
410
+ * FULL `canRead` (per-node frontmatter honoured) — this is a single-file
411
+ * check, so the per-file read is cheap. Maps the workspace-relative path to
412
+ * its KB-repo-relative form via the shared `toKbRelative`; a path outside the
413
+ * KB repo (reserved workspace files, the KB dir itself) carries no `read:`
414
+ * rules and is never gated — matching the agent tools and diff routes. `read`
415
+ * is default-deny: a KB path must have an effective read grant, such as
416
+ * `read: everyone`, a listed role/user, or an owner grant. Errors fail closed.
417
+ */
418
+ async function requireReadPermission(
419
+ req: express.Request,
420
+ res: express.Response,
421
+ workspaceId: string,
422
+ relativePath: string,
423
+ ): Promise<boolean> {
424
+ const user = await requireUser(req, res);
425
+ if (!user) return false;
426
+ const repoRelative = toKbRelative(relativePath, kbDirName);
427
+ if (repoRelative === null) return true; // non-KB path — no read rules apply
428
+ let allowed: boolean;
429
+ try {
430
+ allowed = await accessControl.canRead(workspaceId, user.email, repoRelative);
431
+ } catch (err) {
432
+ sendError(res, err);
433
+ return false;
434
+ }
435
+ if (!allowed) {
436
+ res.status(403).json({ error: `You don't have permission to read "${relativePath}".` });
437
+ return false;
438
+ }
439
+ return true;
440
+ }
441
+
442
+ /**
443
+ * Build the file-tree read filter for `userEmail`: a per-directory batched
444
+ * verdict over workspace-relative paths, backed by the FULL `canReadBatch`
445
+ * — folder `access.md` chain AND each node's own frontmatter, so the tree
446
+ * shows exactly what the content routes will let the user open (a
447
+ * frontmatter `read:` deny hides the file; a frontmatter grant makes it
448
+ * discoverable). The per-file frontmatter reads are memoized inside the
449
+ * access service (invalidated on commit/pull/switch), so repeat tree builds
450
+ * don't re-walk the disk. Returns undefined-free `ReadTreeFilter`;
451
+ * `listFiles(id)` with no filter is the unfiltered (pre-feature) behaviour
452
+ * for callers that don't gate.
453
+ */
454
+ function buildTreeReadFilter(workspaceId: string, userEmail: string): ReadTreeFilter {
455
+ return async (wsRelPaths) => {
456
+ const verdict = await resolveReadableMap(
457
+ (w, e, rels) => accessControl.canReadBatch(w, e, rels),
458
+ workspaceId,
459
+ userEmail,
460
+ kbDirName,
461
+ wsRelPaths,
462
+ );
463
+ // The structural top-level folders are always shown as folders, even to
464
+ // a user who can't read into them. Their existence isn't sensitive
465
+ // (every KB has them), and keeping them visible lets the explorer render
466
+ // its Knowledge/Skills section view instead of collapsing to an empty
467
+ // flat tree. `KnowledgeBase/`, `Data/`, `Agents/`, `Pipelines/`,
468
+ // `Skills/` + `Tools/` are the current layout; a ROOT-LEVEL `Knowledge/`
469
+ // is the legacy pre-split layout's knowledge root (kb-layout.ts calls it
470
+ // the neutral bucket) and gets the same treatment so legacy clones don't
471
+ // collapse. Only the folders themselves are forced visible — their
472
+ // contents stay gated by the verdict above.
473
+ const structuralRoots = new Set([
474
+ KNOWLEDGE_BASE_DIR,
475
+ DATA_DIR,
476
+ AGENTS_DIR,
477
+ PIPELINES_DIR,
478
+ SKILLS_DIR,
479
+ TOOLS_DIR,
480
+ KNOWLEDGE_DIR,
481
+ ]);
482
+ for (const wp of wsRelPaths) {
483
+ const rel = toKbRelative(wp, kbDirName);
484
+ if (rel !== null && structuralRoots.has(rel)) verdict.set(wp, true);
485
+ }
486
+ return verdict;
487
+ };
488
+ }
489
+
490
+ /**
491
+ * Land a `seed-access-md` creation-grant plan: merge the grant into the new
492
+ * directory's `access.md` through the same lock+commit cycle as any other
493
+ * save, then drop the resolver cache so the very next tree build sees the
494
+ * grant. The current bytes are re-read UNDER the lock and the grant spliced
495
+ * into them — never a blind overwrite, so a concurrent creator's just-landed
496
+ * grant on the same new directory survives. Runs BEFORE the creation itself
497
+ * so the explorer never shows-then-hides the new subtree; if the creation
498
+ * subsequently fails, the leftover is an empty new folder readable only by
499
+ * its creator. Best-effort by contract: a failure here logs and never
500
+ * blocks the creation.
501
+ */
502
+ async function seedCreatorAccessMd(
503
+ workspaceId: string,
504
+ user: AuthUser,
505
+ plan: { wsRelPath: string; apply: (current: string) => string },
506
+ ): Promise<void> {
507
+ try {
508
+ await withLock(workspaceId, user, plan.wsRelPath, async () => {
509
+ let current = '';
510
+ try {
511
+ current = await workspaceService.readFile(workspaceId, plan.wsRelPath);
512
+ } catch {
513
+ // Not there yet — the normal case for a brand-new directory.
514
+ }
515
+ const next = plan.apply(current);
516
+ if (next !== current) {
517
+ await workspaceService.writeFile(workspaceId, plan.wsRelPath, next);
518
+ }
519
+ });
520
+ creatorAccess.noteAccessFileWritten(workspaceId);
521
+ } catch (err) {
522
+ console.warn(
523
+ `[workspace.routes] creator access.md seed failed for "${plan.wsRelPath}":`,
524
+ err instanceof Error ? err.message : err,
525
+ );
526
+ }
527
+ }
528
+
529
+ router.get('/workspace/:id/file/raw', async (req, res) => {
530
+ const id = authenticated(req, res);
531
+ if (id === null) return;
532
+ const filePath = req.query.path as string;
533
+ if (!filePath) {
534
+ res.status(400).json({ error: 'path query parameter is required' });
535
+ return;
536
+ }
537
+ // `?download=1` flips this from inline-serve (used by PdfRenderer and
538
+ // the image renderers) to "save to disk" — and the save path is gated
539
+ // on per-path `download:` rules in access.md. The inline path stays
540
+ // open to any authenticated user so the PdfRenderer / image renderers
541
+ // keep working for everyone.
542
+ // Read gate first — inline serve (PdfRenderer/image renderers) was open to
543
+ // any authenticated user; now a node the caller can't read is 403'd whether
544
+ // or not `?download=1` is set. Download additionally needs the `download:` verb.
545
+ if (!(await requireReadPermission(req, res, id, filePath))) return;
546
+ const wantsDownload = req.query.download === '1';
547
+ if (wantsDownload) {
548
+ if (!(await requireDownloadPermission(req, res, id, filePath))) return;
549
+ }
550
+ try {
551
+ const buffer = await workspaceService.readFileBinary(id, filePath);
552
+ const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
553
+ const mimeTypes: Record<string, string> = {
554
+ '.png': 'image/png',
555
+ '.jpg': 'image/jpeg',
556
+ '.jpeg': 'image/jpeg',
557
+ '.gif': 'image/gif',
558
+ '.webp': 'image/webp',
559
+ '.svg': 'image/svg+xml',
560
+ '.bmp': 'image/bmp',
561
+ '.ico': 'image/x-icon',
562
+ '.pdf': 'application/pdf',
563
+ '.docx':
564
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
565
+ '.xlsx':
566
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
567
+ };
568
+ // SVG is active web content (it can carry <script>). Inline view
569
+ // already trusts the source (the workspace owns its bytes), but a
570
+ // saved-to-disk SVG that's later re-opened in a browser tab would
571
+ // execute its scripts under the file:// origin — so when the user
572
+ // explicitly downloads, force octet-stream to defang it.
573
+ const downloadMime = ext === '.svg' ? 'application/octet-stream' : (mimeTypes[ext] || 'application/octet-stream');
574
+ res.setHeader('Content-Type', wantsDownload ? downloadMime : (mimeTypes[ext] || 'application/octet-stream'));
575
+ // Block MIME-sniffing so a misdeclared file can't be promoted to
576
+ // active content by the browser.
577
+ res.setHeader('X-Content-Type-Options', 'nosniff');
578
+ if (wantsDownload) {
579
+ // RFC 5987 UTF-8 filename encoding so unicode + spaces round-trip;
580
+ // CR/LF stripped to block header injection via a crafted path.
581
+ const basename = (filePath.split('/').pop() || 'file').replace(/[\r\n]/g, '');
582
+ res.setHeader(
583
+ 'Content-Disposition',
584
+ `attachment; filename*=UTF-8''${encodeURIComponent(basename)}`,
585
+ );
586
+ }
587
+ res.send(buffer);
588
+ } catch (error) {
589
+ const msg = error instanceof Error ? error.message : 'Unknown error';
590
+ if (msg === 'Path traversal detected') {
591
+ res.status(403).json({ error: msg });
592
+ return;
593
+ }
594
+ res.status(404).json({ error: 'File not found' });
595
+ }
596
+ });
597
+
598
+ /**
599
+ * GET /workspace/:id/folder/zip?path=<folder>&download=1
600
+ *
601
+ * Build a zip of the folder (respecting the file tree's skip rules: no
602
+ * `.git/`, no `.gitkeep`, no `.bevelignore`d paths) and return it as an
603
+ * attachment. Gated on `download:` access on the folder path — same
604
+ * primitive as the file-download path. Entries inside the archive are
605
+ * prefixed with the folder name so unzipping produces `<folderName>/…`
606
+ * rather than spilling into the caller's working directory.
607
+ *
608
+ * `?download=1` is required to keep the URL shape consistent with the file
609
+ * route — a stray inline request would have no other meaningful behavior
610
+ * for a folder anyway.
611
+ */
612
+ router.get('/workspace/:id/folder/zip', async (req, res) => {
613
+ const id = authenticated(req, res);
614
+ if (id === null) return;
615
+ // Reject array-shaped queries (`?path=a&path=b` → ['a','b']) and
616
+ // non-string values explicitly — the old `as string` cast was a lie
617
+ // and would crash on the downstream `.split('/')`.
618
+ const rawPath = req.query.path;
619
+ if (typeof rawPath !== 'string') {
620
+ res.status(400).json({ error: 'path query parameter is required' });
621
+ return;
622
+ }
623
+ // Trim whitespace and strip trailing slashes — `foo/` would otherwise
624
+ // basename to `''` and produce a misleading `.zip` filename. Done
625
+ // before the empty check so `' / '` is also caught.
626
+ const folderPath = rawPath.trim().replace(/\/+$/, '');
627
+ if (!folderPath) {
628
+ res.status(400).json({ error: 'path query parameter is required' });
629
+ return;
630
+ }
631
+ if (req.query.download !== '1') {
632
+ res.status(400).json({ error: 'download=1 is required for folder zip downloads' });
633
+ return;
634
+ }
635
+ if (!(await requireDownloadPermission(req, res, id, folderPath))) return;
636
+ try {
637
+ const buffer = await workspaceService.createFolderZip(id, folderPath);
638
+ // `|| 'folder'` covers the edge case where `folderPath` itself was
639
+ // a single bare slash (`/`) that survived the trim — the service
640
+ // would still reject it as path traversal, but the basename
641
+ // default keeps the response sane if it ever gets here.
642
+ const basename = (folderPath.split('/').pop() || 'folder').replace(/[\r\n]/g, '');
643
+ const zipName = `${basename}.zip`;
644
+ res.setHeader('Content-Type', 'application/zip');
645
+ res.setHeader('X-Content-Type-Options', 'nosniff');
646
+ res.setHeader(
647
+ 'Content-Disposition',
648
+ `attachment; filename*=UTF-8''${encodeURIComponent(zipName)}`,
649
+ );
650
+ res.send(buffer);
651
+ } catch (error) {
652
+ if (error instanceof FolderTooLargeError) {
653
+ res.status(413).json({ error: error.message });
654
+ return;
655
+ }
656
+ const msg = error instanceof Error ? error.message : 'Unknown error';
657
+ if (msg === 'Path traversal detected') {
658
+ res.status(403).json({ error: msg });
659
+ return;
660
+ }
661
+ if (msg === 'Not a directory') {
662
+ res.status(400).json({ error: msg });
663
+ return;
664
+ }
665
+ // ENOENT / other fs errors → 404 (matches the file route's fallback).
666
+ res.status(404).json({ error: 'Folder not found' });
667
+ }
668
+ });
669
+
670
+ router.get('/workspace/:id/file', async (req, res) => {
671
+ const id = authenticated(req, res);
672
+ if (id === null) return;
673
+ const filePath = req.query.path as string;
674
+ if (!filePath) {
675
+ res.status(400).json({ error: 'path query parameter is required' });
676
+ return;
677
+ }
678
+ if (!(await requireReadPermission(req, res, id, filePath))) return;
679
+ try {
680
+ const content = await workspaceService.readFile(id, filePath);
681
+ // No-store: this endpoint is used by the live-refresh path after a
682
+ // teammate's save (file-changed SSE → refetch). A cached response
683
+ // would yield stale bytes and silently defeat the auto-refresh.
684
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
685
+ res.json({ content });
686
+ } catch (error) {
687
+ const msg = error instanceof Error ? error.message : 'Unknown error';
688
+ if (msg === 'Path traversal detected') {
689
+ res.status(403).json({ error: msg });
690
+ return;
691
+ }
692
+ res.status(404).json({ error: 'File not found' });
693
+ }
694
+ });
695
+
696
+ router.delete('/workspace/:id/file', async (req, res) => {
697
+ const id = authenticated(req, res);
698
+ if (id === null) return;
699
+ const filePath = req.query.path as string;
700
+ if (!filePath) {
701
+ res.status(400).json({ error: 'path query parameter is required' });
702
+ return;
703
+ }
704
+ const user = await requireUser(req, res);
705
+ if (!user) return;
706
+ try {
707
+ // Recursive dir delete decomposes into N per-file lock+commit cycles
708
+ // — each file lands as its own one-file change so the per-file
709
+ // commit-history invariant `GitService.commit` enforces still holds.
710
+ // What we DO batch is the push: instead of N round-trips to origin
711
+ // (the dominant cost — a 30-file folder previously took 30+s of
712
+ // sequential pushes), we run each release with `{ skipPush: true }`
713
+ // and call `pushBranch` once at the end. Per-file `fs-tree-changed`
714
+ // emits are also coalesced into a single end-of-batch event so the
715
+ // explorer doesn't refresh N times.
716
+ const workspaceDir = await workspaceService.getWorkspacePath(id);
717
+ const absolute = path.resolve(workspaceDir, filePath);
718
+ // Boundary check before any `fs.*` call. The single-file path
719
+ // bottoms out in `workspaceService.deleteFile` (which asserts the
720
+ // boundary itself), but this fast-path resolves the absolute
721
+ // ourselves and then `fs.stat` / `fs.rm` / `enumerateFilesUnder`
722
+ // it. Without this guard, a `../escape` `filePath` would let the
723
+ // dir-delete branch operate on directories outside the workspace.
724
+ const workspaceRoot = path.resolve(workspaceDir);
725
+ if (absolute !== workspaceRoot && !absolute.startsWith(workspaceRoot + path.sep)) {
726
+ const err: Error & { status?: number } = new Error('Path traversal detected');
727
+ err.status = 403;
728
+ throw err;
729
+ }
730
+ let stat: { isDirectory: () => boolean } | null = null;
731
+ try {
732
+ stat = await fs.stat(absolute);
733
+ } catch {
734
+ // Not on disk — let workspaceService.deleteFile return its own 404.
735
+ }
736
+ if (stat?.isDirectory()) {
737
+ const filesInDir = await enumerateFilesUnder(absolute, workspaceDir);
738
+ const branch = branchForWorkspaceId(id);
739
+ for (const relFile of filesInDir) {
740
+ await withLock(
741
+ id,
742
+ user,
743
+ relFile,
744
+ async () => {
745
+ await workspaceService.deleteFile(id, relFile);
746
+ },
747
+ { skipFsTreeEvent: true },
748
+ );
749
+ }
750
+ // No explicit push here — each per-file release enqueues a
751
+ // pending-commits row, and the background worker drains them
752
+ // (commit + push) on its own schedule. The N round-trips that
753
+ // the old skipPush+pushBranch pattern collapsed into one happen
754
+ // serially in the worker; user perception is unchanged because
755
+ // the disk-side delete is what other sessions see via
756
+ // `fs-tree-changed`.
757
+ // Sweep the now-empty directory subtree off disk. Git doesn't track
758
+ // empty folders, so there's nothing to commit; this is just disk
759
+ // hygiene so the file tree stops showing the empty containers.
760
+ //
761
+ // This MUST recurse: a folder that held *subfolders* still has those
762
+ // (now-empty) subdirectory shells on disk after the per-file deletes,
763
+ // so a single non-recursive `rmdir(absolute)` would see a non-empty
764
+ // dir and bail — leaving the folder visible in the tree and looking
765
+ // undeletable (BEVA-132). `removeEmptyDirs` walks bottom-up and only
766
+ // removes dirs that are *actually empty* at the moment it visits them,
767
+ // so a concurrent writer's new file (and its parent chain) is
768
+ // preserved — the same safety property the old non-recursive check had.
769
+ try {
770
+ await removeEmptyDirs(absolute);
771
+ } catch (rmErr) {
772
+ // Directory already gone (raced delete), or a concurrent writer
773
+ // repopulated it. Either way, skip removal — the per-file deletes
774
+ // are what's load-bearing.
775
+ console.warn(
776
+ `[workspace.routes] dir cleanup skipped for "${filePath}":`,
777
+ rmErr instanceof Error ? rmErr.message : rmErr,
778
+ );
779
+ }
780
+ // Single tree-refresh signal for the whole batch (we suppressed
781
+ // the per-file ones via `skipFsTreeEvent`).
782
+ eventBus.emit({ kind: 'fs-tree-changed', workspaceId: id, branch });
783
+ res.json({ status: 'deleted', count: filesInDir.length });
784
+ return;
785
+ }
786
+ await withLock(id, user, filePath, () => workspaceService.deleteFile(id, filePath));
787
+ res.json({ status: 'deleted' });
788
+ } catch (err) {
789
+ sendError(res, err);
790
+ }
791
+ });
792
+
793
+ router.patch('/workspace/:id/file', async (req, res) => {
794
+ const id = authenticated(req, res);
795
+ if (id === null) return;
796
+ const { oldPath, newPath } = req.body as { oldPath?: string; newPath?: string };
797
+ if (!oldPath || !newPath) {
798
+ res.status(400).json({ error: 'oldPath and newPath are required in body' });
799
+ return;
800
+ }
801
+ const user = await requireUser(req, res);
802
+ if (!user) return;
803
+ try {
804
+ // Move = rename on disk + commit on both sides. We lock-and-release
805
+ // the destination first (commits the new file's appearance), then
806
+ // lock-and-release the source path (commits its deletion). Two
807
+ // commits rather than one — git's rename detection only kicks in
808
+ // when both delete and create are staged together, and the current
809
+ // `commitFile` is single-path. Lossy w.r.t. rename history, but
810
+ // correct: the file ends up where it should and history shows both
811
+ // ends of the move.
812
+ // Acquire BOTH locks before the move so no other writer can grab
813
+ // the source path under us during the rename window. We hold them
814
+ // simultaneously by nesting `withLock` calls. The lock order is
815
+ // lexical (the same deterministic order used in
816
+ // `LockingFilesystem.moveFile`) so two concurrent moves on the
817
+ // same pair of paths can never deadlock by acquiring in opposite
818
+ // orders. The actual move runs inside the innermost callback;
819
+ // the unwind commits each side's release in reverse order, which
820
+ // produces two single-file commits (one create, one delete) —
821
+ // not a single merge-style rename commit, but git's log/blame
822
+ // rename detection still groups them visually after the fact.
823
+ const [firstLock, secondLock] = oldPath < newPath ? [oldPath, newPath] : [newPath, oldPath];
824
+ await withLock(id, user, firstLock, () =>
825
+ withLock(id, user, secondLock, () =>
826
+ workspaceService.moveEntry(id, oldPath, newPath),
827
+ ),
828
+ );
829
+ res.json({ status: 'moved' });
830
+ } catch (err) {
831
+ sendError(res, err);
832
+ }
833
+ });
834
+
835
+ router.put('/workspace/:id/file', async (req, res) => {
836
+ const id = authenticated(req, res);
837
+ if (id === null) return;
838
+ const filePath = req.query.path as string;
839
+ if (!filePath) {
840
+ res.status(400).json({ error: 'path query parameter is required' });
841
+ return;
842
+ }
843
+ const { content } = req.body as { content?: string };
844
+ if (content === undefined) {
845
+ res.status(400).json({ error: 'content is required in body' });
846
+ return;
847
+ }
848
+ const user = await requireUser(req, res);
849
+ if (!user) return;
850
+ try {
851
+ // Refuse a hand-edit that would leave roles.yaml unparseable BEFORE any
852
+ // byte hits disk — a broken roles.yaml is an app-wide admin lockout
853
+ // (loadModel hard-throws). The dedicated Roles & Members surface has its
854
+ // own validate gate; this covers the raw-text editor path.
855
+ if (isRolesYamlPath(filePath, kbDirName)) assertRolesYamlParsable(content);
856
+ // Creator read grant: a brand-new file at a spot whose access chain
857
+ // doesn't grant the creator `read` would vanish from their own explorer
858
+ // (read is default-deny). Plan BEFORE the write: a new subtree gets its
859
+ // access.md seeded first; a loose .md carries the grant in its own
860
+ // frontmatter as part of this same single write.
861
+ const plan = await creatorAccess.planForCreate(id, user, filePath, 'file');
862
+ if (plan?.kind === 'seed-access-md') await seedCreatorAccessMd(id, user, plan);
863
+ const toWrite = plan?.kind === 'frontmatter' ? plan.apply(content) : content;
864
+ await withLock(id, user, filePath, () => workspaceService.writeFile(id, filePath, toWrite));
865
+ res.json({ status: 'written' });
866
+ } catch (err) {
867
+ sendError(res, err);
868
+ }
869
+ });
870
+
871
+ router.post('/workspace/:id/directory', async (req, res) => {
872
+ const id = authenticated(req, res);
873
+ if (id === null) return;
874
+ const { path: dirPath, defer } = req.body as { path?: string; defer?: boolean };
875
+ if (!dirPath) {
876
+ res.status(400).json({ error: 'path is required in body' });
877
+ return;
878
+ }
879
+ const user = await requireUser(req, res);
880
+ if (!user) return;
881
+ try {
882
+ // Creator read grant: a new folder in an unreadable spot gets its own
883
+ // (or its topmost new ancestor's) access.md seeded FIRST, naming the
884
+ // creator under `read:` — otherwise the default-deny tree filter hides
885
+ // the folder from its own creator the moment it appears. Seeding first
886
+ // also makes the dir non-empty, so the .gitkeep below is skipped and
887
+ // the folder's first committed file IS its access.md.
888
+ const plan = await creatorAccess.planForCreate(id, user, dirPath, 'dir');
889
+ if (plan?.kind === 'seed-access-md') await seedCreatorAccessMd(id, user, plan);
890
+ // `createDirectory` makes the dir and, if it ends up empty, drops a
891
+ // `.gitkeep` so git can track the otherwise-untracked folder. The
892
+ // lock-and-release on `<dir>/.gitkeep` commits whatever landed; if
893
+ // the dir was already populated and no .gitkeep was created, the
894
+ // release sees nothing dirty for that path and is a no-op.
895
+ //
896
+ // `defer: true` (folder-upload empty-dir pass) still skips the per-call
897
+ // `fs-tree-changed` so the bulk caller can emit one combined refresh
898
+ // after the burst. Per-file pushes are gone everywhere — the
899
+ // pending-commits worker drains commits out of band — so the only
900
+ // remaining option is the tree-event suppression.
901
+ const gitkeepPath = dirPath.endsWith('/') ? `${dirPath}.gitkeep` : `${dirPath}/.gitkeep`;
902
+ await withLock(
903
+ id,
904
+ user,
905
+ gitkeepPath,
906
+ async () => {
907
+ await workspaceService.createDirectory(id, dirPath);
908
+ },
909
+ defer === true ? { skipFsTreeEvent: true } : undefined,
910
+ );
911
+ res.json({ status: 'created' });
912
+ } catch (err) {
913
+ sendError(res, err);
914
+ }
915
+ });
916
+
917
+ router.post('/workspace/:id/unzip', async (req, res) => {
918
+ const id = authenticated(req, res);
919
+ if (id === null) return;
920
+ const body = (req.body ?? {}) as Record<string, unknown>;
921
+ const zipPath = body.path;
922
+ const destination = body.destination;
923
+ if (typeof zipPath !== 'string' || zipPath.length === 0) {
924
+ res.status(400).json({ error: 'path is required in body and must be a string' });
925
+ return;
926
+ }
927
+ if (destination !== undefined && (typeof destination !== 'string' || destination.length === 0)) {
928
+ res.status(400).json({ error: 'destination, if provided, must be a non-empty string' });
929
+ return;
930
+ }
931
+ const user = await requireUser(req, res);
932
+ if (!user) return;
933
+ try {
934
+ // Extract first (all files land on disk), then sweep each extracted
935
+ // file through a lock+release so it commits + pushes as its own
936
+ // one-file change. Per-file commits mean the validator runs N times
937
+ // on large archives — fine for the typical "few files" drop, slow
938
+ // but correct for a 100-file zip. If a single file's release fails
939
+ // (e.g. validator 422), the loop stops there so the user sees the
940
+ // first concrete problem rather than a list of N similar failures.
941
+ const result = await workspaceService.unzipFile(id, zipPath, destination);
942
+ for (const relFile of result.extracted) {
943
+ await withLock(id, user, relFile, async () => {
944
+ // The file is already on disk from the unzip; the release commits +
945
+ // pushes it. If the extraction landed a markdown node the creator
946
+ // can't read (default-deny chains), splice their read grant into
947
+ // its frontmatter first so the committed change already carries it.
948
+ // Best-effort: a failed grant write must not throw — withLock's
949
+ // failure path releases WITHOUT committing and would discard the
950
+ // extracted file from disk, turning a cosmetic grant failure into
951
+ // data loss.
952
+ try {
953
+ const granted = await creatorAccess.grantInExtractedFile(id, user, relFile);
954
+ if (granted !== null) await workspaceService.writeFile(id, relFile, granted);
955
+ } catch (err) {
956
+ console.warn(
957
+ `[workspace.routes] creator grant on extracted "${relFile}" failed:`,
958
+ err instanceof Error ? err.message : err,
959
+ );
960
+ }
961
+ });
962
+ }
963
+ res.json(result);
964
+ } catch (err) {
965
+ sendError(res, err);
966
+ }
967
+ });
968
+
969
+ router.post('/workspace/:id/upload', async (req, res) => {
970
+ const id = authenticated(req, res);
971
+ if (id === null) return;
972
+ const filePath = req.query.path as string;
973
+ if (!filePath) {
974
+ res.status(400).json({ error: 'path query parameter is required' });
975
+ return;
976
+ }
977
+ // `?defer=true` is the bulk-upload caller's signal to suppress the
978
+ // per-file `fs-tree-changed` SSE — the caller emits one combined
979
+ // refresh at end-of-burst via `POST /flush`. Pushing isn't deferred
980
+ // anywhere anymore; every successful release enqueues a pending-commit
981
+ // row and the worker drains commits + pushes out of band.
982
+ const defer = req.query.defer === 'true';
983
+ const declaredSize = parseInt(req.headers['content-length'] ?? '', 10);
984
+ if (declaredSize > MAX_UPLOAD_BYTES) {
985
+ res.status(413).json({ error: `File exceeds ${MAX_UPLOAD_BYTES} byte limit` });
986
+ return;
987
+ }
988
+ const user = await requireUser(req, res);
989
+ if (!user) return;
990
+
991
+ try {
992
+ const chunks: Buffer[] = [];
993
+ let totalBytes = 0;
994
+ for await (const chunk of req) {
995
+ const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
996
+ totalBytes += buf.length;
997
+ if (totalBytes > MAX_UPLOAD_BYTES) {
998
+ res.status(413).json({ error: `File exceeds ${MAX_UPLOAD_BYTES} byte limit` });
999
+ return;
1000
+ }
1001
+ chunks.push(buf);
1002
+ }
1003
+ const data = Buffer.concat(chunks);
1004
+ // Creator read grant, mirroring PUT /file: seed a new subtree's
1005
+ // access.md first, or fold the grant into an uploaded markdown file's
1006
+ // frontmatter. Binary uploads into a pre-existing unreadable folder
1007
+ // can't carry a per-file grant (no frontmatter) and proceed ungranted.
1008
+ const plan = await creatorAccess.planForCreate(id, user, filePath, 'file');
1009
+ if (plan?.kind === 'seed-access-md') await seedCreatorAccessMd(id, user, plan);
1010
+ const toWrite =
1011
+ plan?.kind === 'frontmatter'
1012
+ ? Buffer.from(plan.apply(data.toString('utf8')), 'utf8')
1013
+ : data;
1014
+ await withLock(
1015
+ id,
1016
+ user,
1017
+ filePath,
1018
+ () => workspaceService.writeFileBinary(id, filePath, toWrite),
1019
+ defer ? { skipFsTreeEvent: true } : undefined,
1020
+ );
1021
+ res.json({ status: 'uploaded' });
1022
+ } catch (err) {
1023
+ sendError(res, err);
1024
+ }
1025
+ });
1026
+
1027
+ // `POST /workspace/:id/flush` — end-of-batch checkpoint for folder
1028
+ // uploads (and any future batch caller using `defer: true`). Under the
1029
+ // pending-commits queue, the "push everything we just committed" job
1030
+ // happens out of band in the worker, so this route's only remaining
1031
+ // responsibility is to emit the single combined `fs-tree-changed`
1032
+ // event the bulk caller suppressed per file.
1033
+ router.post('/workspace/:id/flush', async (req, res) => {
1034
+ const id = authenticated(req, res);
1035
+ if (id === null) return;
1036
+ const user = await requireUser(req, res);
1037
+ if (!user) return;
1038
+ try {
1039
+ const branch = branchForWorkspaceId(id);
1040
+ eventBus.emit({ kind: 'fs-tree-changed', workspaceId: id, branch });
1041
+ res.json({ status: 'flushed' });
1042
+ } catch (err) {
1043
+ sendError(res, err);
1044
+ }
1045
+ });
1046
+
1047
+ return router;
1048
+ }
1049
+
1050
+ /**
1051
+ * Enumerate every file (not directory) under `absoluteDir`, returned as
1052
+ * workspace-relative POSIX paths. Used by the DELETE handler to decompose
1053
+ * a recursive directory delete into per-file lock+release cycles so each
1054
+ * deletion lands as its own one-file change.
1055
+ *
1056
+ * Skips `.git` to avoid trying to commit the internal git index when a
1057
+ * caller targets it accidentally. Returns paths in stable lexical order
1058
+ * for predictable commit sequencing.
1059
+ */
1060
+ async function enumerateFilesUnder(absoluteDir: string, workspaceDir: string): Promise<string[]> {
1061
+ const out: string[] = [];
1062
+ async function walk(dir: string): Promise<void> {
1063
+ let entries: import('node:fs').Dirent[];
1064
+ try {
1065
+ entries = await fs.readdir(dir, { withFileTypes: true });
1066
+ } catch {
1067
+ return;
1068
+ }
1069
+ entries.sort((a, b) => a.name.localeCompare(b.name));
1070
+ for (const entry of entries) {
1071
+ if (entry.name === '.git' && entry.isDirectory()) continue;
1072
+ const child = path.join(dir, entry.name);
1073
+ if (entry.isDirectory()) {
1074
+ await walk(child);
1075
+ } else {
1076
+ out.push(path.relative(workspaceDir, child).replace(/\\/g, '/'));
1077
+ }
1078
+ }
1079
+ }
1080
+ await walk(absoluteDir);
1081
+ return out;
1082
+ }
1083
+
1084
+ /**
1085
+ * Recursively remove empty directories under `absoluteDir`, bottom-up, then
1086
+ * remove `absoluteDir` itself if it ends up empty. A directory is removed only
1087
+ * if it contains nothing at the moment it's visited, so any file a concurrent
1088
+ * writer dropped in mid-delete — and every parent directory on its path —
1089
+ * survives. `.git` is left alone. Used after a recursive folder delete to
1090
+ * sweep the leftover empty-folder shells off disk so the file tree (which
1091
+ * lists on-disk directories, not just tracked files) stops showing the
1092
+ * deleted container.
1093
+ */
1094
+ async function removeEmptyDirs(absoluteDir: string): Promise<void> {
1095
+ let entries: import('node:fs').Dirent[];
1096
+ try {
1097
+ entries = await fs.readdir(absoluteDir, { withFileTypes: true });
1098
+ } catch {
1099
+ // Already gone (raced delete) — nothing to do.
1100
+ return;
1101
+ }
1102
+ for (const entry of entries) {
1103
+ if (entry.name === '.git' && entry.isDirectory()) continue;
1104
+ if (entry.isDirectory()) {
1105
+ await removeEmptyDirs(path.join(absoluteDir, entry.name));
1106
+ }
1107
+ }
1108
+ // Re-read after pruning children: a subdir we just emptied now lets this
1109
+ // dir become removable too. Any surviving file (or `.git`) keeps it.
1110
+ const remaining = await fs.readdir(absoluteDir);
1111
+ if (remaining.length === 0) {
1112
+ await fs.rmdir(absoluteDir);
1113
+ }
1114
+ }