@bevel-software/platform-core-backend 0.4.1 → 0.5.1

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 (305) hide show
  1. package/THIRD-PARTY-NOTICES.md +6118 -0
  2. package/dist/core/core-ports.d.ts +15 -0
  3. package/dist/core/core-ports.d.ts.map +1 -1
  4. package/dist/core/core-ports.js.map +1 -1
  5. package/dist/core/create-core-server.d.ts.map +1 -1
  6. package/dist/core/create-core-server.js +40 -6
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +17 -1
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +153 -29
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core-config.d.ts +70 -21
  13. package/dist/core-config.d.ts.map +1 -1
  14. package/dist/core-config.js +134 -58
  15. package/dist/core-config.js.map +1 -1
  16. package/dist/modules/access/access-control.interface.d.ts +6 -0
  17. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  18. package/dist/modules/access/access-control.service.d.ts +74 -5
  19. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  20. package/dist/modules/access/access-control.service.js +116 -15
  21. package/dist/modules/access/access-control.service.js.map +1 -1
  22. package/dist/modules/access/access-declarations.d.ts +93 -0
  23. package/dist/modules/access/access-declarations.d.ts.map +1 -0
  24. package/dist/modules/access/access-declarations.js +168 -0
  25. package/dist/modules/access/access-declarations.js.map +1 -0
  26. package/dist/modules/access/access-mutation.service.js +4 -4
  27. package/dist/modules/access/access-mutation.service.js.map +1 -1
  28. package/dist/modules/access/access-splice.d.ts +16 -1
  29. package/dist/modules/access/access-splice.d.ts.map +1 -1
  30. package/dist/modules/access/access-splice.js +40 -21
  31. package/dist/modules/access/access-splice.js.map +1 -1
  32. package/dist/modules/access/access.routes.d.ts.map +1 -1
  33. package/dist/modules/access/access.routes.js +51 -0
  34. package/dist/modules/access/access.routes.js.map +1 -1
  35. package/dist/modules/access/creator-access.d.ts +8 -0
  36. package/dist/modules/access/creator-access.d.ts.map +1 -1
  37. package/dist/modules/access/creator-access.js +21 -2
  38. package/dist/modules/access/creator-access.js.map +1 -1
  39. package/dist/modules/admin/admin-access.service.d.ts +1 -1
  40. package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
  41. package/dist/modules/admin/admin-access.service.js +3 -2
  42. package/dist/modules/admin/admin-access.service.js.map +1 -1
  43. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  44. package/dist/modules/auth/auth.routes.js +50 -1
  45. package/dist/modules/auth/auth.routes.js.map +1 -1
  46. package/dist/modules/auth/auth.service.d.ts +39 -10
  47. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  48. package/dist/modules/auth/auth.service.js +38 -16
  49. package/dist/modules/auth/auth.service.js.map +1 -1
  50. package/dist/modules/database/core-schema.d.ts +124 -0
  51. package/dist/modules/database/core-schema.d.ts.map +1 -1
  52. package/dist/modules/database/core-schema.js +45 -0
  53. package/dist/modules/database/core-schema.js.map +1 -1
  54. package/dist/modules/groups/group-provision.service.d.ts +130 -0
  55. package/dist/modules/groups/group-provision.service.d.ts.map +1 -0
  56. package/dist/modules/groups/group-provision.service.js +288 -0
  57. package/dist/modules/groups/group-provision.service.js.map +1 -0
  58. package/dist/modules/groups/groups.contract.d.ts +106 -0
  59. package/dist/modules/groups/groups.contract.d.ts.map +1 -0
  60. package/dist/modules/groups/groups.contract.js +36 -0
  61. package/dist/modules/groups/groups.contract.js.map +1 -0
  62. package/dist/modules/groups/groups.routes.d.ts +42 -0
  63. package/dist/modules/groups/groups.routes.d.ts.map +1 -0
  64. package/dist/modules/groups/groups.routes.js +379 -0
  65. package/dist/modules/groups/groups.routes.js.map +1 -0
  66. package/dist/modules/groups/groups.service.d.ts +60 -0
  67. package/dist/modules/groups/groups.service.d.ts.map +1 -0
  68. package/dist/modules/groups/groups.service.js +172 -0
  69. package/dist/modules/groups/groups.service.js.map +1 -0
  70. package/dist/modules/groups/index.d.ts +7 -0
  71. package/dist/modules/groups/index.d.ts.map +1 -0
  72. package/dist/modules/groups/index.js +6 -0
  73. package/dist/modules/groups/index.js.map +1 -0
  74. package/dist/modules/groups/join-proposals.d.ts +53 -0
  75. package/dist/modules/groups/join-proposals.d.ts.map +1 -0
  76. package/dist/modules/groups/join-proposals.js +67 -0
  77. package/dist/modules/groups/join-proposals.js.map +1 -0
  78. package/dist/modules/groups/join-requests.service.d.ts +81 -0
  79. package/dist/modules/groups/join-requests.service.d.ts.map +1 -0
  80. package/dist/modules/groups/join-requests.service.js +135 -0
  81. package/dist/modules/groups/join-requests.service.js.map +1 -0
  82. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +20 -0
  83. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  84. package/dist/modules/secrets-vault/secrets-vault.routes.js +59 -11
  85. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  86. package/dist/modules/settings/deployment-settings.service.d.ts +129 -0
  87. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -0
  88. package/dist/modules/settings/deployment-settings.service.js +368 -0
  89. package/dist/modules/settings/deployment-settings.service.js.map +1 -0
  90. package/dist/modules/settings/setup.routes.d.ts +49 -0
  91. package/dist/modules/settings/setup.routes.d.ts.map +1 -0
  92. package/dist/modules/settings/setup.routes.js +292 -0
  93. package/dist/modules/settings/setup.routes.js.map +1 -0
  94. package/dist/modules/skills/index.d.ts +2 -1
  95. package/dist/modules/skills/index.d.ts.map +1 -1
  96. package/dist/modules/skills/index.js +1 -0
  97. package/dist/modules/skills/index.js.map +1 -1
  98. package/dist/modules/skills/pending-skills.service.d.ts +40 -0
  99. package/dist/modules/skills/pending-skills.service.d.ts.map +1 -0
  100. package/dist/modules/skills/pending-skills.service.js +147 -0
  101. package/dist/modules/skills/pending-skills.service.js.map +1 -0
  102. package/dist/modules/skills/skills.contract.d.ts +32 -3
  103. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  104. package/dist/modules/skills/skills.contract.js +1 -1
  105. package/dist/modules/skills/skills.routes.d.ts +3 -2
  106. package/dist/modules/skills/skills.routes.d.ts.map +1 -1
  107. package/dist/modules/skills/skills.routes.js +18 -1
  108. package/dist/modules/skills/skills.routes.js.map +1 -1
  109. package/dist/modules/skills/skills.service.d.ts +22 -0
  110. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  111. package/dist/modules/skills/skills.service.js +24 -12
  112. package/dist/modules/skills/skills.service.js.map +1 -1
  113. package/dist/modules/tool-manuals/index.d.ts +1 -1
  114. package/dist/modules/tool-manuals/index.d.ts.map +1 -1
  115. package/dist/modules/tool-manuals/index.js.map +1 -1
  116. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +47 -2
  117. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  118. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  119. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +4 -0
  120. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  121. package/dist/modules/tool-manuals/tool-manuals.routes.js +23 -0
  122. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  123. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +3 -1
  124. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  125. package/dist/modules/tool-manuals/tool-manuals.service.js +84 -17
  126. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  127. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  128. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -2
  129. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  130. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  131. package/dist/modules/workflow/agent-tools/workflow.tools.js +4 -2
  132. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  133. package/dist/modules/workflow/event-bus.d.ts +11 -0
  134. package/dist/modules/workflow/event-bus.d.ts.map +1 -1
  135. package/dist/modules/workflow/event-bus.js +22 -0
  136. package/dist/modules/workflow/event-bus.js.map +1 -1
  137. package/dist/modules/workflow/file-lock.service.d.ts +9 -0
  138. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  139. package/dist/modules/workflow/file-lock.service.js +16 -0
  140. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  141. package/dist/modules/workflow/git/branch-name.d.ts +1 -1
  142. package/dist/modules/workflow/git/branch-name.d.ts.map +1 -1
  143. package/dist/modules/workflow/git/branch-name.js +1 -1
  144. package/dist/modules/workflow/git/branch-name.js.map +1 -1
  145. package/dist/modules/workflow/git/git.service.d.ts +31 -2
  146. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  147. package/dist/modules/workflow/git/git.service.js +144 -52
  148. package/dist/modules/workflow/git/git.service.js.map +1 -1
  149. package/dist/modules/workflow/git/pull-request.service.d.ts +12 -6
  150. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  151. package/dist/modules/workflow/git/pull-request.service.js +19 -9
  152. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  153. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  154. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  155. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  156. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  157. package/dist/modules/workflow/workflow.routes.js +95 -14
  158. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  159. package/dist/modules/workflow/workflow.service.d.ts +92 -3
  160. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  161. package/dist/modules/workflow/workflow.service.js +381 -108
  162. package/dist/modules/workflow/workflow.service.js.map +1 -1
  163. package/dist/modules/workspace/bevel-ignore.d.ts +5 -0
  164. package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -1
  165. package/dist/modules/workspace/bevel-ignore.js +5 -1
  166. package/dist/modules/workspace/bevel-ignore.js.map +1 -1
  167. package/dist/modules/workspace/kb-seed.interface.d.ts +1 -1
  168. package/dist/modules/workspace/kb-seed.service.d.ts +60 -5
  169. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  170. package/dist/modules/workspace/kb-seed.service.js +228 -51
  171. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  172. package/dist/modules/workspace/workspace.routes.d.ts +2 -1
  173. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  174. package/dist/modules/workspace/workspace.routes.js +51 -18
  175. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  176. package/dist/modules/workspace/workspace.service.d.ts +16 -4
  177. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  178. package/dist/modules/workspace/workspace.service.js +36 -9
  179. package/dist/modules/workspace/workspace.service.js.map +1 -1
  180. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  181. package/dist/modules/workspace/workspace.tools.js +8 -3
  182. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  183. package/dist/shared/kb-layout.test.js +2 -2
  184. package/dist/test-setup.d.ts +2 -0
  185. package/dist/test-setup.d.ts.map +1 -0
  186. package/dist/test-setup.js +18 -0
  187. package/dist/test-setup.js.map +1 -0
  188. package/kb-template/.bevelignore +6 -2
  189. package/kb-template/AGENTS.md +272 -0
  190. package/kb-template/KnowledgeBase/How to get started.md +65 -0
  191. package/kb-template/access.md +36 -36
  192. package/migrations/0002_user_onboarding_done.sql +1 -0
  193. package/migrations/0003_deployment_settings.sql +9 -0
  194. package/migrations/meta/0001_snapshot.json +1486 -0
  195. package/migrations/meta/0002_snapshot.json +1493 -0
  196. package/migrations/meta/0003_snapshot.json +1552 -0
  197. package/migrations/meta/_journal.json +15 -1
  198. package/package.json +5 -4
  199. package/src/__tests__/branch-model.test.ts +79 -0
  200. package/src/__tests__/core-config.admin.test.ts +114 -0
  201. package/src/__tests__/database-url.test.ts +70 -0
  202. package/src/core/core-ports.ts +15 -0
  203. package/src/core/create-core-server.ts +55 -6
  204. package/src/core/create-core-services.ts +201 -36
  205. package/src/core-config.ts +139 -59
  206. package/src/modules/access/__tests__/access-declarations.test.ts +195 -0
  207. package/src/modules/access/__tests__/access-md-format.test.ts +191 -0
  208. package/src/modules/access/__tests__/access-splice.test.ts +21 -0
  209. package/src/modules/access/__tests__/access.routes.overrides.test.ts +214 -0
  210. package/src/modules/access/access-control.interface.ts +11 -0
  211. package/src/modules/access/access-control.service.ts +113 -15
  212. package/src/modules/access/access-declarations.ts +251 -0
  213. package/src/modules/access/access-mutation.service.ts +4 -4
  214. package/src/modules/access/access-splice.ts +64 -21
  215. package/src/modules/access/access.routes.ts +61 -0
  216. package/src/modules/access/creator-access.ts +22 -2
  217. package/src/modules/admin/admin-access.service.ts +4 -3
  218. package/src/modules/auth/__tests__/auth.service.test.ts +57 -0
  219. package/src/modules/auth/__tests__/onboarding-done.route.test.ts +147 -0
  220. package/src/modules/auth/auth.routes.ts +50 -1
  221. package/src/modules/auth/auth.service.ts +55 -17
  222. package/src/modules/database/core-schema.ts +46 -0
  223. package/src/modules/groups/__tests__/group-index.service.test.ts +225 -0
  224. package/src/modules/groups/__tests__/group-provision.service.test.ts +247 -0
  225. package/src/modules/groups/__tests__/groups.routes.test.ts +493 -0
  226. package/src/modules/groups/__tests__/join-proposals.test.ts +101 -0
  227. package/src/modules/groups/__tests__/join-requests.service.test.ts +175 -0
  228. package/src/modules/groups/group-provision.service.ts +328 -0
  229. package/src/modules/groups/groups.contract.ts +107 -0
  230. package/src/modules/groups/groups.routes.ts +433 -0
  231. package/src/modules/groups/groups.service.ts +193 -0
  232. package/src/modules/groups/index.ts +17 -0
  233. package/src/modules/groups/join-proposals.ts +114 -0
  234. package/src/modules/groups/join-requests.service.ts +185 -0
  235. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +283 -0
  236. package/src/modules/secrets-vault/secrets-vault.routes.ts +68 -12
  237. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +256 -0
  238. package/src/modules/settings/__tests__/setup.routes.test.ts +201 -0
  239. package/src/modules/settings/deployment-settings.service.ts +419 -0
  240. package/src/modules/settings/setup.routes.ts +317 -0
  241. package/src/modules/skills/__tests__/pending-skills.service.test.ts +240 -0
  242. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  243. package/src/modules/skills/index.ts +3 -0
  244. package/src/modules/skills/pending-skills.service.ts +168 -0
  245. package/src/modules/skills/skills.contract.ts +34 -3
  246. package/src/modules/skills/skills.routes.ts +23 -2
  247. package/src/modules/skills/skills.service.ts +24 -16
  248. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +262 -0
  249. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  250. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +61 -23
  251. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -156
  252. package/src/modules/tool-manuals/index.ts +2 -0
  253. package/src/modules/tool-manuals/tool-manuals.contract.ts +50 -2
  254. package/src/modules/tool-manuals/tool-manuals.routes.ts +21 -0
  255. package/src/modules/tool-manuals/tool-manuals.service.ts +86 -17
  256. package/src/modules/tool-manuals/tool-manuals.tools.ts +206 -202
  257. package/src/modules/workflow/__tests__/event-bus.test.ts +35 -0
  258. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +251 -5
  259. package/src/modules/workflow/agent-tools/workflow.tools.ts +4 -2
  260. package/src/modules/workflow/event-bus.ts +25 -0
  261. package/src/modules/workflow/file-lock.service.ts +17 -0
  262. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +9 -49
  263. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +101 -0
  264. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +219 -211
  265. package/src/modules/workflow/git/__tests__/git.service.fileContentsAtCommit.test.ts +132 -0
  266. package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +4 -2
  267. package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +275 -272
  268. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +5 -0
  269. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +44 -0
  270. package/src/modules/workflow/git/branch-name.ts +5 -1
  271. package/src/modules/workflow/git/git.service.ts +159 -62
  272. package/src/modules/workflow/git/pull-request.service.ts +22 -11
  273. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +4 -1
  274. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  275. package/src/modules/workflow/workflow.routes.ts +91 -13
  276. package/src/modules/workflow/workflow.service.ts +353 -13
  277. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +255 -24
  278. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -233
  279. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -176
  280. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -303
  281. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -276
  282. package/src/modules/workspace/__tests__/workspace.tools.test.ts +14 -6
  283. package/src/modules/workspace/bevel-ignore.ts +66 -62
  284. package/src/modules/workspace/kb-seed.interface.ts +1 -1
  285. package/src/modules/workspace/kb-seed.service.ts +247 -53
  286. package/src/modules/workspace/workspace.routes.ts +54 -18
  287. package/src/modules/workspace/workspace.service.ts +45 -7
  288. package/src/modules/workspace/workspace.tools.ts +922 -917
  289. package/src/shared/__tests__/frontmatter-id.test.ts +53 -0
  290. package/src/shared/__tests__/join-request.test.ts +96 -0
  291. package/src/shared/__tests__/kb-layout.group.test.ts +45 -0
  292. package/src/shared/kb-layout.test.ts +2 -2
  293. package/src/test-setup.ts +18 -0
  294. package/kb-template/Agents/README.md +0 -38
  295. package/kb-template/CLAUDE.md +0 -419
  296. package/kb-template/Data/.gitkeep +0 -0
  297. package/kb-template/Data/README.md +0 -39
  298. package/kb-template/KnowledgeBase/README.md +0 -37
  299. package/kb-template/Pipelines/.gitkeep +0 -0
  300. package/kb-template/Pipelines/README.md +0 -38
  301. package/kb-template/Skills/.gitkeep +0 -0
  302. package/kb-template/Skills/README.md +0 -32
  303. package/kb-template/Tools/.gitkeep +0 -0
  304. package/kb-template/Tools/README.md +0 -42
  305. /package/kb-template/{Agents → Groups}/.gitkeep +0 -0
@@ -0,0 +1,191 @@
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 type { WorkspaceService } from '../../workspace/workspace.service.js';
7
+ import {
8
+ AccessControlService,
9
+ accessMdDeclaresBodyRules,
10
+ accessMdSelfEntries,
11
+ parseAccessFile,
12
+ } from '../access-control.service.js';
13
+ import { spliceGrant, spliceRevoke } from '../access-splice.js';
14
+
15
+ /**
16
+ * The two-format `access.md` story:
17
+ *
18
+ * - LEGACY: the frontmatter carries the FOLDER's rules; the file cannot
19
+ * govern itself (its readability follows the folder chain).
20
+ * - NEW: the body carries the folder's rules; the frontmatter — like every
21
+ * other file's — is about the FILE itself. `read: everyone` there makes
22
+ * the group discoverable (anyone may open the access.md and see who runs
23
+ * the folder) while the folder's contents stay locked.
24
+ *
25
+ * Compat rule under test: a file whose body is NOT parsable as rules resolves
26
+ * its frontmatter for the folder, exactly as before.
27
+ */
28
+
29
+ const KB = 'knowledge-base';
30
+
31
+ const ROLES_YAML = `roles:
32
+ Admin:
33
+ - razvan@bevel.software
34
+ GTM Team:
35
+ - felix@example.com
36
+ `;
37
+
38
+ /** New-format group access.md: discoverable by everyone, readable by GTM Team. */
39
+ const NEW_FORMAT = `---
40
+ read:
41
+ - everyone
42
+ ---
43
+ read:
44
+ - GTM Team
45
+ write:
46
+ - GTM Team
47
+ `;
48
+
49
+ const LEGACY_FORMAT = `---
50
+ read:
51
+ - GTM Team
52
+ write:
53
+ - GTM Team
54
+ ---
55
+ Some prose explaining this folder.
56
+ `;
57
+
58
+ describe('access.md format detection + parsing', () => {
59
+ it('detects body rules (new format) and parses the BODY for the folder', () => {
60
+ expect(accessMdDeclaresBodyRules(NEW_FORMAT)).toBe(true);
61
+ const parsed = parseAccessFile(NEW_FORMAT, 'Groups/GTM/access.md');
62
+ expect(parsed.ok).toBe(true);
63
+ if (!parsed.ok) return;
64
+ expect(parsed.file.entries.read).toEqual([
65
+ { kind: 'role', role: 'gtm team', displayRole: 'GTM Team', deny: false },
66
+ ]);
67
+ // The frontmatter's `everyone` grant is NOT a folder rule.
68
+ expect(parsed.file.entries.read.some((e) => e.kind === 'role' && e.role === 'everyone')).toBe(
69
+ false,
70
+ );
71
+ });
72
+
73
+ it('a prose body stays legacy: the frontmatter governs the folder', () => {
74
+ expect(accessMdDeclaresBodyRules(LEGACY_FORMAT)).toBe(false);
75
+ const parsed = parseAccessFile(LEGACY_FORMAT, 'access.md');
76
+ expect(parsed.ok).toBe(true);
77
+ if (!parsed.ok) return;
78
+ expect(parsed.file.entries.read).toEqual([
79
+ { kind: 'role', role: 'gtm team', displayRole: 'GTM Team', deny: false },
80
+ ]);
81
+ });
82
+
83
+ it('an empty body stays legacy', () => {
84
+ expect(accessMdDeclaresBodyRules('---\nread:\n - GTM Team\n---\n')).toBe(false);
85
+ });
86
+
87
+ it('a body that names a verb but is malformed is a hard ERROR, never a silent fallback', () => {
88
+ // Falling back to the frontmatter here would hand the folder to the
89
+ // (possibly `read: everyone`) self-rules — a typo must fail loudly.
90
+ const typo = '---\nread:\n - everyone\n---\nread: GTM Team\n';
91
+ expect(accessMdDeclaresBodyRules(typo)).toBe(true);
92
+ const parsed = parseAccessFile(typo, 'Groups/GTM/access.md');
93
+ expect(parsed.ok).toBe(false);
94
+ if (parsed.ok) return;
95
+ expect(parsed.errors.join(' ')).toContain("'read:' must be a list");
96
+ });
97
+
98
+ it('self entries: frontmatter in the new format, null for legacy', () => {
99
+ const self = accessMdSelfEntries(NEW_FORMAT);
100
+ expect(self?.read).toEqual([
101
+ { kind: 'role', role: 'everyone', displayRole: 'everyone', deny: false },
102
+ ]);
103
+ expect(accessMdSelfEntries(LEGACY_FORMAT)).toBeNull();
104
+ });
105
+ });
106
+
107
+ describe('access.md splice targeting', () => {
108
+ const FELIX = { kind: 'user' as const, email: 'ali@x.eu', displayName: 'Ali Baba' };
109
+
110
+ it("target 'folder' on a new-format file lands the grant in the BODY", () => {
111
+ const r = spliceGrant(NEW_FORMAT, 'read', FELIX, { target: 'folder' });
112
+ expect(r.changed).toBe(true);
113
+ const parsed = parseAccessFile(r.text, 'Groups/GTM/access.md');
114
+ expect(parsed.ok && parsed.file.entries.read.some((e) => e.kind === 'user' && e.email === 'ali@x.eu')).toBe(true);
115
+ // The self-frontmatter is untouched.
116
+ expect(accessMdSelfEntries(r.text)?.read).toEqual([
117
+ { kind: 'role', role: 'everyone', displayRole: 'everyone', deny: false },
118
+ ]);
119
+ });
120
+
121
+ it("target 'folder' on a legacy file lands the grant in the frontmatter (as before)", () => {
122
+ const r = spliceGrant(LEGACY_FORMAT, 'read', FELIX, { target: 'folder' });
123
+ expect(r.changed).toBe(true);
124
+ const parsed = parseAccessFile(r.text, 'access.md');
125
+ expect(parsed.ok && parsed.file.entries.read.some((e) => e.kind === 'user' && e.email === 'ali@x.eu')).toBe(true);
126
+ // Still legacy — the prose body did not become rules.
127
+ expect(accessMdDeclaresBodyRules(r.text)).toBe(false);
128
+ });
129
+
130
+ it("default target ('node') on a new-format file edits the SELF frontmatter", () => {
131
+ const r = spliceGrant(NEW_FORMAT, 'read', FELIX);
132
+ expect(r.changed).toBe(true);
133
+ expect(accessMdSelfEntries(r.text)?.read.some((e) => e.kind === 'user' && e.email === 'ali@x.eu')).toBe(true);
134
+ // Folder rules untouched.
135
+ const parsed = parseAccessFile(r.text, 'Groups/GTM/access.md');
136
+ expect(parsed.ok && parsed.file.entries.read.some((e) => e.kind === 'user')).toBe(false);
137
+ });
138
+
139
+ it("revoke with target 'folder' removes from the body and round-trips", () => {
140
+ const granted = spliceGrant(NEW_FORMAT, 'read', FELIX, { target: 'folder' }).text;
141
+ const revoked = spliceRevoke(granted, 'read', FELIX, { target: 'folder' });
142
+ expect(revoked.changed).toBe(true);
143
+ const parsed = parseAccessFile(revoked.text, 'Groups/GTM/access.md');
144
+ expect(parsed.ok && parsed.file.entries.read.some((e) => e.kind === 'user')).toBe(false);
145
+ });
146
+ });
147
+
148
+ describe('end-to-end resolution over a new-format group', () => {
149
+ let root: string;
150
+ const workspaceId = 'ws-groups-fmt';
151
+
152
+ beforeEach(async () => {
153
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-accessfmt-'));
154
+ });
155
+ afterEach(async () => {
156
+ await fs.rm(root, { recursive: true, force: true });
157
+ });
158
+
159
+ async function seed(): Promise<AccessControlService> {
160
+ const workspaceDir = path.join(root, workspaceId);
161
+ const repo = path.join(workspaceDir, KB);
162
+ await fs.mkdir(path.join(repo, 'Groups', 'GTM'), { recursive: true });
163
+ await fs.writeFile(path.join(repo, 'roles.yaml'), ROLES_YAML);
164
+ await fs.writeFile(path.join(repo, 'access.md'), '---\nwrite:\n - Admin\n---\n');
165
+ await fs.writeFile(path.join(repo, 'Groups', 'GTM', 'access.md'), NEW_FORMAT);
166
+ await fs.mkdir(path.join(repo, 'Groups', 'GTM', 'outreach'), { recursive: true });
167
+ await fs.writeFile(path.join(repo, 'Groups', 'GTM', 'outreach', 'SKILL.md'), '# outreach\n');
168
+ const stub = {
169
+ getWorkspacePath: async (id: string) => {
170
+ if (id !== workspaceId) throw new Error(`unexpected workspace ${id}`);
171
+ return workspaceDir;
172
+ },
173
+ } as unknown as WorkspaceService;
174
+ return new AccessControlService(stub, KB);
175
+ }
176
+
177
+ it('a NON-member can read the access.md itself (discovery) but nothing inside the folder', async () => {
178
+ const svc = await seed();
179
+ const outsider = 'sara@example.com';
180
+ expect(await svc.canRead(workspaceId, outsider, 'Groups/GTM/access.md')).toBe(true);
181
+ expect(await svc.canRead(workspaceId, outsider, 'Groups/GTM/outreach/SKILL.md')).toBe(false);
182
+ });
183
+
184
+ it('a member reads both; body rules (not frontmatter) decide the folder', async () => {
185
+ const svc = await seed();
186
+ const member = 'felix@example.com';
187
+ expect(await svc.canRead(workspaceId, member, 'Groups/GTM/access.md')).toBe(true);
188
+ expect(await svc.canRead(workspaceId, member, 'Groups/GTM/outreach/SKILL.md')).toBe(true);
189
+ expect(await svc.canWrite(workspaceId, member, 'Groups/GTM/outreach/SKILL.md')).toBe(true);
190
+ });
191
+ });
@@ -199,6 +199,27 @@ describe('validatePrincipal — injection guard', () => {
199
199
  ).toThrow(AccessSpliceError);
200
200
  });
201
201
 
202
+ it.each([['\n'], ['\r'], ['\r\n'], [''], ['']])(
203
+ 'rejects a display name carrying the control char %j (frontmatter injection)',
204
+ (ch) => {
205
+ expect(() =>
206
+ validatePrincipal({
207
+ kind: 'user',
208
+ email: 'a@b.com',
209
+ // Interior, so `trim()` cannot quietly rescue it on the way through.
210
+ displayName: `Ali${ch}read:${ch} - everyone`,
211
+ }),
212
+ ).toThrow(AccessSpliceError);
213
+ },
214
+ );
215
+
216
+ it('trims a SURROUNDING newline rather than rejecting, and renders the trimmed name', () => {
217
+ // The guard runs on the trimmed name and the trimmed name is what gets
218
+ // returned — so a stray edge newline can never reach a rendered line.
219
+ const p = validatePrincipal({ kind: 'user', email: 'a@b.com', displayName: '\n Ali \n' });
220
+ expect(p.kind === 'user' && p.displayName).toBe('Ali');
221
+ });
222
+
202
223
  it('rejects a group name with a colon (would forge a key)', () => {
203
224
  expect(() => validatePrincipal({ kind: 'role', role: 'write:\n - everyone' })).toThrow(
204
225
  AccessSpliceError,
@@ -0,0 +1,214 @@
1
+ import type { Server } from 'node:http';
2
+ import type { AddressInfo } from 'node:net';
3
+ import express from 'express';
4
+ import { describe, it, expect, afterEach, vi } from 'vitest';
5
+ import type { FileTreeEntry } from '@bevel-software/platform-shared';
6
+
7
+ import type { IAccessControl } from '../access-control.interface.js';
8
+ import type { WorkspaceService } from '../../workspace/workspace.service.js';
9
+ import type { AuthService } from '../../auth/auth.service.js';
10
+ import type { WorkflowService } from '../../workflow/workflow.service.js';
11
+ import type { WorkflowEventBus } from '../../workflow/event-bus.js';
12
+ import type { Database } from '../../database/connection.js';
13
+ import { createAccessRoutes } from '../access.routes.js';
14
+
15
+ /**
16
+ * HTTP contract for `GET /access/overrides`. The scan itself is covered in
17
+ * `access-declarations.test.ts`; what is under test here is the DISCLOSURE
18
+ * boundary — the folder-read gate, the per-row read filter, and the path
19
+ * validation that stops a caller pointing the scan outside the KB.
20
+ */
21
+
22
+ const USER = { id: 'u-1', email: 'alice@bevel.software', name: 'Alice' };
23
+ const WS = 'main';
24
+ const KB = 'knowledge-base';
25
+
26
+ function buildTree(paths: string[]): FileTreeEntry {
27
+ const root: FileTreeEntry = { name: '', relativePath: '', type: 'directory', children: [] };
28
+ for (const wsPath of paths) {
29
+ const segments = wsPath.split('/');
30
+ let node = root;
31
+ segments.forEach((segment, i) => {
32
+ const isLeaf = i === segments.length - 1;
33
+ let child = node.children?.find((c) => c.name === segment);
34
+ if (!child) {
35
+ child = {
36
+ name: segment,
37
+ relativePath: segments.slice(0, i + 1).join('/'),
38
+ type: isLeaf ? 'file' : 'directory',
39
+ ...(isLeaf ? {} : { children: [] }),
40
+ };
41
+ node.children?.push(child);
42
+ }
43
+ node = child;
44
+ });
45
+ }
46
+ return root;
47
+ }
48
+
49
+ interface Harness {
50
+ server: Server;
51
+ baseUrl: string;
52
+ canRead: ReturnType<typeof vi.fn>;
53
+ canReadBatch: ReturnType<typeof vi.fn>;
54
+ }
55
+
56
+ async function makeHarness(opts: {
57
+ files?: Record<string, string>;
58
+ /** Folder-level read verdict. Defaults to readable. */
59
+ canRead?: (repoRelPath: string) => boolean;
60
+ /** Per-row read verdict on `governs`. Defaults to readable. */
61
+ rowReadable?: (repoRelPath: string) => boolean;
62
+ }): Promise<Harness> {
63
+ const files = opts.files ?? {};
64
+
65
+ const canRead = vi.fn(async (_w: string, _e: string, p: string) =>
66
+ opts.canRead ? opts.canRead(p) : true,
67
+ );
68
+ const canReadBatch = vi.fn(async (_w: string, _e: string, paths: string[]) => {
69
+ const m = new Map<string, boolean>();
70
+ for (const p of paths) m.set(p, opts.rowReadable ? opts.rowReadable(p) : true);
71
+ return m;
72
+ });
73
+
74
+ const accessControl = { canRead, canReadBatch } as unknown as IAccessControl;
75
+
76
+ const workspaceService = {
77
+ listFiles: vi.fn(async () => buildTree(Object.keys(files))),
78
+ readFile: vi.fn(async (_id: string, wsRel: string) => {
79
+ const text = files[wsRel];
80
+ if (text === undefined) throw new Error(`ENOENT ${wsRel}`);
81
+ return text;
82
+ }),
83
+ getOrCreateForBranch: vi.fn(async () => ({ id: WS, name: WS, kbDirName: KB })),
84
+ } as unknown as WorkspaceService;
85
+
86
+ const authService = { getUserById: vi.fn(async () => USER) } as unknown as AuthService;
87
+ const workflowService = {} as unknown as WorkflowService;
88
+ const eventBus = { emit: vi.fn() } as unknown as WorkflowEventBus;
89
+ const db = {} as unknown as Database;
90
+
91
+ const app = express();
92
+ app.use(express.json());
93
+ app.use('/api', (req, _res, next) => {
94
+ (req as unknown as { userId: string }).userId = USER.id;
95
+ next();
96
+ });
97
+ app.use(
98
+ '/api',
99
+ createAccessRoutes(accessControl, workspaceService, authService, workflowService, eventBus, db, KB),
100
+ );
101
+
102
+ const server = await new Promise<Server>((resolve) => {
103
+ const s = app.listen(0, () => resolve(s));
104
+ });
105
+ const addr = server.address() as AddressInfo;
106
+ return { server, baseUrl: `http://127.0.0.1:${addr.port}`, canRead, canReadBatch };
107
+ }
108
+
109
+ function close(s: Server): Promise<void> {
110
+ return new Promise((resolve, reject) => s.close((e) => (e ? reject(e) : resolve())));
111
+ }
112
+
113
+ const fm = (body: string) => `---\n${body}\n---\n\n# Body\n`;
114
+
115
+ const FILES = {
116
+ [`${KB}/Groups/GTM/access.md`]: fm('read:\n - GTM Team'),
117
+ [`${KB}/Groups/GTM/battlecards/access.md`]: fm('read:\n - deny everyone'),
118
+ [`${KB}/Groups/GTM/slack.tool`]: fm('owner: Ali <ali@bevel.software>'),
119
+ };
120
+
121
+ describe('GET /access/overrides', () => {
122
+ let h: Harness | null = null;
123
+ afterEach(async () => {
124
+ if (h) await close(h.server);
125
+ h = null;
126
+ });
127
+
128
+ const get = (path: string) =>
129
+ fetch(
130
+ `${h!.baseUrl}/api/workspace/${encodeURIComponent(WS)}/access/overrides?path=${encodeURIComponent(path)}`,
131
+ );
132
+
133
+ it('returns the declarations under a folder the caller can read', async () => {
134
+ h = await makeHarness({ files: FILES });
135
+
136
+ const res = await get('Groups/GTM');
137
+ expect(res.status).toBe(200);
138
+ expect(await res.json()).toEqual({
139
+ truncated: false,
140
+ overrides: [
141
+ {
142
+ path: 'Groups/GTM/battlecards/access.md',
143
+ governs: 'Groups/GTM/battlecards',
144
+ source: 'access-md',
145
+ entries: [{ verb: 'read', deny: true, principal: { kind: 'everyone' } }],
146
+ },
147
+ {
148
+ path: 'Groups/GTM/slack.tool',
149
+ governs: 'Groups/GTM/slack.tool',
150
+ source: 'frontmatter',
151
+ entries: [
152
+ {
153
+ verb: 'owner',
154
+ deny: false,
155
+ principal: { kind: 'user', email: 'ali@bevel.software', name: 'Ali' },
156
+ },
157
+ ],
158
+ },
159
+ ],
160
+ });
161
+ });
162
+
163
+ it('403s a caller who cannot read the folder', async () => {
164
+ h = await makeHarness({ files: FILES, canRead: () => false });
165
+
166
+ const res = await get('Groups/GTM');
167
+ expect(res.status).toBe(403);
168
+ expect(await res.json()).toEqual({ error: 'You do not have access to this folder.' });
169
+ });
170
+
171
+ it('drops rows governing something the caller cannot read', async () => {
172
+ h = await makeHarness({
173
+ files: FILES,
174
+ rowReadable: (p) => p !== 'Groups/GTM/battlecards',
175
+ });
176
+
177
+ const res = await get('Groups/GTM');
178
+ const body = (await res.json()) as { overrides: { path: string }[] };
179
+ expect(body.overrides.map((o) => o.path)).toEqual(['Groups/GTM/slack.tool']);
180
+ });
181
+
182
+ it('accepts a workspace-relative path exactly like a repo-relative one', async () => {
183
+ h = await makeHarness({ files: FILES });
184
+
185
+ const repoRel = (await (await get('Groups/GTM')).json()) as unknown;
186
+ const wsRel = (await (await get(`${KB}/Groups/GTM`)).json()) as unknown;
187
+ expect(wsRel).toEqual(repoRel);
188
+ expect(h.canRead).toHaveBeenCalledWith(WS, USER.email, 'Groups/GTM');
189
+ });
190
+
191
+ it('400s a path that escapes the KB repo', async () => {
192
+ h = await makeHarness({ files: FILES });
193
+
194
+ expect((await get(`${KB}/../tmp`)).status).toBe(400);
195
+ expect((await get('/etc/passwd')).status).toBe(400);
196
+ });
197
+
198
+ it('400s a target that is a file, not a folder', async () => {
199
+ h = await makeHarness({ files: FILES });
200
+
201
+ const res = await get('Groups/GTM/slack.tool');
202
+ expect(res.status).toBe(400);
203
+ expect(await res.json()).toEqual({ error: 'path must be a folder' });
204
+ });
205
+
206
+ it('400s a missing path parameter', async () => {
207
+ h = await makeHarness({ files: FILES });
208
+
209
+ const res = await fetch(
210
+ `${h.baseUrl}/api/workspace/${encodeURIComponent(WS)}/access/overrides`,
211
+ );
212
+ expect(res.status).toBe(400);
213
+ });
214
+ });
@@ -136,6 +136,17 @@ export interface IAccessControl {
136
136
  relativePath: string,
137
137
  ): Promise<boolean>;
138
138
 
139
+ /**
140
+ * Batched `canOwner` — one config load per call, same `owner:`-lists-only
141
+ * resolution (no admin rescue). Used by group enumeration to answer "which
142
+ * of these folders does the caller own?" without one model load per group.
143
+ */
144
+ canOwnerBatch(
145
+ workspaceId: string,
146
+ userEmail: string,
147
+ relativePaths: string[],
148
+ ): Promise<Map<string, boolean>>;
149
+
139
150
  /**
140
151
  * The set of principals (roles + direct users) that own this path — i.e.
141
152
  * who to contact about it. Resolved from `owner:` lists only. Used by the
@@ -157,7 +157,7 @@ export interface UserEntry {
157
157
 
158
158
  export type ParsedEntry = RoleEntry | UserEntry;
159
159
 
160
- interface AccessFile {
160
+ export interface AccessFile {
161
161
  /** repo-relative POSIX path — e.g. `access.md`, `Knowledge/Sales/access.md`. */
162
162
  path: string;
163
163
  /** repo-relative POSIX directory — `''` for root. */
@@ -353,6 +353,16 @@ export function extractFrontmatter(
353
353
  return { ok: false, error: 'unterminated frontmatter — no closing `---` found' };
354
354
  }
355
355
 
356
+ /** The text AFTER the closing frontmatter fence ('' when there is no fence). */
357
+ export function bodyAfterFrontmatter(text: string): string {
358
+ const lines = text.split(/\r?\n/);
359
+ if (lines.length === 0 || lines[0].trim() !== '---') return '';
360
+ for (let i = 1; i < lines.length; i++) {
361
+ if (lines[i].trim() === '---') return lines.slice(i + 1).join('\n');
362
+ }
363
+ return '';
364
+ }
365
+
356
366
  // ---------------------------------------------------------------------------
357
367
  // Canonicalisation
358
368
  // ---------------------------------------------------------------------------
@@ -488,18 +498,86 @@ export function parseRolesYaml(
488
498
  return { ok: true, index };
489
499
  }
490
500
 
491
- function parseAccessFile(
501
+ /**
502
+ * Does an `access.md` body declare access rules — i.e. is the file in the NEW
503
+ * (body-governs-the-folder) format?
504
+ *
505
+ * The two-format story: historically the FRONTMATTER carried the folder's
506
+ * rules and the file could not govern itself. The new format follows the
507
+ * convention every other file uses — frontmatter is about the FILE (who may
508
+ * read/write `access.md` itself), and the content (the body) is the folder's
509
+ * rules. The compat rule, applied per file: **when the body is not parsable as
510
+ * rules, the frontmatter is resolved for the folder as before.**
511
+ *
512
+ * "Parsable as rules" is deliberately shallow — the body YAML-parses to a
513
+ * mapping that names at least one known verb. A prose body (the repo-root
514
+ * README-style file) fails the YAML parse or carries no verb key and stays
515
+ * legacy. A body that DOES name a verb has claimed to be rules: shape errors
516
+ * inside it (a scalar verb, a malformed entry) are then hard parse ERRORS of
517
+ * the file, never a silent fallback to the frontmatter — falling back would
518
+ * let a typo in the body hand the folder to the frontmatter's (possibly
519
+ * `read: everyone`) self-rules.
520
+ */
521
+ export function accessMdDeclaresBodyRules(text: string): boolean {
522
+ const body = bodyAfterFrontmatter(text);
523
+ if (!body.trim()) return false;
524
+ const parsed = parseYamlSubset(body);
525
+ if (!parsed.ok) return false;
526
+ const root = parsed.value;
527
+ if (root == null || typeof root !== 'object' || Array.isArray(root)) return false;
528
+ return Object.keys(root as Record<string, YamlValue>).some((k) => KNOWN_VERBS_SET.has(k));
529
+ }
530
+
531
+ /**
532
+ * The access verbs an `access.md` declares FOR ITSELF — its frontmatter, and
533
+ * only in the new format (body-governed). A legacy file cannot govern itself:
534
+ * its frontmatter IS the folder's rules, so returning them as own-entries
535
+ * would double-apply them at the file scope.
536
+ *
537
+ * Parsed forgivingly (like node frontmatter): the new-format frontmatter may
538
+ * carry non-access keys, and a typo there must not make the file unreadable.
539
+ */
540
+ export function accessMdSelfEntries(text: string): Record<Verb, ParsedEntry[]> | null {
541
+ if (!accessMdDeclaresBodyRules(text)) return null;
542
+ return parseOwnAccessEntries(text);
543
+ }
544
+
545
+ /**
546
+ * Parse one `access.md`'s FOLDER rules into its per-verb entry lists.
547
+ *
548
+ * Two formats (see {@link accessMdDeclaresBodyRules}): when the body declares
549
+ * rules, the body is parsed (new format — frontmatter then governs the file
550
+ * itself via {@link accessMdSelfEntries}); otherwise the frontmatter is parsed
551
+ * (legacy format), exactly as before.
552
+ *
553
+ * Exported so read-only surfaces can report WHERE rules are declared without
554
+ * reimplementing the parse (see `access-declarations.ts`). Exporting changes
555
+ * nothing about resolution — this is the same function the resolver builds its
556
+ * model from, so a display of declarations can never drift from the rules that
557
+ * are actually enforced.
558
+ */
559
+ export function parseAccessFile(
492
560
  text: string,
493
561
  relativePath: string,
494
562
  ): { ok: true; file: AccessFile; warnings: string[] } | { ok: false; errors: string[] } {
495
- const fm = extractFrontmatter(text);
496
- if (!fm.ok) return { ok: false, errors: [`${relativePath}: ${fm.error}`] };
497
- const parsed = parseYamlSubset(fm.frontmatter);
563
+ const bodyFormat = accessMdDeclaresBodyRules(text);
564
+ let ruleSource: string;
565
+ if (bodyFormat) {
566
+ ruleSource = bodyAfterFrontmatter(text);
567
+ } else {
568
+ const fm = extractFrontmatter(text);
569
+ if (!fm.ok) return { ok: false, errors: [`${relativePath}: ${fm.error}`] };
570
+ ruleSource = fm.frontmatter;
571
+ }
572
+ const parsed = parseYamlSubset(ruleSource);
498
573
  if (!parsed.ok) return { ok: false, errors: [`${relativePath}: ${parsed.error}`] };
499
574
 
500
575
  const root = parsed.value;
501
576
  if (root == null || typeof root !== 'object' || Array.isArray(root)) {
502
- return { ok: false, errors: [`${relativePath}: frontmatter must be a mapping`] };
577
+ return {
578
+ ok: false,
579
+ errors: [`${relativePath}: ${bodyFormat ? 'body' : 'frontmatter'} must be a mapping`],
580
+ };
503
581
  }
504
582
 
505
583
  const errors: string[] = [];
@@ -1222,6 +1300,22 @@ export class AccessControlService implements IAccessControl {
1222
1300
  return hasPermissionResolved(model, 'download', userEmail, relativePath, own);
1223
1301
  }
1224
1302
 
1303
+ async canOwnerBatch(
1304
+ workspaceId: string,
1305
+ userEmail: string,
1306
+ relativePaths: string[],
1307
+ ): Promise<Map<string, boolean>> {
1308
+ const model = await this.loadModel(workspaceId);
1309
+ const repoDir = await this.repoDir(workspaceId);
1310
+ // Parallel own-entries reads, memoized — same shape as `canWriteBatch`.
1311
+ const owns = await Promise.all(relativePaths.map((p) => this.cachedOwnEntries(workspaceId, repoDir, p)));
1312
+ const result = new Map<string, boolean>();
1313
+ relativePaths.forEach((p, i) => {
1314
+ result.set(p, hasPermissionResolved(model, 'owner', userEmail, p, owns[i]));
1315
+ });
1316
+ return result;
1317
+ }
1318
+
1225
1319
  async canOwner(
1226
1320
  workspaceId: string,
1227
1321
  userEmail: string,
@@ -1601,23 +1695,25 @@ export class AccessControlService implements IAccessControl {
1601
1695
  }
1602
1696
 
1603
1697
  /**
1604
- * Read the access verbs a node file declares in its own frontmatter from the
1605
- * working tree. Returns null when the file has no per-file access config
1606
- * (the common case). `access.md` and `roles.yaml` are skipped — `access.md`
1607
- * frontmatter governs its directory (handled by the dir chain), not itself,
1608
- * and `roles.yaml` is not a node.
1698
+ * Read the access verbs a file declares FOR ITSELF from the working tree.
1699
+ * Returns null when the file has no per-file access config (the common
1700
+ * case). `roles.yaml` is never a node; an `access.md` governs itself only
1701
+ * in the new (body-governed) format, where its frontmatter is self-access —
1702
+ * see {@link accessMdSelfEntries}. A legacy `access.md` still yields null:
1703
+ * its frontmatter IS the folder rules the dir chain already applies.
1609
1704
  */
1610
1705
  private async readOwnEntries(
1611
1706
  repoDir: string,
1612
1707
  relativePath: string,
1613
1708
  ): Promise<OwnEntries | null> {
1614
- if (isAccessMdPath(relativePath) || relativePath === 'roles.yaml') return null;
1709
+ if (relativePath === 'roles.yaml') return null;
1615
1710
  let text: string;
1616
1711
  try {
1617
1712
  text = await fs.readFile(path.join(repoDir, relativePath), 'utf-8');
1618
1713
  } catch {
1619
1714
  return null;
1620
1715
  }
1716
+ if (isAccessMdPath(relativePath)) return accessMdSelfEntries(text);
1621
1717
  return parseOwnAccessEntries(text);
1622
1718
  }
1623
1719
 
@@ -1632,9 +1728,10 @@ export class AccessControlService implements IAccessControl {
1632
1728
  ref: string,
1633
1729
  relativePath: string,
1634
1730
  ): Promise<OwnEntries | null> {
1635
- if (isAccessMdPath(relativePath) || relativePath === 'roles.yaml') return null;
1731
+ if (relativePath === 'roles.yaml') return null;
1636
1732
  const text = await this.showAtRef(repoDir, ref, relativePath);
1637
1733
  if (text === null) return null;
1734
+ if (isAccessMdPath(relativePath)) return accessMdSelfEntries(text);
1638
1735
  return parseOwnAccessEntries(text);
1639
1736
  }
1640
1737
 
@@ -1654,14 +1751,15 @@ export class AccessControlService implements IAccessControl {
1654
1751
  const result = new Map<string, OwnEntries | null>();
1655
1752
  const wanted: string[] = [];
1656
1753
  for (const p of relativePaths) {
1657
- if (isAccessMdPath(p) || p === 'roles.yaml') result.set(p, null);
1754
+ if (p === 'roles.yaml') result.set(p, null);
1658
1755
  else if (!wanted.includes(p)) wanted.push(p);
1659
1756
  }
1660
1757
  if (wanted.length === 0) return result;
1661
1758
  const texts = await catFileBatch(repoDir, wanted.map((p) => `${ref}:${p}`));
1662
1759
  wanted.forEach((p, i) => {
1663
1760
  const text = texts[i];
1664
- result.set(p, text === null ? null : parseOwnAccessEntries(text));
1761
+ if (text === null) result.set(p, null);
1762
+ else result.set(p, isAccessMdPath(p) ? accessMdSelfEntries(text) : parseOwnAccessEntries(text));
1665
1763
  });
1666
1764
  return result;
1667
1765
  }