@bevel-software/platform-core-backend 0.11.1 → 0.11.2

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 (312) hide show
  1. package/dist/core/create-core-services.d.ts +1 -1
  2. package/dist/core/create-core-services.d.ts.map +1 -1
  3. package/dist/core/create-core-services.js +2 -2
  4. package/dist/core/create-core-services.js.map +1 -1
  5. package/dist/modules/access/access-control.service.d.ts +1 -253
  6. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  7. package/dist/modules/access/access-control.service.js +3 -632
  8. package/dist/modules/access/access-control.service.js.map +1 -1
  9. package/dist/modules/access/access-declarations.d.ts +2 -2
  10. package/dist/modules/access/access-declarations.d.ts.map +1 -1
  11. package/dist/modules/access/access-declarations.js +2 -2
  12. package/dist/modules/access/access-declarations.js.map +1 -1
  13. package/dist/modules/access/access-mutation.service.d.ts +3 -3
  14. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  15. package/dist/modules/access/access-mutation.service.js +3 -3
  16. package/dist/modules/access/access-mutation.service.js.map +1 -1
  17. package/dist/modules/access/access.routes.js +4 -4
  18. package/dist/modules/access/access.routes.js.map +1 -1
  19. package/dist/modules/access/admin-locked-commit.d.ts +2 -2
  20. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
  21. package/dist/modules/access/admin-locked-commit.js +2 -2
  22. package/dist/modules/access/admin-locked-commit.js.map +1 -1
  23. package/dist/modules/access/admin-route-helpers.js +1 -1
  24. package/dist/modules/access/admin-route-helpers.js.map +1 -1
  25. package/dist/modules/access/capability-registry.js +1 -1
  26. package/dist/modules/access/capability-registry.js.map +1 -1
  27. package/dist/modules/access/creator-access.d.ts +1 -45
  28. package/dist/modules/access/creator-access.d.ts.map +1 -1
  29. package/dist/modules/access/creator-access.js +4 -24
  30. package/dist/modules/access/creator-access.js.map +1 -1
  31. package/dist/modules/access/groups-admin.routes.js +1 -1
  32. package/dist/modules/access/groups-admin.routes.js.map +1 -1
  33. package/dist/modules/access/groups-admin.service.d.ts +1 -1
  34. package/dist/modules/access/groups-admin.service.d.ts.map +1 -1
  35. package/dist/modules/access/groups-admin.service.js +6 -5
  36. package/dist/modules/access/groups-admin.service.js.map +1 -1
  37. package/dist/modules/access/groups-edit.js +2 -2
  38. package/dist/modules/access/groups-edit.js.map +1 -1
  39. package/dist/modules/access/reference-scan.js +1 -1
  40. package/dist/modules/access/reference-scan.js.map +1 -1
  41. package/dist/modules/access/roles-admin.service.d.ts +1 -1
  42. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  43. package/dist/modules/access/roles-admin.service.js +8 -7
  44. package/dist/modules/access/roles-admin.service.js.map +1 -1
  45. package/dist/modules/access/roles-edit.js +1 -1
  46. package/dist/modules/access/roles-edit.js.map +1 -1
  47. package/dist/modules/access/synced-groups-committer.js +4 -4
  48. package/dist/modules/access/synced-groups-committer.js.map +1 -1
  49. package/dist/modules/access/synced-groups-writer.js +2 -2
  50. package/dist/modules/access/synced-groups-writer.js.map +1 -1
  51. package/dist/modules/access-model/access-errors.d.ts +34 -0
  52. package/dist/modules/access-model/access-errors.d.ts.map +1 -0
  53. package/dist/modules/access-model/access-errors.js +40 -0
  54. package/dist/modules/access-model/access-errors.js.map +1 -0
  55. package/dist/modules/access-model/access-grammar.d.ts +266 -0
  56. package/dist/modules/access-model/access-grammar.d.ts.map +1 -0
  57. package/dist/modules/access-model/access-grammar.js +644 -0
  58. package/dist/modules/access-model/access-grammar.js.map +1 -0
  59. package/dist/modules/access-model/access-splice.d.ts +140 -0
  60. package/dist/modules/access-model/access-splice.d.ts.map +1 -0
  61. package/dist/modules/access-model/access-splice.js +389 -0
  62. package/dist/modules/access-model/access-splice.js.map +1 -0
  63. package/dist/modules/access-model/creator.d.ts +54 -0
  64. package/dist/modules/access-model/creator.d.ts.map +1 -0
  65. package/dist/modules/access-model/creator.js +30 -0
  66. package/dist/modules/access-model/creator.js.map +1 -0
  67. package/dist/modules/access-model/group-files.d.ts +83 -0
  68. package/dist/modules/access-model/group-files.d.ts.map +1 -0
  69. package/dist/modules/access-model/group-files.js +167 -0
  70. package/dist/modules/access-model/group-files.js.map +1 -0
  71. package/dist/modules/access-model/kb-read-filter.d.ts +41 -0
  72. package/dist/modules/access-model/kb-read-filter.d.ts.map +1 -0
  73. package/dist/modules/access-model/kb-read-filter.js +60 -0
  74. package/dist/modules/access-model/kb-read-filter.js.map +1 -0
  75. package/dist/modules/access-model/render-roles-yaml.d.ts +22 -0
  76. package/dist/modules/access-model/render-roles-yaml.d.ts.map +1 -0
  77. package/dist/modules/access-model/render-roles-yaml.js +56 -0
  78. package/dist/modules/access-model/render-roles-yaml.js.map +1 -0
  79. package/dist/modules/access-model/roles-yaml-guard.d.ts +56 -0
  80. package/dist/modules/access-model/roles-yaml-guard.d.ts.map +1 -0
  81. package/dist/modules/access-model/roles-yaml-guard.js +79 -0
  82. package/dist/modules/access-model/roles-yaml-guard.js.map +1 -0
  83. package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
  84. package/dist/modules/admin/admin-access.service.js +1 -1
  85. package/dist/modules/admin/admin-access.service.js.map +1 -1
  86. package/dist/modules/diff/diff.routes.js +4 -4
  87. package/dist/modules/diff/diff.routes.js.map +1 -1
  88. package/dist/modules/diff/diff.service.d.ts +1 -1
  89. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  90. package/dist/modules/kb-fs/branch-name.d.ts +10 -0
  91. package/dist/modules/kb-fs/branch-name.d.ts.map +1 -0
  92. package/dist/modules/kb-fs/branch-name.js +76 -0
  93. package/dist/modules/kb-fs/branch-name.js.map +1 -0
  94. package/dist/modules/kb-fs/clone-config.d.ts +61 -0
  95. package/dist/modules/kb-fs/clone-config.d.ts.map +1 -0
  96. package/dist/modules/kb-fs/clone-config.js +69 -0
  97. package/dist/modules/kb-fs/clone-config.js.map +1 -0
  98. package/dist/modules/kb-fs/file-change-notifier.d.ts +38 -0
  99. package/dist/modules/kb-fs/file-change-notifier.d.ts.map +1 -0
  100. package/dist/modules/kb-fs/file-change-notifier.js +22 -0
  101. package/dist/modules/kb-fs/file-change-notifier.js.map +1 -0
  102. package/dist/modules/kb-fs/locking-filesystem.d.ts +137 -0
  103. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -0
  104. package/dist/modules/kb-fs/locking-filesystem.js +553 -0
  105. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -0
  106. package/dist/modules/kb-fs/mutex.d.ts +11 -0
  107. package/dist/modules/kb-fs/mutex.d.ts.map +1 -0
  108. package/dist/modules/kb-fs/mutex.js +23 -0
  109. package/dist/modules/kb-fs/mutex.js.map +1 -0
  110. package/dist/modules/kb-fs/read-only-filesystem.d.ts +24 -0
  111. package/dist/modules/kb-fs/read-only-filesystem.d.ts.map +1 -0
  112. package/dist/modules/kb-fs/read-only-filesystem.js +39 -0
  113. package/dist/modules/kb-fs/read-only-filesystem.js.map +1 -0
  114. package/dist/modules/plugins/join-proposals.d.ts +1 -1
  115. package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
  116. package/dist/modules/plugins/join-proposals.js +1 -1
  117. package/dist/modules/plugins/join-proposals.js.map +1 -1
  118. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
  119. package/dist/modules/plugins/join-requests.service.js +1 -1
  120. package/dist/modules/plugins/join-requests.service.js.map +1 -1
  121. package/dist/modules/plugins/plugin-provision.service.js +3 -3
  122. package/dist/modules/plugins/plugin-provision.service.js.map +1 -1
  123. package/dist/modules/plugins/plugins.routes.js +2 -2
  124. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  125. package/dist/modules/plugins/plugins.service.js +1 -1
  126. package/dist/modules/plugins/plugins.service.js.map +1 -1
  127. package/dist/modules/secrets-vault/secrets-vault.routes.js +1 -1
  128. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  129. package/dist/modules/skills/pending-skills.service.d.ts.map +1 -1
  130. package/dist/modules/skills/pending-skills.service.js +1 -1
  131. package/dist/modules/skills/pending-skills.service.js.map +1 -1
  132. package/dist/modules/skills/skills.service.js +1 -1
  133. package/dist/modules/skills/skills.service.js.map +1 -1
  134. package/dist/modules/tool-helpers/tool-context.d.ts +2 -2
  135. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  136. package/dist/modules/tool-helpers/tool-context.js +4 -4
  137. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  138. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +1 -1
  139. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  140. package/dist/modules/tool-manuals/mcp-server-edit.service.js +1 -1
  141. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  142. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +1 -1
  143. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  144. package/dist/modules/tool-manuals/tool-manuals.routes.js +1 -1
  145. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  146. package/dist/modules/tool-manuals/tool-manuals.service.js +1 -1
  147. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  148. package/dist/modules/tool-manuals/tool-manuals.tools.js +1 -1
  149. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  150. package/dist/modules/workflow/agent-tools/workflow.tools.js +1 -1
  151. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  152. package/dist/modules/workflow/file-lock.service.js +1 -1
  153. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  154. package/dist/modules/workflow/git/git.service.d.ts +2 -2
  155. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  156. package/dist/modules/workflow/git/git.service.js +5 -5
  157. package/dist/modules/workflow/git/git.service.js.map +1 -1
  158. package/dist/modules/workflow/git/pull-request.service.js +1 -1
  159. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  160. package/dist/modules/workflow/review-workflow/review-workflow.service.js +2 -2
  161. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  162. package/dist/modules/workflow/session-ontology.service.js +1 -1
  163. package/dist/modules/workflow/session-ontology.service.js.map +1 -1
  164. package/dist/modules/workflow/workflow.routes.js +2 -2
  165. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  166. package/dist/modules/workflow/workflow.service.d.ts +1 -1
  167. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  168. package/dist/modules/workflow/workflow.service.js +4 -4
  169. package/dist/modules/workflow/workflow.service.js.map +1 -1
  170. package/dist/modules/workspace/startup/kb-startup-runner.js +1 -1
  171. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  172. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js +1 -1
  173. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js.map +1 -1
  174. package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts +1 -1
  175. package/dist/modules/workspace/startup/steps/roles-yaml.step.js +2 -2
  176. package/dist/modules/workspace/startup/steps/roles-yaml.step.js.map +1 -1
  177. package/dist/modules/workspace/startup/steps/seed-tree.js +1 -1
  178. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  179. package/dist/modules/workspace/workspace.routes.d.ts +1 -1
  180. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  181. package/dist/modules/workspace/workspace.routes.js +5 -4
  182. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  183. package/dist/modules/workspace/workspace.service.d.ts +2 -9
  184. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  185. package/dist/modules/workspace/workspace.service.js +14 -15
  186. package/dist/modules/workspace/workspace.service.js.map +1 -1
  187. package/dist/modules/workspace/workspace.tools.js +3 -3
  188. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  189. package/dist/shared/domain-errors.d.ts +202 -0
  190. package/dist/shared/domain-errors.d.ts.map +1 -0
  191. package/dist/shared/domain-errors.js +303 -0
  192. package/dist/shared/domain-errors.js.map +1 -0
  193. package/dist/shared/workspace-id.d.ts +27 -0
  194. package/dist/shared/workspace-id.d.ts.map +1 -0
  195. package/dist/shared/workspace-id.js +36 -0
  196. package/dist/shared/workspace-id.js.map +1 -0
  197. package/package.json +3 -3
  198. package/src/core/create-core-services.ts +2 -2
  199. package/src/modules/access/__tests__/access-control.service.test.ts +1 -1
  200. package/src/modules/access/__tests__/access-groups.test.ts +6 -5
  201. package/src/modules/access/__tests__/access-md-format.test.ts +3 -3
  202. package/src/modules/access/__tests__/access-mutation.service.test.ts +1 -1
  203. package/src/modules/access/__tests__/admin-locked-commit.test.ts +1 -1
  204. package/src/modules/access/__tests__/admin-route-helpers.test.ts +1 -1
  205. package/src/modules/access/__tests__/roles-admin.service.test.ts +2 -2
  206. package/src/modules/access/__tests__/roles-edit.test.ts +1 -1
  207. package/src/modules/access/__tests__/synced-groups-committer.test.ts +1 -1
  208. package/src/modules/access/__tests__/synced-groups-writer.test.ts +1 -1
  209. package/src/modules/access/access-control.service.ts +23 -768
  210. package/src/modules/access/access-declarations.ts +2 -2
  211. package/src/modules/access/access-mutation.service.ts +3 -3
  212. package/src/modules/access/access.routes.ts +5 -5
  213. package/src/modules/access/admin-locked-commit.ts +2 -2
  214. package/src/modules/access/admin-route-helpers.ts +1 -1
  215. package/src/modules/access/capability-registry.ts +1 -1
  216. package/src/modules/access/creator-access.ts +9 -72
  217. package/src/modules/access/groups-admin.routes.ts +1 -1
  218. package/src/modules/access/groups-admin.service.ts +6 -7
  219. package/src/modules/access/groups-edit.ts +2 -2
  220. package/src/modules/access/reference-scan.ts +1 -1
  221. package/src/modules/access/roles-admin.service.ts +8 -8
  222. package/src/modules/access/roles-edit.ts +1 -1
  223. package/src/modules/access/synced-groups-committer.ts +4 -4
  224. package/src/modules/access/synced-groups-writer.ts +2 -2
  225. package/src/modules/access-model/__tests__/access-grammar.test.ts +31 -0
  226. package/src/modules/{access → access-model}/__tests__/access-splice.test.ts +268 -268
  227. package/src/modules/{access → access-model}/access-errors.ts +1 -1
  228. package/src/modules/access-model/access-grammar.ts +778 -0
  229. package/src/modules/{access → access-model}/access-splice.ts +1 -1
  230. package/src/modules/access-model/creator.ts +79 -0
  231. package/src/modules/{access → access-model}/group-files.ts +1 -1
  232. package/src/modules/{access → access-model}/render-roles-yaml.ts +1 -1
  233. package/src/modules/{access → access-model}/roles-yaml-guard.ts +2 -2
  234. package/src/modules/admin/admin-access.service.ts +2 -1
  235. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +2 -2
  236. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +3 -2
  237. package/src/modules/diff/__tests__/diff.service.test.ts +3 -2
  238. package/src/modules/diff/diff.routes.ts +4 -4
  239. package/src/modules/diff/diff.service.ts +1 -1
  240. package/src/modules/{workflow/git → kb-fs}/__tests__/branch-name.test.ts +1 -1
  241. package/src/modules/{workflow → kb-fs}/__tests__/locking-filesystem.test.ts +1 -1
  242. package/src/modules/{workflow/git → kb-fs}/branch-name.ts +1 -1
  243. package/src/modules/{workflow → kb-fs}/locking-filesystem.ts +2 -2
  244. package/src/modules/plugins/__tests__/join-requests.service.test.ts +2 -4
  245. package/src/modules/plugins/__tests__/plugin-index.service.test.ts +1 -1
  246. package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
  247. package/src/modules/plugins/join-proposals.ts +1 -1
  248. package/src/modules/plugins/join-requests.service.ts +2 -1
  249. package/src/modules/plugins/plugin-provision.service.ts +3 -3
  250. package/src/modules/plugins/plugins.routes.ts +2 -2
  251. package/src/modules/plugins/plugins.service.ts +1 -1
  252. package/src/modules/secrets-vault/secrets-vault.routes.ts +1 -1
  253. package/src/modules/skills/__tests__/skills.service.test.ts +1 -1
  254. package/src/modules/skills/pending-skills.service.ts +2 -1
  255. package/src/modules/skills/skills.service.ts +1 -1
  256. package/src/modules/tool-helpers/tool-context.ts +6 -5
  257. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +1 -1
  258. package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +1 -1
  259. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +1 -1
  260. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +1 -1
  261. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +1 -1
  262. package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
  263. package/src/modules/tool-manuals/tool-manuals.routes.ts +2 -1
  264. package/src/modules/tool-manuals/tool-manuals.service.ts +1 -1
  265. package/src/modules/tool-manuals/tool-manuals.tools.ts +1 -1
  266. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
  267. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +1 -1
  268. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
  269. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +1 -1
  270. package/src/modules/workflow/agent-tools/workflow.tools.ts +1 -1
  271. package/src/modules/workflow/file-lock.service.ts +1 -1
  272. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +1 -1
  273. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
  274. package/src/modules/workflow/git/__tests__/git.service.diffFileAtCommit.test.ts +1 -1
  275. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +1 -1
  276. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +1 -1
  277. package/src/modules/workflow/git/git.service.ts +5 -5
  278. package/src/modules/workflow/git/pull-request.service.ts +1 -1
  279. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
  280. package/src/modules/workflow/review-workflow/review-workflow.service.ts +2 -2
  281. package/src/modules/workflow/session-ontology.service.ts +1 -1
  282. package/src/modules/workflow/workflow.routes.ts +2 -2
  283. package/src/modules/workflow/workflow.service.ts +5 -5
  284. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +1 -1
  285. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +1 -1
  286. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -2
  287. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +1 -1
  288. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -5
  289. package/src/modules/workspace/__tests__/workspace.service.test.ts +13 -1
  290. package/src/modules/workspace/__tests__/workspace.tools.test.ts +1 -1
  291. package/src/modules/workspace/startup/kb-startup-runner.ts +1 -1
  292. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +1 -1
  293. package/src/modules/workspace/startup/steps/groups-to-plugins.step.ts +1 -1
  294. package/src/modules/workspace/startup/steps/roles-yaml.step.ts +2 -2
  295. package/src/modules/workspace/startup/steps/seed-tree.ts +1 -1
  296. package/src/modules/workspace/workspace.routes.ts +6 -5
  297. package/src/modules/workspace/workspace.service.ts +14 -18
  298. package/src/modules/workspace/workspace.tools.ts +3 -3
  299. package/src/shared/__tests__/join-request.test.ts +1 -1
  300. package/src/shared/__tests__/workspace-id.test.ts +17 -0
  301. package/src/{modules/workflow/workflow.errors.ts → shared/domain-errors.ts} +6 -1
  302. package/src/shared/workspace-id.ts +36 -0
  303. /package/src/modules/{access → access-model}/__tests__/kb-read-filter.test.ts +0 -0
  304. /package/src/modules/{access → access-model}/__tests__/roles-yaml-guard.test.ts +0 -0
  305. /package/src/modules/{access → access-model}/kb-read-filter.ts +0 -0
  306. /package/src/modules/{workflow/git → kb-fs}/__tests__/clone-config.test.ts +0 -0
  307. /package/src/modules/{workflow → kb-fs}/__tests__/file-change-notifier.test.ts +0 -0
  308. /package/src/modules/{workflow/git → kb-fs}/__tests__/mutex.test.ts +0 -0
  309. /package/src/modules/{workflow/git → kb-fs}/clone-config.ts +0 -0
  310. /package/src/modules/{workflow → kb-fs}/file-change-notifier.ts +0 -0
  311. /package/src/modules/{workflow/git → kb-fs}/mutex.ts +0 -0
  312. /package/src/modules/{workflow → kb-fs}/read-only-filesystem.ts +0 -0
@@ -0,0 +1,778 @@
1
+ /**
2
+ * The pure access grammar — every parser, canonicaliser, constant, and type
3
+ * that defines WHAT the roles/groups/access files mean, with no I/O and no
4
+ * service state. Extracted from `modules/access/access-control.service.ts`;
5
+ * the resolver and the `AccessControlService` (filesystem/git model loading,
6
+ * caching, permission gates) stay there and consume this grammar.
7
+ *
8
+ * This module is a LEAF: files here import each other freely, but from the
9
+ * outside world only node builtins, platform-shared, `@mastra/core/workspace`
10
+ * types, and `src/shared/*` — never another `modules/*` directory. (The
11
+ * `./group-files.js` import below is access-model's own file, not a breach.)
12
+ */
13
+
14
+ import type { GroupsIndex } from './group-files.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Constants — kept in lockstep with knowledge-base/lib/access-control.js
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export const ADMIN_CANONICAL = 'admin';
21
+ /**
22
+ * Verbs the resolver understands in an `access.md` frontmatter. Each verb
23
+ * is a list of grants (role or `Name <email>` references, optionally
24
+ * prefixed with `deny `). Keep `Verb` and `KNOWN_VERBS` in lockstep —
25
+ * `AccessFile.entries` is statically keyed on this union.
26
+ *
27
+ * `read` controls who may VIEW a path (the file viewer, embed surface, and
28
+ * the agent's read tools). It is **default-deny**: a path with no effective
29
+ * `read:` or `owner:` grant is not readable. To make content public, list the
30
+ * built-in role `everyone` under `read:`.
31
+ *
32
+ * The verbs nest: `owner` is a superset of `read` + `write` + `download`, and
33
+ * `write` is itself a superset of `read` (anyone who can edit can view). An
34
+ * `owner` grant therefore confers all three lower verbs, a `write` grant
35
+ * additionally confers `read`, and `owner` also marks the principal as a
36
+ * contact point for the node (surfaced in the UI so users know who to ask).
37
+ * See `sourceVerbsFor` for how these implications fold into resolution.
38
+ */
39
+ export const KNOWN_VERBS = ['read', 'write', 'download', 'owner'] as const;
40
+ export type Verb = (typeof KNOWN_VERBS)[number];
41
+ const KNOWN_VERBS_SET: ReadonlySet<string> = new Set<string>(KNOWN_VERBS);
42
+ export const EVERYONE_CANONICAL = 'everyone';
43
+ /** Display name for the built-in `everyone` role in the share UI. */
44
+ export const EVERYONE_DISPLAY = 'Everyone';
45
+
46
+ /**
47
+ * Verbs whose entries contribute to resolving `verb`, target verb first.
48
+ * `owner` implies `read`, `write`, and `download`; `write` additionally
49
+ * implies `read`. So resolving `read` folds in `write` and `owner`, resolving
50
+ * `write`/`download` folds in `owner`, and resolving `owner` uses only `owner`.
51
+ *
52
+ * The implication is **grant-only** (see `resolveAtPath`): a superset grant
53
+ * confers the lower verb, but a superset *denial* does not — `deny write` says
54
+ * nothing about `read`, so it never strips a separate read grant. The target
55
+ * verb itself contributes both its grants and its denials.
56
+ */
57
+ export function sourceVerbsFor(verb: Verb): Verb[] {
58
+ switch (verb) {
59
+ case 'owner':
60
+ return ['owner'];
61
+ case 'read':
62
+ return ['read', 'write', 'owner'];
63
+ default:
64
+ return [verb, 'owner'];
65
+ }
66
+ }
67
+ export const RESERVED_ROLE_NAMES = new Set(['deny', EVERYONE_CANONICAL]);
68
+ export const DENY_PREFIX = 'deny ';
69
+ /**
70
+ * Member-entry prefix in roles.yaml that references a GROUP instead of an
71
+ * email: `- group:Engineering`. Explicit on purpose — membership kind is
72
+ * never guessed from string shape. Valid on EVERY role including Admin, with
73
+ * one kept invariant (see `parseRolesYaml`): Admin must always retain at
74
+ * least one direct email member, so a misconfigured or unreachable directory
75
+ * can never leave the deployment without a rescuable admin.
76
+ */
77
+ export const GROUP_REF_PREFIX = 'group:';
78
+ /**
79
+ * Explicit ROLE token prefix in access.md entries: `role/<Name>` resolves to
80
+ * the roles.yaml role only, never a group. A BARE name resolves GROUP-FIRST
81
+ * and falls back to the role — so `role/` is the escape hatch when a group
82
+ * shares the role's name. The prefix is reserved in the group name-safety
83
+ * rules (a group may never be named `role/...`), and every role is also
84
+ * registered in the principal index under its `role/<canonical>` alias.
85
+ */
86
+ export const ROLE_TOKEN_PREFIX = 'role/';
87
+
88
+ export const USER_REF_REGEX = /^(.+?)\s+<\s*([^<>\s]+@[^<>\s]+)\s*>\s*$/;
89
+ export const EMAIL_REGEX = /^[^<>\s@]+@[^<>\s@]+\.[^<>\s@]+$/;
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Domain types
93
+ // ---------------------------------------------------------------------------
94
+
95
+ export interface RoleEntry {
96
+ kind: 'role';
97
+ role: string; // canonical (lowercase, single-spaced)
98
+ displayRole: string; // original casing/spacing
99
+ deny: boolean;
100
+ }
101
+
102
+ export interface UserEntry {
103
+ kind: 'user';
104
+ email: string; // canonical (lowercased, trimmed)
105
+ displayName: string;
106
+ deny: boolean;
107
+ }
108
+
109
+ export type ParsedEntry = RoleEntry | UserEntry;
110
+
111
+ export interface AccessFile {
112
+ /** repo-relative POSIX path — e.g. `access.md`, `Knowledge/Sales/access.md`. */
113
+ path: string;
114
+ /** repo-relative POSIX directory — `''` for root. */
115
+ dir: string;
116
+ /**
117
+ * Verb → list of grants/denials. Always carries an entry for every
118
+ * known verb (empty array when the verb wasn't declared in the file) so
119
+ * the resolver doesn't need to null-check at every chain step.
120
+ */
121
+ entries: Record<Verb, ParsedEntry[]>;
122
+ }
123
+
124
+ function emptyEntries(): Record<Verb, ParsedEntry[]> {
125
+ const out = {} as Record<Verb, ParsedEntry[]>;
126
+ for (const v of KNOWN_VERBS) out[v] = [];
127
+ return out;
128
+ }
129
+
130
+ export function isAccessMdPath(p: string): boolean {
131
+ return p === 'access.md' || p.endsWith('/access.md');
132
+ }
133
+
134
+ /**
135
+ * Extensions of node files whose OWN `---` frontmatter can carry access verbs
136
+ * the resolver enforces (`readOwnEntries` → `parseOwnAccessEntries`). The
137
+ * SINGLE source of truth for every surface that enumerates candidate files —
138
+ * the access-declarations scan and the shared `KbReferenceScanner` (which
139
+ * must scan/rewrite the same set, or a rename strands a live `.tool`
140
+ * frontmatter grant). `access.md` is covered by `.md`.
141
+ */
142
+ export const ACCESS_FRONTMATTER_EXTENSIONS = ['.md', '.tool'] as const;
143
+
144
+ /** True when `p` is a file the resolver reads access frontmatter from. */
145
+ export function hasAccessFrontmatterExtension(p: string): boolean {
146
+ return ACCESS_FRONTMATTER_EXTENSIONS.some((ext) => p.endsWith(ext));
147
+ }
148
+ /**
149
+ * The PRINCIPAL index — canonical name → member emails. Despite the name it
150
+ * holds both kinds of named principal after `mergeGroupsIntoRoles` runs:
151
+ * roles.yaml roles (`kind: 'role'`, the default) and the active group file's
152
+ * groups (`kind: 'group'`). Grant resolution treats them identically — a
153
+ * grant names a principal, the principal has member emails — which is what
154
+ * lets the whole closeness-first resolver work on groups without changes.
155
+ *
156
+ * `groupRefs` carries a role's `group:<Name>` member entries between parse
157
+ * and merge; the merge expands them into `emails`/`byEmail`.
158
+ */
159
+ export interface RolesIndex {
160
+ byCanonical: Map<
161
+ string,
162
+ { displayName: string; emails: Set<string>; groupRefs?: Set<string>; kind?: 'role' | 'group' }
163
+ >;
164
+ byEmail: Map<string, Set<string>>;
165
+ }
166
+ // ---------------------------------------------------------------------------
167
+ // Tiny YAML subset parser — handles only block mappings + block sequences
168
+ // of plain scalars. See `knowledge-base/lib/access-control.js` for the
169
+ // reference implementation (this file is the TypeScript port).
170
+ // ---------------------------------------------------------------------------
171
+
172
+ type YamlValue = string | YamlValue[] | { [key: string]: YamlValue } | null;
173
+
174
+ interface YamlOk {
175
+ ok: true;
176
+ value: YamlValue;
177
+ }
178
+
179
+ interface YamlErr {
180
+ ok: false;
181
+ error: string;
182
+ }
183
+
184
+ /**
185
+ * Strip a trailing `# comment` the way the YAML-subset tokeniser reads a
186
+ * line: a `#` at the start (after only whitespace) or preceded by whitespace
187
+ * begins a comment. Exported so the reference scanner matches tokens against
188
+ * the SAME comment rule the resolver parses with (a `- GTM Team # sales`
189
+ * entry is the token `GTM Team`, never `GTM Team # sales`).
190
+ */
191
+ export function stripComment(line: string): string {
192
+ let inWs = true;
193
+ for (let i = 0; i < line.length; i++) {
194
+ const ch = line[i];
195
+ if (ch === '#' && (inWs || (i > 0 && /\s/.test(line[i - 1])))) {
196
+ return line.slice(0, i);
197
+ }
198
+ if (!/\s/.test(ch)) inWs = false;
199
+ }
200
+ return line;
201
+ }
202
+
203
+ interface Token {
204
+ lineNum: number;
205
+ indent: number;
206
+ kind: 'kv' | 'item';
207
+ key?: string;
208
+ value: string;
209
+ }
210
+
211
+ function tokenise(
212
+ text: string,
213
+ opts?: { tolerateEmptyKeys?: boolean },
214
+ ): YamlErr | { ok: true; tokens: Token[] } {
215
+ const lines = text.split(/\r?\n/);
216
+ const tokens: Token[] = [];
217
+ // Tolerated blank keys are uniquified as runs of spaces so a SECOND blank
218
+ // key doesn't trip the duplicate-key check (which would retire every valid
219
+ // group after it). All-whitespace keys still canonicalize to '' downstream,
220
+ // so the entry-level "empty group name — skipped" handling sees them all.
221
+ let blankKeySeq = 0;
222
+ for (let i = 0; i < lines.length; i++) {
223
+ const stripped = stripComment(lines[i]).replace(/\s+$/, '');
224
+ if (!stripped.trim()) continue;
225
+ const indentMatch = stripped.match(/^( *)/);
226
+ const indent = indentMatch ? indentMatch[1].length : 0;
227
+ const content = stripped.slice(indent);
228
+ const lineNum = i + 1;
229
+
230
+ if (content.startsWith('- ') || content === '-') {
231
+ const value = content === '-' ? '' : content.slice(2).trim();
232
+ tokens.push({ lineNum, indent, kind: 'item', value });
233
+ continue;
234
+ }
235
+ const colonIdx = content.indexOf(':');
236
+ if (colonIdx < 0) {
237
+ return { ok: false, error: `line ${lineNum}: expected 'key:' or '- value' but got '${content}'` };
238
+ }
239
+ let key = content.slice(0, colonIdx).trim();
240
+ const valuePart = content.slice(colonIdx + 1).trim();
241
+ // An empty key is normally a hard error (roles.yaml/access.md want loud
242
+ // failures), but a parser with ENTRY-level forgiveness (the group files:
243
+ // one bad entry must not retire every other group) keeps it as a
244
+ // whitespace key for its own skip-with-warning handling.
245
+ if (!key) {
246
+ if (!opts?.tolerateEmptyKeys) {
247
+ return { ok: false, error: `line ${lineNum}: empty mapping key` };
248
+ }
249
+ key = ' '.repeat(++blankKeySeq);
250
+ }
251
+ tokens.push({ lineNum, indent, kind: 'kv', key, value: valuePart });
252
+ }
253
+ return { ok: true, tokens };
254
+ }
255
+
256
+ export function parseYamlSubset(
257
+ text: string,
258
+ opts?: { tolerateEmptyKeys?: boolean },
259
+ ): YamlOk | YamlErr {
260
+ const tok = tokenise(text, opts);
261
+ if (!tok.ok) return tok;
262
+ if (tok.tokens.length === 0) return { ok: true, value: {} };
263
+
264
+ type Frame = { indent: number; container: YamlValue[] | { [key: string]: YamlValue }; kind: 'map' | 'list' };
265
+ const root: { [key: string]: YamlValue } = {};
266
+ const stack: Frame[] = [{ indent: -1, container: root, kind: 'map' }];
267
+
268
+ for (let t = 0; t < tok.tokens.length; t++) {
269
+ const cur = tok.tokens[t];
270
+ while (stack.length > 1 && stack[stack.length - 1].indent >= cur.indent) {
271
+ stack.pop();
272
+ }
273
+ const top = stack[stack.length - 1];
274
+
275
+ if (cur.kind === 'item') {
276
+ if (top.kind !== 'list') {
277
+ return {
278
+ ok: false,
279
+ error: `line ${cur.lineNum}: list item with no enclosing list (indent ${cur.indent})`,
280
+ };
281
+ }
282
+ (top.container as YamlValue[]).push(cur.value);
283
+ continue;
284
+ }
285
+
286
+ if (top.kind !== 'map') {
287
+ return {
288
+ ok: false,
289
+ error: `line ${cur.lineNum}: mapping key '${cur.key}' inside a list — not supported`,
290
+ };
291
+ }
292
+ const map = top.container as { [key: string]: YamlValue };
293
+ const key = cur.key as string;
294
+ if (Object.prototype.hasOwnProperty.call(map, key)) {
295
+ return { ok: false, error: `line ${cur.lineNum}: duplicate key '${key}'` };
296
+ }
297
+
298
+ if (cur.value !== '') {
299
+ // Inline empty collections are the only flow-style YAML we accept, so
300
+ // `owner: []` reads as an empty list and `groups: {}` as an empty
301
+ // mapping rather than the scalar strings "[]" / "{}".
302
+ if (cur.value === '[]') {
303
+ map[key] = [];
304
+ continue;
305
+ }
306
+ if (cur.value === '{}') {
307
+ map[key] = {};
308
+ continue;
309
+ }
310
+ map[key] = cur.value;
311
+ continue;
312
+ }
313
+
314
+ const next = tok.tokens[t + 1];
315
+ if (!next || next.indent <= cur.indent) {
316
+ map[key] = null;
317
+ continue;
318
+ }
319
+ if (next.kind === 'item') {
320
+ const list: YamlValue[] = [];
321
+ map[key] = list;
322
+ stack.push({ indent: cur.indent, container: list, kind: 'list' });
323
+ } else {
324
+ const sub: { [key: string]: YamlValue } = {};
325
+ map[key] = sub;
326
+ stack.push({ indent: cur.indent, container: sub, kind: 'map' });
327
+ }
328
+ }
329
+
330
+ return { ok: true, value: root };
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Frontmatter extraction
335
+ // ---------------------------------------------------------------------------
336
+
337
+ export function extractFrontmatter(
338
+ text: string,
339
+ ): { ok: true; frontmatter: string } | { ok: false; error: string } {
340
+ const lines = text.split(/\r?\n/);
341
+ if (lines.length === 0 || lines[0].trim() !== '---') {
342
+ return { ok: false, error: 'expected `---` on the first line' };
343
+ }
344
+ for (let i = 1; i < lines.length; i++) {
345
+ if (lines[i].trim() === '---') {
346
+ return { ok: true, frontmatter: lines.slice(1, i).join('\n') };
347
+ }
348
+ }
349
+ return { ok: false, error: 'unterminated frontmatter — no closing `---` found' };
350
+ }
351
+
352
+ /** The text AFTER the closing frontmatter fence ('' when there is no fence). */
353
+ export function bodyAfterFrontmatter(text: string): string {
354
+ const lines = text.split(/\r?\n/);
355
+ if (lines.length === 0 || lines[0].trim() !== '---') return '';
356
+ for (let i = 1; i < lines.length; i++) {
357
+ if (lines[i].trim() === '---') return lines.slice(i + 1).join('\n');
358
+ }
359
+ return '';
360
+ }
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // Canonicalisation
364
+ // ---------------------------------------------------------------------------
365
+
366
+ export function canonicalRoleName(name: string): string {
367
+ return name.trim().toLowerCase().replace(/\s+/g, ' ');
368
+ }
369
+
370
+ export function canonicalEmail(email: string): string {
371
+ return email.trim().toLowerCase();
372
+ }
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // Entry parser
376
+ // ---------------------------------------------------------------------------
377
+
378
+ export function parseAccessEntry(
379
+ raw: unknown,
380
+ ): { ok: true; entry: ParsedEntry } | { ok: false; error: string } {
381
+ if (typeof raw !== 'string') return { ok: false, error: 'entry must be a string' };
382
+ const trimmed = raw.trim();
383
+ if (!trimmed) return { ok: false, error: 'empty entry' };
384
+
385
+ let deny = false;
386
+ let body = trimmed;
387
+ if (trimmed.startsWith(DENY_PREFIX)) {
388
+ deny = true;
389
+ body = trimmed.slice(DENY_PREFIX.length).trim();
390
+ if (!body) return { ok: false, error: `'deny' with no principal` };
391
+ }
392
+
393
+ const userMatch = body.match(USER_REF_REGEX);
394
+ if (userMatch) {
395
+ const displayName = userMatch[1].trim();
396
+ const email = canonicalEmail(userMatch[2]);
397
+ if (!displayName) return { ok: false, error: `user reference '${body}' has no name` };
398
+ if (!EMAIL_REGEX.test(email)) {
399
+ return { ok: false, error: `user reference '${body}' has malformed email '${email}'` };
400
+ }
401
+ return { ok: true, entry: { kind: 'user', email, displayName, deny } };
402
+ }
403
+
404
+ if (body.includes('<') || body.includes('>')) {
405
+ return {
406
+ ok: false,
407
+ error: `entry '${body}' looks like a user reference but doesn't match 'Name <email>' shape`,
408
+ };
409
+ }
410
+
411
+ let role = canonicalRoleName(body);
412
+ if (!role) return { ok: false, error: `empty role name in entry '${raw}'` };
413
+ // Explicit role token: normalize `role/ <Name>` spacing so the canonical
414
+ // form is always `role/<canonicalName>` — the exact alias key the principal
415
+ // index registers for every role.
416
+ if (role.startsWith(ROLE_TOKEN_PREFIX)) {
417
+ const suffix = canonicalRoleName(role.slice(ROLE_TOKEN_PREFIX.length));
418
+ if (!suffix) return { ok: false, error: `entry '${body}' names no role after '${ROLE_TOKEN_PREFIX}'` };
419
+ role = `${ROLE_TOKEN_PREFIX}${suffix}`;
420
+ }
421
+ return { ok: true, entry: { kind: 'role', role, displayRole: body, deny } };
422
+ }
423
+ // ---------------------------------------------------------------------------
424
+ // roles.yaml + access.md parsers
425
+ // ---------------------------------------------------------------------------
426
+
427
+ export function parseRolesYaml(
428
+ text: string,
429
+ ): { ok: true; index: RolesIndex } | { ok: false; errors: string[] } {
430
+ const parsed = parseYamlSubset(text);
431
+ if (!parsed.ok) return { ok: false, errors: [`roles.yaml: ${parsed.error}`] };
432
+
433
+ const errors: string[] = [];
434
+ const root = parsed.value;
435
+ if (root == null || typeof root !== 'object' || Array.isArray(root)) {
436
+ return { ok: false, errors: [`roles.yaml: must be a top-level mapping`] };
437
+ }
438
+
439
+ const rolesNode = (root as Record<string, YamlValue>).roles;
440
+ if (rolesNode == null || typeof rolesNode !== 'object' || Array.isArray(rolesNode)) {
441
+ return { ok: false, errors: [`roles.yaml: missing top-level 'roles:' mapping`] };
442
+ }
443
+
444
+ const index: RolesIndex = {
445
+ byCanonical: new Map(),
446
+ byEmail: new Map(),
447
+ };
448
+
449
+ for (const [displayName, value] of Object.entries(rolesNode as Record<string, YamlValue>)) {
450
+ const canonical = canonicalRoleName(displayName);
451
+ if (!canonical) {
452
+ errors.push(`roles.yaml: empty role name`);
453
+ continue;
454
+ }
455
+ if (RESERVED_ROLE_NAMES.has(canonical)) {
456
+ errors.push(
457
+ `roles.yaml: role '${displayName}' uses reserved name '${canonical}' — this token has special meaning in access entries and cannot be a roles.yaml role`,
458
+ );
459
+ continue;
460
+ }
461
+ if (canonical.startsWith(ROLE_TOKEN_PREFIX)) {
462
+ errors.push(
463
+ `roles.yaml: role '${displayName}' starts with the reserved '${ROLE_TOKEN_PREFIX}' prefix — that spelling is the explicit role token in access entries`,
464
+ );
465
+ continue;
466
+ }
467
+ if (index.byCanonical.has(canonical)) {
468
+ const prev = index.byCanonical.get(canonical)!.displayName;
469
+ errors.push(
470
+ `roles.yaml: role '${displayName}' canonicalises to '${canonical}', which is already declared as '${prev}'`,
471
+ );
472
+ continue;
473
+ }
474
+ if (!Array.isArray(value)) {
475
+ errors.push(`roles.yaml: role '${displayName}' must be a list of emails`);
476
+ continue;
477
+ }
478
+ const emails = new Set<string>();
479
+ const groupRefs = new Set<string>();
480
+ for (const rawEmail of value) {
481
+ if (typeof rawEmail !== 'string') {
482
+ errors.push(`roles.yaml: role '${displayName}' has a non-string entry`);
483
+ continue;
484
+ }
485
+ // `- group:<Name>` assigns the role to a whole group (expanded against
486
+ // the active group source by `mergeGroupsIntoRoles`). Allowed on every
487
+ // role, Admin included — the Admin invariant below only demands at
488
+ // least one DIRECT email member so a broken directory can never leave
489
+ // the deployment adminless.
490
+ if (rawEmail.trim().toLowerCase().startsWith(GROUP_REF_PREFIX)) {
491
+ const refName = canonicalRoleName(rawEmail.trim().slice(GROUP_REF_PREFIX.length));
492
+ if (!refName) {
493
+ errors.push(`roles.yaml: role '${displayName}' has an empty group reference '${rawEmail}'`);
494
+ continue;
495
+ }
496
+ groupRefs.add(refName);
497
+ continue;
498
+ }
499
+ const email = canonicalEmail(rawEmail);
500
+ if (!EMAIL_REGEX.test(email)) {
501
+ errors.push(`roles.yaml: role '${displayName}' has malformed email '${rawEmail}'`);
502
+ continue;
503
+ }
504
+ emails.add(email);
505
+ let set = index.byEmail.get(email);
506
+ if (!set) {
507
+ set = new Set();
508
+ index.byEmail.set(email, set);
509
+ }
510
+ set.add(canonical);
511
+ }
512
+ index.byCanonical.set(canonical, { displayName: displayName.trim(), emails, groupRefs });
513
+ }
514
+
515
+ if (!index.byCanonical.has(ADMIN_CANONICAL)) {
516
+ errors.push(`roles.yaml: must declare at least one 'Admin' role`);
517
+ } else if (index.byCanonical.get(ADMIN_CANONICAL)!.emails.size === 0) {
518
+ // The kept invariant: Admin may reference groups, but must ALWAYS retain
519
+ // at least one direct email member — the rescue story requires an admin
520
+ // whose membership does not depend on a reachable, well-configured
521
+ // directory. A group-only Admin is as hard an error as an adminless one.
522
+ errors.push(
523
+ `roles.yaml: 'Admin' role has no direct email members — Admin must keep at least one individual email (group references alone are not enough)`,
524
+ );
525
+ }
526
+
527
+ if (errors.length) return { ok: false, errors };
528
+ return { ok: true, index };
529
+ }
530
+ /**
531
+ * Merge the active group source into the principal index and expand role →
532
+ * group assignments. Mutates `index` in place; returns human-readable
533
+ * warnings (callers log them — nothing here ever throws, because group
534
+ * problems must degrade, not brick access resolution).
535
+ *
536
+ * Rules (the grant-grammar precedence):
537
+ * - Every role is ALSO registered under its explicit `role/<canonical>`
538
+ * alias — the token that always resolves to the role.
539
+ * - A BARE name resolves GROUP-FIRST: when a group's canonical name
540
+ * collides with a role's, the bare key resolves to the GROUP (warned);
541
+ * the role stays reachable via `role/<canonical>`.
542
+ * - Role `group:<Name>` refs — Admin's included — expand against the
543
+ * merged groups; an unknown ref contributes nothing (warned).
544
+ * - `byEmail` is rebuilt so each member holds exactly the tokens that
545
+ * resolve to a principal they belong to (bare + `role/` alias for roles,
546
+ * bare for groups).
547
+ */
548
+ export function mergeGroupsIntoRoles(
549
+ index: RolesIndex,
550
+ groups: GroupsIndex,
551
+ sourceFile: string,
552
+ ): string[] {
553
+ const warnings: string[] = [];
554
+ // Snapshot before any group lands: at this point the index holds roles only.
555
+ const roleRecords = new Map(index.byCanonical);
556
+
557
+ // 1. Expand role → group assignments (Admin included — its safety net is
558
+ // the parse-time "at least one direct email" invariant, not a merge skip).
559
+ for (const [, principal] of roleRecords) {
560
+ if (!principal.groupRefs?.size) continue;
561
+ for (const ref of principal.groupRefs) {
562
+ const group = groups.get(ref);
563
+ if (!group) {
564
+ warnings.push(
565
+ `roles.yaml: role '${principal.displayName}' references unknown group '${ref}' — reference ignored`,
566
+ );
567
+ continue;
568
+ }
569
+ for (const email of group.emails) principal.emails.add(email);
570
+ }
571
+ }
572
+
573
+ // 2. Bare-name precedence: groups win the bare token; the role keeps its
574
+ // `role/<canonical>` alias registered below.
575
+ for (const [canonical, def] of groups) {
576
+ if (roleRecords.has(canonical)) {
577
+ warnings.push(
578
+ `${sourceFile}: group '${def.displayName}' shares its name with a role — the bare name now resolves to the GROUP; use '${ROLE_TOKEN_PREFIX}${canonical}' to reference the role`,
579
+ );
580
+ }
581
+ index.byCanonical.set(canonical, {
582
+ displayName: def.displayName,
583
+ emails: new Set(def.emails),
584
+ kind: 'group',
585
+ });
586
+ }
587
+
588
+ // 3. Explicit `role/<canonical>` alias for every role (same record — the
589
+ // alias and the bare key, when the role still owns it, stay in lockstep).
590
+ for (const [canonical, principal] of roleRecords) {
591
+ principal.kind = 'role';
592
+ index.byCanonical.set(`${ROLE_TOKEN_PREFIX}${canonical}`, principal);
593
+ }
594
+
595
+ // 4. Rebuild the email → tokens map from the final index so membership
596
+ // reflects the post-precedence keys (a collided role's members no longer
597
+ // hold the bare token unless the group also contains them).
598
+ index.byEmail.clear();
599
+ for (const [key, principal] of index.byCanonical) {
600
+ for (const email of principal.emails) {
601
+ let set = index.byEmail.get(email);
602
+ if (!set) {
603
+ set = new Set();
604
+ index.byEmail.set(email, set);
605
+ }
606
+ set.add(key);
607
+ }
608
+ }
609
+
610
+ return warnings;
611
+ }
612
+ /**
613
+ * Does an `access.md` body declare access rules — i.e. is the file in the NEW
614
+ * (body-governs-the-folder) format?
615
+ *
616
+ * The two-format story: historically the FRONTMATTER carried the folder's
617
+ * rules and the file could not govern itself. The new format follows the
618
+ * convention every other file uses — frontmatter is about the FILE (who may
619
+ * read/write `access.md` itself), and the content (the body) is the folder's
620
+ * rules. The compat rule, applied per file: **when the body is not parsable as
621
+ * rules, the frontmatter is resolved for the folder as before.**
622
+ *
623
+ * "Parsable as rules" is deliberately shallow — the body YAML-parses to a
624
+ * mapping that names at least one known verb. A prose body (the repo-root
625
+ * README-style file) fails the YAML parse or carries no verb key and stays
626
+ * legacy. A body that DOES name a verb has claimed to be rules: shape errors
627
+ * inside it (a scalar verb, a malformed entry) are then hard parse ERRORS of
628
+ * the file, never a silent fallback to the frontmatter — falling back would
629
+ * let a typo in the body hand the folder to the frontmatter's (possibly
630
+ * `read: everyone`) self-rules.
631
+ */
632
+ export function accessMdDeclaresBodyRules(text: string): boolean {
633
+ const body = bodyAfterFrontmatter(text);
634
+ if (!body.trim()) return false;
635
+ const parsed = parseYamlSubset(body);
636
+ if (!parsed.ok) return false;
637
+ const root = parsed.value;
638
+ if (root == null || typeof root !== 'object' || Array.isArray(root)) return false;
639
+ return Object.keys(root as Record<string, YamlValue>).some((k) => KNOWN_VERBS_SET.has(k));
640
+ }
641
+
642
+ /**
643
+ * The access verbs an `access.md` declares FOR ITSELF — its frontmatter, and
644
+ * only in the new format (body-governed). A legacy file cannot govern itself:
645
+ * its frontmatter IS the folder's rules, so returning them as own-entries
646
+ * would double-apply them at the file scope.
647
+ *
648
+ * Parsed forgivingly (like node frontmatter): the new-format frontmatter may
649
+ * carry non-access keys, and a typo there must not make the file unreadable.
650
+ */
651
+ export function accessMdSelfEntries(text: string): Record<Verb, ParsedEntry[]> | null {
652
+ if (!accessMdDeclaresBodyRules(text)) return null;
653
+ return parseOwnAccessEntries(text);
654
+ }
655
+
656
+ /**
657
+ * Parse one `access.md`'s FOLDER rules into its per-verb entry lists.
658
+ *
659
+ * Two formats (see {@link accessMdDeclaresBodyRules}): when the body declares
660
+ * rules, the body is parsed (new format — frontmatter then governs the file
661
+ * itself via {@link accessMdSelfEntries}); otherwise the frontmatter is parsed
662
+ * (legacy format), exactly as before.
663
+ *
664
+ * Exported so read-only surfaces can report WHERE rules are declared without
665
+ * reimplementing the parse (see `access-declarations.ts`). Exporting changes
666
+ * nothing about resolution — this is the same function the resolver builds its
667
+ * model from, so a display of declarations can never drift from the rules that
668
+ * are actually enforced.
669
+ */
670
+ export function parseAccessFile(
671
+ text: string,
672
+ relativePath: string,
673
+ ): { ok: true; file: AccessFile; warnings: string[] } | { ok: false; errors: string[] } {
674
+ const bodyFormat = accessMdDeclaresBodyRules(text);
675
+ let ruleSource: string;
676
+ if (bodyFormat) {
677
+ ruleSource = bodyAfterFrontmatter(text);
678
+ } else {
679
+ const fm = extractFrontmatter(text);
680
+ if (!fm.ok) return { ok: false, errors: [`${relativePath}: ${fm.error}`] };
681
+ ruleSource = fm.frontmatter;
682
+ }
683
+ const parsed = parseYamlSubset(ruleSource);
684
+ if (!parsed.ok) return { ok: false, errors: [`${relativePath}: ${parsed.error}`] };
685
+
686
+ const root = parsed.value;
687
+ if (root == null || typeof root !== 'object' || Array.isArray(root)) {
688
+ return {
689
+ ok: false,
690
+ errors: [`${relativePath}: ${bodyFormat ? 'body' : 'frontmatter'} must be a mapping`],
691
+ };
692
+ }
693
+
694
+ const errors: string[] = [];
695
+ const warnings: string[] = [];
696
+ const entries = emptyEntries();
697
+
698
+ for (const [key, value] of Object.entries(root as Record<string, YamlValue>)) {
699
+ if (!KNOWN_VERBS_SET.has(key)) {
700
+ // Forgiving by design: unknown keys (typos, future verbs, custom
701
+ // metadata an operator chose to colocate) must not break the access
702
+ // tree. Warn so operators see the typo in logs, then move on as if
703
+ // the key didn't exist. The verbs we DO understand still parse.
704
+ warnings.push(
705
+ `${relativePath}: unknown access key '${key}' — ignored (known: ${[...KNOWN_VERBS].join(', ')})`,
706
+ );
707
+ continue;
708
+ }
709
+ const verb = key as Verb;
710
+ if (!Array.isArray(value)) {
711
+ errors.push(`${relativePath}: '${key}:' must be a list`);
712
+ continue;
713
+ }
714
+ const list: ParsedEntry[] = [];
715
+ for (const raw of value) {
716
+ const result = parseAccessEntry(raw);
717
+ if (!result.ok) {
718
+ errors.push(`${relativePath}: ${result.error}`);
719
+ continue;
720
+ }
721
+ list.push(result.entry);
722
+ }
723
+ entries[verb] = list;
724
+ }
725
+
726
+ if (errors.length) return { ok: false, errors };
727
+
728
+ const slash = relativePath.lastIndexOf('/');
729
+ const dir = slash === -1 ? '' : relativePath.slice(0, slash);
730
+ return { ok: true, file: { path: relativePath, dir, entries }, warnings };
731
+ }
732
+ /**
733
+ * Per-verb access entries declared in a single node file's *own* frontmatter
734
+ * (the same YAML block that carries `nodeType:`). This is the most-specific
735
+ * scope — applied after every directory `access.md` in the chain.
736
+ */
737
+ export type OwnEntries = Record<Verb, ParsedEntry[]>;
738
+ /**
739
+ * Parse the access verbs a node file declares in its own YAML frontmatter.
740
+ * Returns the per-verb entry lists, or null when the file has no frontmatter
741
+ * or declares no access verb at all.
742
+ *
743
+ * Forgiving by design — a node's frontmatter legitimately carries non-access
744
+ * keys (notably `nodeType:`), so unknown keys are ignored, and a malformed
745
+ * entry is dropped rather than failing the file. A typo in a node's `owner:`
746
+ * must never make the node unreadable; it just doesn't grant anything.
747
+ */
748
+ export function parseOwnAccessEntries(text: string): OwnEntries | null {
749
+ const fm = extractFrontmatter(text);
750
+ if (!fm.ok) return null;
751
+ const parsed = parseYamlSubset(fm.frontmatter);
752
+ if (!parsed.ok) return null;
753
+ const root = parsed.value;
754
+ if (root == null || typeof root !== 'object' || Array.isArray(root)) return null;
755
+
756
+ const entries = emptyEntries();
757
+ let sawVerb = false;
758
+ for (const [key, value] of Object.entries(root as Record<string, YamlValue>)) {
759
+ if (!KNOWN_VERBS_SET.has(key)) continue; // ignore nodeType, etc.
760
+ // Accept both the list form (`owner:\n - A\n - B`) and the convenience
761
+ // single-value scalar form (`owner: Test <test@test.com>`) — the latter
762
+ // is the natural way to name one owner in a node's own frontmatter.
763
+ // Anything else (a mapping, null) is malformed → skip forgivingly.
764
+ let raws: YamlValue[];
765
+ if (Array.isArray(value)) raws = value;
766
+ else if (typeof value === 'string' && value.trim()) raws = [value];
767
+ else continue;
768
+ const verb = key as Verb;
769
+ const list: ParsedEntry[] = [];
770
+ for (const raw of raws) {
771
+ const result = parseAccessEntry(raw);
772
+ if (result.ok) list.push(result.entry);
773
+ }
774
+ entries[verb] = list;
775
+ sawVerb = true;
776
+ }
777
+ return sawVerb ? entries : null;
778
+ }