@bevel-software/platform-core-backend 0.22.0 → 0.23.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 (325) hide show
  1. package/dist/core/core-ports.d.ts +11 -1
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts +3 -3
  5. package/dist/core/create-core-server.d.ts.map +1 -1
  6. package/dist/core/create-core-server.js +30 -11
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +9 -2
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +112 -23
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core/lifecycle.d.ts +34 -1
  13. package/dist/core/lifecycle.d.ts.map +1 -1
  14. package/dist/core/lifecycle.js +89 -13
  15. package/dist/core/lifecycle.js.map +1 -1
  16. package/dist/core-config.d.ts +0 -12
  17. package/dist/core-config.d.ts.map +1 -1
  18. package/dist/core-config.js +11 -13
  19. package/dist/core-config.js.map +1 -1
  20. package/dist/index.d.ts +3 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/modules/access/access-control.interface.d.ts +42 -12
  25. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  26. package/dist/modules/access/access-control.service.d.ts +67 -10
  27. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-control.service.js +214 -35
  29. package/dist/modules/access/access-control.service.js.map +1 -1
  30. package/dist/modules/access/access.routes.d.ts.map +1 -1
  31. package/dist/modules/access/access.routes.js +13 -15
  32. package/dist/modules/access/access.routes.js.map +1 -1
  33. package/dist/modules/auth/account-admission.d.ts +58 -7
  34. package/dist/modules/auth/account-admission.d.ts.map +1 -1
  35. package/dist/modules/auth/account-admission.js +44 -1
  36. package/dist/modules/auth/account-admission.js.map +1 -1
  37. package/dist/modules/auth/account.routes.d.ts +1 -1
  38. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  39. package/dist/modules/auth/account.routes.js +61 -5
  40. package/dist/modules/auth/account.routes.js.map +1 -1
  41. package/dist/modules/auth/auth.middleware.d.ts +1 -1
  42. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  43. package/dist/modules/auth/auth.middleware.js +18 -7
  44. package/dist/modules/auth/auth.middleware.js.map +1 -1
  45. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  46. package/dist/modules/auth/auth.routes.js +8 -0
  47. package/dist/modules/auth/auth.routes.js.map +1 -1
  48. package/dist/modules/auth/auth.service.d.ts +70 -2
  49. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  50. package/dist/modules/auth/auth.service.js +176 -7
  51. package/dist/modules/auth/auth.service.js.map +1 -1
  52. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  53. package/dist/modules/auth/oidc-auth-provider.js +7 -2
  54. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  55. package/dist/modules/database/core-schema.d.ts +41 -24
  56. package/dist/modules/database/core-schema.d.ts.map +1 -1
  57. package/dist/modules/database/core-schema.js +35 -27
  58. package/dist/modules/database/core-schema.js.map +1 -1
  59. package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
  60. package/dist/modules/kb-fs/branch-name.js +12 -2
  61. package/dist/modules/kb-fs/branch-name.js.map +1 -1
  62. package/dist/modules/kb-fs/repo-path.d.ts +11 -16
  63. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  64. package/dist/modules/kb-fs/repo-path.js +79 -0
  65. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  66. package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
  67. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
  68. package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
  69. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
  70. package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
  71. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
  72. package/dist/modules/kb-sync/sync-auth.js +1 -1
  73. package/dist/modules/kb-sync/sync-auth.js.map +1 -1
  74. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  75. package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
  76. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  77. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  78. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
  79. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  80. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  81. package/dist/modules/settings/deployment-settings.service.js +8 -3
  82. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  83. package/dist/modules/settings/setup.routes.d.ts +52 -1
  84. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  85. package/dist/modules/settings/setup.routes.js +207 -19
  86. package/dist/modules/settings/setup.routes.js.map +1 -1
  87. package/dist/modules/skills/skills.contract.d.ts +90 -18
  88. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  89. package/dist/modules/skills/skills.contract.js +4 -1
  90. package/dist/modules/skills/skills.contract.js.map +1 -1
  91. package/dist/modules/skills/skills.service.d.ts +98 -9
  92. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  93. package/dist/modules/skills/skills.service.js +239 -37
  94. package/dist/modules/skills/skills.service.js.map +1 -1
  95. package/dist/modules/skills/skills.tools.d.ts +7 -0
  96. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  97. package/dist/modules/skills/skills.tools.js +71 -7
  98. package/dist/modules/skills/skills.tools.js.map +1 -1
  99. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  100. package/dist/modules/tool-auth/external-api-key.service.js +4 -2
  101. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  102. package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
  103. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
  104. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  105. package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
  106. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  107. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
  108. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  109. package/dist/modules/tool-helpers/tool-handler.js +25 -4
  110. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  111. package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
  112. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  113. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  114. package/dist/modules/tool-helpers/validate-token.js +1 -1
  115. package/dist/modules/tool-helpers/validate-token.js.map +1 -1
  116. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
  117. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
  118. package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
  119. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
  120. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  121. package/dist/modules/workflow/agent-tools/workflow.tools.js +75 -35
  122. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  123. package/dist/modules/workflow/file-lock.service.d.ts +24 -0
  124. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  125. package/dist/modules/workflow/file-lock.service.js +30 -0
  126. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  127. package/dist/modules/workflow/git/git.service.d.ts +69 -0
  128. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  129. package/dist/modules/workflow/git/git.service.js +153 -2
  130. package/dist/modules/workflow/git/git.service.js.map +1 -1
  131. package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
  132. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  133. package/dist/modules/workflow/pending-commits.service.js +55 -0
  134. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  135. package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
  136. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
  137. package/dist/modules/workflow/workflow-hooks.js +16 -1
  138. package/dist/modules/workflow/workflow-hooks.js.map +1 -1
  139. package/dist/modules/workflow/workflow.service.d.ts +60 -6
  140. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  141. package/dist/modules/workflow/workflow.service.js +111 -4
  142. package/dist/modules/workflow/workflow.service.js.map +1 -1
  143. package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
  144. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
  145. package/dist/modules/workspace/agent-access.gate.js +123 -0
  146. package/dist/modules/workspace/agent-access.gate.js.map +1 -0
  147. package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
  148. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  149. package/dist/modules/workspace/routine-write-policy.js +5 -6
  150. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  151. package/dist/modules/workspace/session-sink.d.ts +5 -5
  152. package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
  153. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
  154. package/dist/modules/workspace/set-aside-clone.js +92 -0
  155. package/dist/modules/workspace/set-aside-clone.js.map +1 -0
  156. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +59 -9
  157. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  158. package/dist/modules/workspace/startup/kb-startup-runner.js +65 -24
  159. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  160. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  161. package/dist/modules/workspace/workspace.routes.js +92 -3
  162. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  163. package/dist/modules/workspace/workspace.service.d.ts +115 -5
  164. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  165. package/dist/modules/workspace/workspace.service.js +255 -25
  166. package/dist/modules/workspace/workspace.service.js.map +1 -1
  167. package/dist/modules/workspace/workspace.tools.d.ts +2 -2
  168. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  169. package/dist/modules/workspace/workspace.tools.js +309 -115
  170. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  171. package/dist/modules/write-access/write-access.d.ts +60 -0
  172. package/dist/modules/write-access/write-access.d.ts.map +1 -0
  173. package/dist/modules/write-access/write-access.js +129 -0
  174. package/dist/modules/write-access/write-access.js.map +1 -0
  175. package/dist/shared/domain-errors.d.ts +25 -0
  176. package/dist/shared/domain-errors.d.ts.map +1 -1
  177. package/dist/shared/domain-errors.js +28 -0
  178. package/dist/shared/domain-errors.js.map +1 -1
  179. package/dist/shared/git.contract.d.ts +20 -0
  180. package/dist/shared/git.contract.d.ts.map +1 -1
  181. package/dist/shared/git.contract.js +26 -0
  182. package/dist/shared/git.contract.js.map +1 -1
  183. package/dist/tenancy/static-tenant-source.d.ts +0 -1
  184. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  185. package/dist/tenancy/static-tenant-source.js +0 -2
  186. package/dist/tenancy/static-tenant-source.js.map +1 -1
  187. package/migrations/0014_change_request_closed_reason.sql +1 -0
  188. package/migrations/0015_account_deactivation.sql +3 -0
  189. package/migrations/meta/0014_snapshot.json +2265 -0
  190. package/migrations/meta/0015_snapshot.json +2271 -0
  191. package/migrations/meta/_journal.json +14 -0
  192. package/package.json +3 -3
  193. package/src/__tests__/kb-layout-config.test.ts +0 -2
  194. package/src/__tests__/retired-settings.test.ts +96 -0
  195. package/src/core/__tests__/gated-boot.test.ts +132 -0
  196. package/src/core/__tests__/lifecycle.test.ts +170 -1
  197. package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
  198. package/src/core/core-ports.ts +11 -1
  199. package/src/core/create-core-server.ts +32 -14
  200. package/src/core/create-core-services.ts +129 -23
  201. package/src/core/lifecycle.ts +98 -13
  202. package/src/core-config.ts +13 -14
  203. package/src/index.ts +12 -1
  204. package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
  205. package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
  206. package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
  207. package/src/modules/access/access-control.interface.ts +43 -12
  208. package/src/modules/access/access-control.service.ts +234 -36
  209. package/src/modules/access/access.routes.ts +13 -15
  210. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +3 -1
  211. package/src/modules/auth/__tests__/account-deactivation.test.ts +252 -0
  212. package/src/modules/auth/__tests__/account.routes.test.ts +81 -3
  213. package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
  214. package/src/modules/auth/__tests__/auth.service.test.ts +1 -1
  215. package/src/modules/auth/account-admission.ts +78 -9
  216. package/src/modules/auth/account.routes.ts +63 -7
  217. package/src/modules/auth/auth.middleware.ts +19 -8
  218. package/src/modules/auth/auth.routes.ts +8 -0
  219. package/src/modules/auth/auth.service.ts +179 -6
  220. package/src/modules/auth/oidc-auth-provider.ts +7 -2
  221. package/src/modules/database/core-schema.ts +35 -27
  222. package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
  223. package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
  224. package/src/modules/kb-fs/branch-name.ts +14 -1
  225. package/src/modules/kb-fs/repo-path.ts +85 -0
  226. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
  227. package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
  228. package/src/modules/kb-sync/sync-auth.ts +2 -2
  229. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
  230. package/src/modules/mcp/__tests__/mcp.service.test.ts +68 -6
  231. package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
  232. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
  233. package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
  234. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
  235. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +91 -58
  236. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +25 -8
  237. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +14 -11
  238. package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
  239. package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
  240. package/src/modules/settings/deployment-settings.service.ts +8 -3
  241. package/src/modules/settings/setup.routes.ts +263 -19
  242. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
  243. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
  244. package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
  245. package/src/modules/skills/skills.contract.ts +91 -18
  246. package/src/modules/skills/skills.service.ts +278 -41
  247. package/src/modules/skills/skills.tools.ts +80 -8
  248. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
  249. package/src/modules/tool-auth/external-api-key.service.ts +4 -2
  250. package/src/modules/tool-auth/internal-token.service.ts +3 -3
  251. package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
  252. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
  253. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
  254. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
  255. package/src/modules/tool-helpers/tool-handler.ts +24 -4
  256. package/src/modules/tool-helpers/tool.contract.ts +3 -2
  257. package/src/modules/tool-helpers/validate-token.ts +1 -1
  258. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
  259. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
  260. package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
  261. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +264 -6
  262. package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
  263. package/src/modules/workflow/agent-tools/workflow.tools.ts +86 -35
  264. package/src/modules/workflow/file-lock.service.ts +31 -0
  265. package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
  266. package/src/modules/workflow/git/git.service.ts +168 -0
  267. package/src/modules/workflow/pending-commits.service.ts +59 -1
  268. package/src/modules/workflow/workflow-hooks.ts +64 -26
  269. package/src/modules/workflow/workflow.service.ts +121 -4
  270. package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
  271. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +384 -0
  272. package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
  273. package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
  274. package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
  275. package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
  276. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
  277. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
  278. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +326 -0
  279. package/src/modules/workspace/__tests__/workspace.service.test.ts +20 -9
  280. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +2 -3
  281. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +2 -3
  282. package/src/modules/workspace/__tests__/workspace.tools.test.ts +685 -14
  283. package/src/modules/workspace/agent-access.gate.ts +164 -0
  284. package/src/modules/workspace/routine-write-policy.ts +5 -6
  285. package/src/modules/workspace/session-sink.ts +5 -5
  286. package/src/modules/workspace/set-aside-clone.ts +96 -0
  287. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +86 -0
  288. package/src/modules/workspace/startup/kb-startup-runner.ts +115 -34
  289. package/src/modules/workspace/workspace.routes.ts +95 -3
  290. package/src/modules/workspace/workspace.service.ts +256 -24
  291. package/src/modules/workspace/workspace.tools.ts +338 -119
  292. package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
  293. package/src/modules/write-access/write-access.ts +153 -0
  294. package/src/shared/domain-errors.ts +31 -0
  295. package/src/shared/git.contract.ts +27 -0
  296. package/src/tenancy/__tests__/static-tenant-source.test.ts +0 -1
  297. package/src/tenancy/static-tenant-source.ts +0 -3
  298. package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
  299. package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
  300. package/dist/modules/workflow/session-ontology.policy.js +0 -62
  301. package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
  302. package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
  303. package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
  304. package/dist/modules/workflow/session-ontology.service.js +0 -147
  305. package/dist/modules/workflow/session-ontology.service.js.map +0 -1
  306. package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
  307. package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
  308. package/dist/modules/workspace/session-ontology.gate.js +0 -161
  309. package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
  310. package/dist/shared/kb-layout.d.ts +0 -39
  311. package/dist/shared/kb-layout.d.ts.map +0 -1
  312. package/dist/shared/kb-layout.js +0 -103
  313. package/dist/shared/kb-layout.js.map +0 -1
  314. package/dist/shared/kb-layout.test.d.ts +0 -2
  315. package/dist/shared/kb-layout.test.d.ts.map +0 -1
  316. package/dist/shared/kb-layout.test.js +0 -75
  317. package/dist/shared/kb-layout.test.js.map +0 -1
  318. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
  319. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
  320. package/src/modules/workflow/session-ontology.policy.ts +0 -70
  321. package/src/modules/workflow/session-ontology.service.ts +0 -183
  322. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
  323. package/src/modules/workspace/session-ontology.gate.ts +0 -191
  324. package/src/shared/kb-layout.test.ts +0 -98
  325. package/src/shared/kb-layout.ts +0 -102
@@ -0,0 +1,385 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+
6
+ import { NodeFs } from '../../kb-fs/node-fs.js';
7
+ import type { WorkspaceService } from '../../workspace/workspace.service.js';
8
+ import { AccessControlService } from '../access-control.service.js';
9
+
10
+ /**
11
+ * `previewAccessAfterRelocation`: the caller's OWN verbs at a destination as
12
+ * they will be once a move or a copy has landed there.
13
+ *
14
+ * The question the four gates cannot answer, because the `access.md` files a
15
+ * folder carries are not at the destination yet — which is how a rename came
16
+ * to warn about losing owner access the folder's own rules were about to hand
17
+ * straight back. Driven over a real on-disk tree, and every answer checked
18
+ * against the ordinary gates once the tree really says what the preview said
19
+ * it would.
20
+ */
21
+
22
+ const KB_DIR = 'knowledge-base';
23
+ const MOVER = 'mover@x.io';
24
+
25
+ function stubWorkspaceService(workspaceId: string, workspaceDir: string): WorkspaceService {
26
+ return {
27
+ getWorkspacePath: async (id: string) => {
28
+ if (id !== workspaceId) throw new Error(`unexpected workspace ${id}`);
29
+ return workspaceDir;
30
+ },
31
+ ensureRemotesFetched: async () => undefined,
32
+ } as unknown as WorkspaceService;
33
+ }
34
+
35
+ const rules = (body: string) => `---\n${body}---\n`;
36
+
37
+ describe('AccessControlService.previewAccessAfterRelocation', () => {
38
+ let root: string;
39
+ let repo: string;
40
+ const workspaceId = 'ws-preview-relocation';
41
+
42
+ beforeEach(async () => {
43
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-preview-reloc-'));
44
+ repo = path.join(root, workspaceId, KB_DIR);
45
+ });
46
+ afterEach(async () => {
47
+ if (root) await fs.rm(root, { recursive: true, force: true });
48
+ });
49
+
50
+ async function write(files: Record<string, string>): Promise<void> {
51
+ for (const [rel, contents] of Object.entries(files)) {
52
+ const abs = path.join(repo, rel);
53
+ await fs.mkdir(path.dirname(abs), { recursive: true });
54
+ await fs.writeFile(abs, contents);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * A service reading the tree as it is NOW. Built fresh after every change
60
+ * on purpose: in production the file-change notifier invalidates the model,
61
+ * and a test that leaned on the five-second cache would be asserting the
62
+ * cache rather than the resolver.
63
+ */
64
+ const service = () =>
65
+ new AccessControlService(
66
+ stubWorkspaceService(workspaceId, path.join(root, workspaceId)),
67
+ KB_DIR,
68
+ new NodeFs(),
69
+ );
70
+
71
+ /** The caller's four verbs at a path, through the ordinary gates. */
72
+ async function verbsAt(svc: AccessControlService, rel: string) {
73
+ const [read, write, download, owner] = await Promise.all([
74
+ svc.canRead(workspaceId, MOVER, rel),
75
+ svc.canWrite(workspaceId, MOVER, rel),
76
+ svc.canDownload(workspaceId, MOVER, rel),
77
+ svc.canOwner(workspaceId, MOVER, rel),
78
+ ]);
79
+ return { read, write, download, owner };
80
+ }
81
+
82
+ /** Move `from` to `to` on disk, as the move itself does. */
83
+ async function reallyMove(from: string, to: string): Promise<void> {
84
+ const dest = path.join(repo, to);
85
+ await fs.mkdir(path.dirname(dest), { recursive: true });
86
+ await fs.rename(path.join(repo, from), dest);
87
+ }
88
+
89
+ /** Copy `from` to `to` on disk, leaving the source where it is. */
90
+ async function reallyCopy(from: string, to: string): Promise<void> {
91
+ const dest = path.join(repo, to);
92
+ await fs.mkdir(path.dirname(dest), { recursive: true });
93
+ await fs.cp(path.join(repo, from), dest, { recursive: true });
94
+ }
95
+
96
+ const BASE = {
97
+ 'roles.yaml': 'roles:\n Admin:\n - admin@x.io\n',
98
+ 'access.md': rules('read:\n - everyone\n'),
99
+ };
100
+
101
+ it('a folder keeps the access its own access.md gives it when it is renamed', async () => {
102
+ await write({
103
+ ...BASE,
104
+ 'Sales/access.md': rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
105
+ 'Sales/deal.md': '# Deal\n',
106
+ });
107
+
108
+ const svc = service();
109
+ const before = await verbsAt(svc, 'Sales');
110
+ const after = await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Sales', 'Revenue');
111
+
112
+ expect(before.owner).toBe(true);
113
+ expect(after).toEqual(before);
114
+
115
+ // The root grants read and nothing else, so the destination AS IT STANDS
116
+ // says the opposite — which is the warning people were shown.
117
+ expect(await verbsAt(svc, 'Revenue')).toMatchObject({ write: false, owner: false });
118
+
119
+ await reallyMove('Sales', 'Revenue');
120
+ expect(await verbsAt(service(), 'Revenue')).toEqual(after);
121
+ });
122
+
123
+ it('a folder with no access.md of its own takes the new parent\'s rules, as it always did', async () => {
124
+ await write({
125
+ ...BASE,
126
+ 'Work/access.md': rules(`write:\n - Mover <${MOVER}>\n`),
127
+ 'Work/Notes/note.md': '# Note\n',
128
+ 'Legal/access.md': rules('read:\n - everyone\n'),
129
+ });
130
+
131
+ const svc = service();
132
+ const before = await verbsAt(svc, 'Work/Notes');
133
+ const after = await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Work/Notes', 'Legal/Notes');
134
+
135
+ expect(before.write).toBe(true);
136
+ expect(after.write).toBe(false);
137
+ // Nothing travels, so the preview is exactly what the gates say today.
138
+ expect(after).toEqual(await verbsAt(svc, 'Legal/Notes'));
139
+
140
+ await reallyMove('Work/Notes', 'Legal/Notes');
141
+ expect(await verbsAt(service(), 'Legal/Notes')).toEqual(after);
142
+ });
143
+
144
+ it('what the folder inherited is left behind; what it carries comes along', async () => {
145
+ await write({
146
+ ...BASE,
147
+ 'Work/access.md': rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
148
+ 'Work/Team/access.md': rules(`write:\n - Mover <${MOVER}>\n`),
149
+ 'Work/Team/plan.md': '# Plan\n',
150
+ 'Legal/access.md': rules('read:\n - everyone\n'),
151
+ });
152
+
153
+ const svc = service();
154
+ expect(await verbsAt(svc, 'Work/Team')).toMatchObject({ write: true, owner: true });
155
+
156
+ const after = await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Work/Team', 'Legal/Team');
157
+
158
+ // Write is the folder's own and travels; owner came from `Work/` and
159
+ // does not.
160
+ expect(after).toMatchObject({ write: true, owner: false });
161
+
162
+ await reallyMove('Work/Team', 'Legal/Team');
163
+ expect(await verbsAt(service(), 'Legal/Team')).toEqual(after);
164
+ });
165
+
166
+ it('a nested access.md does not change the answer at the folder itself', async () => {
167
+ const tree = {
168
+ ...BASE,
169
+ 'Work/access.md': rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
170
+ 'Work/Team/access.md': rules(`write:\n - Mover <${MOVER}>\n`),
171
+ 'Work/Team/plan.md': '# Plan\n',
172
+ 'Legal/access.md': rules('read:\n - everyone\n'),
173
+ };
174
+ await write(tree);
175
+ const withoutNested = await service().previewAccessAfterRelocation(
176
+ workspaceId, MOVER, 'Work/Team', 'Legal/Team',
177
+ );
178
+
179
+ // `Sub/` shuts the caller out entirely, and it moves too — but it governs
180
+ // `Legal/Team/Sub`, not `Legal/Team`.
181
+ await write({ 'Work/Team/Sub/access.md': rules(`read:\n - deny Mover <${MOVER}>\n`) });
182
+ const svc = service();
183
+ expect(await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Work/Team', 'Legal/Team'))
184
+ .toEqual(withoutNested);
185
+ // It is counted, though — at the path it lands on.
186
+ expect(
187
+ await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Work/Team', 'Legal/Team'),
188
+ ).not.toEqual(
189
+ await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Work/Team/Sub', 'Legal/Team/Sub'),
190
+ );
191
+
192
+ await reallyMove('Work/Team', 'Legal/Team');
193
+ const after = service();
194
+ expect(await verbsAt(after, 'Legal/Team')).toEqual(withoutNested);
195
+ expect(await verbsAt(after, 'Legal/Team/Sub')).toMatchObject({ read: false });
196
+ });
197
+
198
+ it('a copy leaves the source rules where they are and lands a second set', async () => {
199
+ await write({
200
+ ...BASE,
201
+ 'Sales/access.md': rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
202
+ 'Sales/deal.md': '# Deal\n',
203
+ // A destination governed DIFFERENTLY from the source, so the answer can
204
+ // only come from the rules the copy carries: Legal grants read and
205
+ // nothing else, as the root does.
206
+ 'Legal/access.md': rules('read:\n - everyone\n'),
207
+ });
208
+
209
+ const svc = service();
210
+ const after = await svc.previewAccessAfterRelocation(
211
+ workspaceId, MOVER, 'Sales', 'Legal/Sales-Copy', { sourceRemains: true },
212
+ );
213
+
214
+ // The copied `access.md` governs the copy, which is the opposite of what
215
+ // the destination says today.
216
+ expect(after).toMatchObject({ write: true, owner: true });
217
+ expect(await verbsAt(svc, 'Legal')).toMatchObject({ write: false, owner: false });
218
+
219
+ await reallyCopy('Sales', 'Legal/Sales-Copy');
220
+ const done = service();
221
+ expect(await verbsAt(done, 'Legal/Sales-Copy')).toEqual(after);
222
+ // `sourceRemains`: both sets of rules are real afterwards, and the source
223
+ // keeps the access it always had.
224
+ expect(await verbsAt(done, 'Sales')).toMatchObject({ owner: true });
225
+ });
226
+
227
+ /**
228
+ * A lone `access.md` is a single FILE whose rules are a whole folder's.
229
+ * Keyed by the directory it sits in, it is not a path "under" the source,
230
+ * so the folder walk cannot see it — and `copy_file` will copy one, which
231
+ * is how a preview came to answer the destination's old rules about a
232
+ * directory that was about to be governed by the file landing in it.
233
+ */
234
+ it('a lone access.md governs the folder it is copied into', async () => {
235
+ await write({
236
+ ...BASE,
237
+ 'Sales/access.md': rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
238
+ 'Legal/note.md': '# Note\n',
239
+ });
240
+
241
+ const svc = service();
242
+ // `Legal/` has no rules of its own; the root grants read and nothing else.
243
+ expect(await verbsAt(svc, 'Legal')).toMatchObject({ write: false, owner: false });
244
+
245
+ const after = await svc.previewAccessAfterRelocation(
246
+ workspaceId, MOVER, 'Sales/access.md', 'Legal/access.md', { sourceRemains: true },
247
+ );
248
+ expect(after).toMatchObject({ write: true, owner: true });
249
+
250
+ await fs.copyFile(path.join(repo, 'Sales/access.md'), path.join(repo, 'Legal/access.md'));
251
+ const done = service();
252
+ expect(await verbsAt(done, 'Legal/access.md')).toEqual(after);
253
+ // What landed governs the folder, not only itself.
254
+ expect(await verbsAt(done, 'Legal')).toMatchObject({ write: true, owner: true });
255
+ });
256
+
257
+ /**
258
+ * The name the bytes land under decides what they GOVERN, and it decides
259
+ * how they are PARSED: the same frontmatter is a folder's rules in an
260
+ * `access.md` and the file's own rules anywhere else. So these bytes
261
+ * govern no folder at `Legal/rules-copy.md` — and still grant the caller
262
+ * write and owner, on that one file. Reading them as the source's name said
263
+ * `write: false, owner: false`, and the copy then handed over both: a
264
+ * preview contradicting the operation it was previewing, which is the
265
+ * entire fault this ticket exists to kill.
266
+ */
267
+ it('a file copied under any other name governs no folder, but keeps its own grants', async () => {
268
+ await write({
269
+ ...BASE,
270
+ 'Sales/access.md': rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
271
+ 'Legal/note.md': '# Note\n',
272
+ });
273
+
274
+ const svc = service();
275
+ const after = await svc.previewAccessAfterRelocation(
276
+ workspaceId, MOVER, 'Sales/access.md', 'Legal/rules-copy.md', { sourceRemains: true },
277
+ );
278
+ // Pinned, not merely equal to the outcome: both sides reading `false`
279
+ // would satisfy the comparison below and be wrong together.
280
+ expect(after).toMatchObject({ write: true, owner: true });
281
+
282
+ await reallyCopy('Sales/access.md', 'Legal/rules-copy.md');
283
+ const done = service();
284
+ expect(await verbsAt(done, 'Legal/rules-copy.md')).toEqual(after);
285
+ // The folder it landed in is untouched: an ordinary note governs nothing
286
+ // but itself, whatever its frontmatter says.
287
+ expect(await verbsAt(done, 'Legal')).toMatchObject({ write: false, owner: false });
288
+ expect(await verbsAt(done, 'Legal/note.md')).toMatchObject({ write: false, owner: false });
289
+ });
290
+
291
+ /**
292
+ * And the mirror, which `copy_file` also allows: ordinary bytes landing as
293
+ * a directory's `access.md` become that directory's rules the moment they
294
+ * land. The preview reads them as the destination will.
295
+ */
296
+ it('a file copied TO an access.md gives the destination folder its rules', async () => {
297
+ await write({
298
+ ...BASE,
299
+ 'Draft/proposed-rules.md': rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
300
+ 'Legal/note.md': '# Note\n',
301
+ });
302
+
303
+ const svc = service();
304
+ expect(await verbsAt(svc, 'Legal')).toMatchObject({ write: false, owner: false });
305
+
306
+ const after = await svc.previewAccessAfterRelocation(
307
+ workspaceId, MOVER, 'Draft/proposed-rules.md', 'Legal/access.md', { sourceRemains: true },
308
+ );
309
+ expect(after).toMatchObject({ write: true, owner: true });
310
+
311
+ await reallyCopy('Draft/proposed-rules.md', 'Legal/access.md');
312
+ const done = service();
313
+ expect(await verbsAt(done, 'Legal/access.md')).toEqual(after);
314
+ // It governs the folder now, which is the whole difference the name makes.
315
+ expect(await verbsAt(done, 'Legal')).toMatchObject({ write: true, owner: true });
316
+ expect(await verbsAt(done, 'Legal/note.md')).toMatchObject({ write: true, owner: true });
317
+ });
318
+
319
+ /**
320
+ * The same rule for the other thing that travels with a file: its own
321
+ * frontmatter. Asking the DESTINATION for it always answered null — nothing
322
+ * is there yet — so a rename of a self-governing file previewed a loss the
323
+ * move hands straight back, and the preview contradicted the access the
324
+ * caller really has afterwards (Specification requirement 6).
325
+ */
326
+ it("a file's own frontmatter travels with its bytes, so a rename costs nothing", async () => {
327
+ await write({
328
+ ...BASE,
329
+ 'Work/access.md': rules('read:\n - everyone\n'),
330
+ 'Work/plan.md': rules(`owner:\n - Mover <${MOVER}>\n`) + '# Plan\n',
331
+ });
332
+
333
+ const svc = service();
334
+ const before = await verbsAt(svc, 'Work/plan.md');
335
+ expect(before.owner).toBe(true);
336
+
337
+ const after = await svc.previewAccessAfterRelocation(
338
+ workspaceId, MOVER, 'Work/plan.md', 'Work/roadmap.md',
339
+ );
340
+ expect(after).toEqual(before);
341
+
342
+ await reallyMove('Work/plan.md', 'Work/roadmap.md');
343
+ expect(await verbsAt(service(), 'Work/roadmap.md')).toEqual(after);
344
+ });
345
+
346
+ it('a single file answers exactly as the gates at the destination do', async () => {
347
+ await write({
348
+ ...BASE,
349
+ 'Work/access.md': rules(`write:\n - Mover <${MOVER}>\n`),
350
+ 'Work/note.md': '# Note\n',
351
+ 'Legal/access.md': rules('read:\n - everyone\n'),
352
+ });
353
+
354
+ const svc = service();
355
+ const after = await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Work/note.md', 'Legal/note.md');
356
+
357
+ expect(after).toEqual(await verbsAt(svc, 'Legal/note.md'));
358
+
359
+ await reallyMove('Work/note.md', 'Legal/note.md');
360
+ expect(await verbsAt(service(), 'Legal/note.md')).toEqual(after);
361
+ });
362
+
363
+ it('answers the caller\'s own verbs and nothing else, and writes nothing', async () => {
364
+ await write({
365
+ ...BASE,
366
+ 'Sales/access.md': rules(`read:\n - deny everyone\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
367
+ 'Sales/deal.md': '# Deal\n',
368
+ });
369
+
370
+ const svc = service();
371
+ const after = await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Sales', 'Revenue');
372
+
373
+ // Four booleans. Nothing about who else holds what, and nothing out of
374
+ // an `access.md` the caller may not read.
375
+ expect(Object.keys(after).sort()).toEqual(['download', 'owner', 'read', 'write']);
376
+ for (const v of Object.values(after)) expect(typeof v).toBe('boolean');
377
+
378
+ // A stranger gets their own answer, not the caller's.
379
+ expect(await svc.previewAccessAfterRelocation(workspaceId, 'stranger@x.io', 'Sales', 'Revenue'))
380
+ .toMatchObject({ read: false, owner: false });
381
+
382
+ expect(await fs.readFile(path.join(repo, 'Sales/access.md'), 'utf-8')).toContain('owner:');
383
+ await expect(fs.access(path.join(repo, 'Revenue'))).rejects.toThrow();
384
+ });
385
+ });
@@ -9,11 +9,12 @@ import { AccessControlService } from '../access-control.service.js';
9
9
  import { holderPrincipals } from '../access-view.js';
10
10
 
11
11
  /**
12
- * `prospectiveHolders`: who can open and edit a file where it sits, and who
13
- * could once a move puts it somewhere else. Driven over a real on-disk tree
14
- * through the public resolver, because the whole point is that the "after"
15
- * side must be the SAME resolution as a real one — the destination's folder
16
- * chain with the file's own frontmatter on top — for a path that does not
12
+ * `prospectiveHolders`: who can open and edit a file or folder where it
13
+ * sits, and who could once a move puts it somewhere else. Driven over a real
14
+ * on-disk tree through the public resolver, because the whole point is that
15
+ * the "after" side must be the SAME resolution as a real one — the
16
+ * destination's folder chain with whatever travels laid over it, a file's own
17
+ * frontmatter or a folder's `access.md` files — for a path that does not
17
18
  * exist yet and therefore cannot be asked for in the ordinary way.
18
19
  */
19
20
 
@@ -136,25 +137,100 @@ describe('AccessControlService.prospectiveHolders', () => {
136
137
  expect(holderPrincipals(before.read)).toContainEqual({ kind: 'group', name: 'Engineering' });
137
138
  });
138
139
 
139
- it('refuses a folder source rather than resolving it as a file', async () => {
140
+ /**
141
+ * A folder carries its own `access.md`, and that file MOVES WITH IT. The
142
+ * resolver used to refuse the question rather than answer it as if the
143
+ * folder were a file — the right refusal for the wrong reason, since the
144
+ * answer it could not give is the one a folder move actually needs.
145
+ */
146
+ it('answers a folder source with the rules it carries, at the path they land on', async () => {
140
147
  const svc = await makeService(TREE);
141
148
 
142
- // `Knowledge/Legal` carries its own access.md and governs everything under
143
- // it. Resolved as a file it would report the rules of the folder ABOVE it
144
- // and none of its own — a confident, wrong answer.
145
- await expect(
146
- svc.prospectiveHolders(workspaceId, 'Knowledge/Legal', 'Knowledge/Sales/Legal'),
147
- ).rejects.toThrow(/folder/i);
149
+ const { before, after } = await svc.prospectiveHolders(
150
+ workspaceId,
151
+ 'Knowledge/Legal',
152
+ 'Knowledge/Sales/Legal',
153
+ );
154
+
155
+ // `Knowledge/Legal/access.md` travels, so Engineering keeps both verbs at
156
+ // the new path — the rules the folder brings, not the ones it is landing
157
+ // among. Read as a FILE the answer would have been Product's, from
158
+ // `Knowledge/Sales/access.md` alone.
159
+ expect(names(before.read)).toContain('Engineering');
160
+ expect(names(before.write)).toContain('Engineering');
161
+ expect(names(after.read)).toContain('Engineering');
162
+ expect(names(after.write)).toContain('Engineering');
163
+ });
164
+
165
+ it('leaves the old parent behind and picks the new one up', async () => {
166
+ const svc = await makeService({
167
+ ...TREE,
168
+ // Engineering's write on Team comes from Legal above it, not from Team.
169
+ 'Knowledge/Legal/Team/access.md': rules('read:\n - Product\n'),
170
+ 'Knowledge/Legal/Team/plan.md': '# Plan\n',
171
+ });
172
+
173
+ const { before, after } = await svc.prospectiveHolders(
174
+ workspaceId,
175
+ 'Knowledge/Legal/Team',
176
+ 'Knowledge/Sales/Team',
177
+ );
178
+
179
+ expect(names(before.write)).toContain('Engineering');
180
+ // Sales grants nobody write, and Team's own file grants only read — so
181
+ // the write Team held by inheritance does not come along.
182
+ expect(names(after.write)).not.toContain('Engineering');
183
+ expect(names(after.read)).toContain('Product');
184
+ });
185
+
186
+ it('a rename of a folder that grants its own access changes nothing', async () => {
187
+ const svc = await makeService(TREE);
188
+
189
+ const { before, after } = await svc.prospectiveHolders(
190
+ workspaceId,
191
+ 'Knowledge/Legal',
192
+ 'Knowledge/Revenue',
193
+ );
194
+
195
+ expect(names(after.read)).toEqual(names(before.read));
196
+ expect(names(after.write)).toEqual(names(before.write));
197
+ });
198
+
199
+ /**
200
+ * A SYMLINK is neither of the two shapes the preview knows. `move_file`
201
+ * refuses a symlink source outright (`assertNoSymlinkOnPath`), so a link to
202
+ * a directory must not be described as a folder taking its `access.md`
203
+ * files along — that would promise the target's rules at a path nothing is
204
+ * ever going to arrive at. Nothing is read through the link either.
205
+ */
206
+ it('a symlink to a folder is not previewed as the folder it points at', async () => {
207
+ const svc = await makeService(TREE);
208
+ const repo = path.join(root, workspaceId, KB_DIR);
209
+ await fs.symlink(path.join(repo, 'Knowledge/Legal'), path.join(repo, 'Knowledge/Sales/alias'));
210
+
211
+ const { after } = await svc.prospectiveHolders(
212
+ workspaceId,
213
+ 'Knowledge/Sales/alias',
214
+ 'Knowledge/alias',
215
+ );
216
+
217
+ // Legal's own rules stayed behind the link: `Knowledge/` has no
218
+ // `access.md`, so nobody holds anything at the destination.
219
+ expect(names(after.read)).not.toContain('Engineering');
220
+ expect(names(after.write)).not.toContain('Engineering');
148
221
  });
149
222
 
150
223
  it('a probe that fails for any reason but absence throws, never a silent "file"', async () => {
151
224
  const svc = await makeService(TREE);
152
- // A disk fault (EACCES here) must never read as "not a directory": that is
153
- // exactly how a folder source would slip past the refusal above, and the
154
- // answer it slips into names the wrong principals. Spied rather than
155
- // staged with mode bits, so the property holds as root and on Windows too.
156
- const real = fs.stat;
157
- const spy = vi.spyOn(fs, 'stat').mockImplementation(((p: string) =>
225
+ // A disk fault (EACCES here) must never read as "not a directory": the
226
+ // probe is what chooses between the two resolutions, so a folder read as
227
+ // a file would be answered with frontmatter it does not have and without
228
+ // the `access.md` files it carries — the wrong principals, named with
229
+ // the same confidence as the right ones. Spied rather than staged with
230
+ // mode bits, so the property holds as root and on Windows too. `lstat`,
231
+ // because that is the probe: a symlink is classified by what it IS.
232
+ const real = fs.lstat;
233
+ const spy = vi.spyOn(fs, 'lstat').mockImplementation(((p: string) =>
158
234
  String(p).endsWith('contract.md')
159
235
  ? Promise.reject(Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }))
160
236
  : (real as (p: string) => Promise<unknown>).call(fs, p)) as never);
@@ -10,7 +10,6 @@ import type { WorkflowService } from '../../workflow/workflow.service.js';
10
10
  import type { WorkflowEventBus } from '../../workflow/event-bus.js';
11
11
  import type { Database } from '../../database/connection.js';
12
12
  import { createAccessRoutes } from '../access.routes.js';
13
- import { WorkflowDomainError } from '../../../shared/domain-errors.js';
14
13
  import { testKbContext } from '../../../__tests__/kb-context.js';
15
14
 
16
15
  /**
@@ -192,15 +191,15 @@ describe('GET /access/prospective', () => {
192
191
  expect(await res.json()).toEqual({ error: 'Internal error.' });
193
192
  });
194
193
 
195
- it('refuses a folder source the way the resolver does, without guessing', async () => {
194
+ it('asks about a folder source the same way, keeping its name at the destination', async () => {
196
195
  h = await makeHarness();
197
- h.prospectiveHolders.mockRejectedValueOnce(
198
- new WorkflowDomainError('prospective access answers for a file, not a folder', 400),
199
- );
200
196
 
201
- const res = await get(`from=${encodeURIComponent('Knowledge/Legal')}&toDir=Knowledge`);
197
+ const res = await get(`from=${encodeURIComponent('Knowledge/Legal')}&toDir=${encodeURIComponent('Knowledge/Sales')}`);
202
198
 
203
- expect(res.status).toBe(400);
199
+ expect(res.status).toBe(200);
200
+ // The route derives the destination from the name, folder or file alike;
201
+ // which of the two it is, and what travels with it, is the resolver's.
202
+ expect(h.prospectiveHolders).toHaveBeenCalledWith(WS, 'Knowledge/Legal', 'Knowledge/Sales/Legal');
204
203
  });
205
204
 
206
205
  it('falls back to the name-only roles list when the resolver omits kinds', async () => {
@@ -84,11 +84,14 @@ export type HolderList = {
84
84
  export type PathHolders = { read: HolderList; write: HolderList };
85
85
 
86
86
  /**
87
- * One file's holders where it is now and where a move would put it — see
88
- * {@link IAccessControl.prospectiveHolders}.
87
+ * One file's or folder's holders where it is now and where a move would put
88
+ * it — see {@link IAccessControl.prospectiveHolders}.
89
89
  */
90
90
  export type ProspectiveHolders = { before: PathHolders; after: PathHolders };
91
91
 
92
+ /** One caller's own verdict per access verb on one path. */
93
+ export type PathVerbs = { read: boolean; write: boolean; download: boolean; owner: boolean };
94
+
92
95
  /**
93
96
  * Per-verb sources of a principal's access on a target. Only verbs the principal
94
97
  * actually holds (via a named file entry) appear; each maps to the closest-first
@@ -358,19 +361,17 @@ export interface IAccessControl {
358
361
  }>;
359
362
 
360
363
  /**
361
- * Who can open and who can edit one file where it IS, and where a move
362
- * would put it. `toPath` names a path that does not exist yet — the point
363
- * of the call is to answer before the move happens — so the resolution
364
- * layers the file's OWN rules (its frontmatter, read from `fromPath`,
365
- * which travels with the bytes) over the destination's folder chain.
364
+ * Who can open and who can edit one file or folder where it IS, and where a
365
+ * move would put it. `toPath` names a path that does not exist yet — the
366
+ * point of the call is to answer before the move happens — so the
367
+ * resolution is over what travels with the bytes: a file's OWN rules (its
368
+ * frontmatter, read from `fromPath`) layered over the destination's folder
369
+ * chain, or a folder's `access.md` files keyed at the directories they
370
+ * land in. Either way the rules the source inherited from its old parent
371
+ * are left behind and the new parent's apply.
366
372
  *
367
373
  * Writes nothing and moves nothing. The move confirmation diffs the two
368
374
  * sides to name who loses and who gains access.
369
- *
370
- * A FILE question only: a `fromPath` that is a directory is refused with a
371
- * 400. A folder's access is its own `access.md` — which moves with it and
372
- * governs everything beneath it — so resolving it as a file would name the
373
- * wrong principals with the same confidence as the right ones.
374
375
  */
375
376
  prospectiveHolders(
376
377
  workspaceId: string,
@@ -378,6 +379,36 @@ export interface IAccessControl {
378
379
  toPath: string,
379
380
  ): Promise<ProspectiveHolders>;
380
381
 
382
+ /**
383
+ * The CALLER'S OWN `{ read, write, download, owner }` at `toPath` as they
384
+ * will be once `fromPath` has been moved (`sourceRemains: false`, the
385
+ * default) or copied (`sourceRemains: true`) there — the question a move's
386
+ * or copy's dry run asks, which the same four gates at `toPath` cannot
387
+ * answer because what travels with the bytes is not at the destination yet:
388
+ * the `access.md` files inside a folder, keyed at the directories they land
389
+ * in, and a file's own frontmatter.
390
+ *
391
+ * What a single file's rules GOVERN follows the name it lands under, not the
392
+ * one it leaves: landing as a directory's `access.md` they are that
393
+ * directory's rules (so copying `Sales/access.md` to `Legal/access.md`
394
+ * previews the access `Legal/` is about to have), and landing under any
395
+ * other name they are the file's own and govern nothing but it. Either way
396
+ * the answer is the one the gates will give a moment later.
397
+ *
398
+ * PREVIEW ONLY. It describes a tree that does not exist, so it must never
399
+ * decide whether an operation may run: the gates keep resolving against the
400
+ * tree that does. It writes nothing and moves nothing, and it answers the
401
+ * caller's own verbs only — never the content of an `access.md` they may
402
+ * not read.
403
+ */
404
+ previewAccessAfterRelocation(
405
+ workspaceId: string,
406
+ userEmail: string,
407
+ fromPath: string,
408
+ toPath: string,
409
+ opts?: { sourceRemains?: boolean },
410
+ ): Promise<PathVerbs>;
411
+
381
412
  /**
382
413
  * Finite expanded email set for configured users who could approve this path
383
414
  * — role members + direct user grants, minus anyone denied. The built-in