@bevel-software/platform-core-backend 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (305) hide show
  1. package/dist/core/core-ports.d.ts +15 -0
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +40 -6
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +17 -1
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +153 -29
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/core-config.d.ts +70 -21
  12. package/dist/core-config.d.ts.map +1 -1
  13. package/dist/core-config.js +134 -58
  14. package/dist/core-config.js.map +1 -1
  15. package/dist/modules/access/access-control.interface.d.ts +6 -0
  16. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  17. package/dist/modules/access/access-control.service.d.ts +74 -5
  18. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  19. package/dist/modules/access/access-control.service.js +116 -15
  20. package/dist/modules/access/access-control.service.js.map +1 -1
  21. package/dist/modules/access/access-declarations.d.ts +93 -0
  22. package/dist/modules/access/access-declarations.d.ts.map +1 -0
  23. package/dist/modules/access/access-declarations.js +168 -0
  24. package/dist/modules/access/access-declarations.js.map +1 -0
  25. package/dist/modules/access/access-mutation.service.js +4 -4
  26. package/dist/modules/access/access-mutation.service.js.map +1 -1
  27. package/dist/modules/access/access-splice.d.ts +16 -1
  28. package/dist/modules/access/access-splice.d.ts.map +1 -1
  29. package/dist/modules/access/access-splice.js +40 -21
  30. package/dist/modules/access/access-splice.js.map +1 -1
  31. package/dist/modules/access/access.routes.d.ts.map +1 -1
  32. package/dist/modules/access/access.routes.js +51 -0
  33. package/dist/modules/access/access.routes.js.map +1 -1
  34. package/dist/modules/access/creator-access.d.ts +8 -0
  35. package/dist/modules/access/creator-access.d.ts.map +1 -1
  36. package/dist/modules/access/creator-access.js +21 -2
  37. package/dist/modules/access/creator-access.js.map +1 -1
  38. package/dist/modules/admin/admin-access.service.d.ts +1 -1
  39. package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
  40. package/dist/modules/admin/admin-access.service.js +3 -2
  41. package/dist/modules/admin/admin-access.service.js.map +1 -1
  42. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  43. package/dist/modules/auth/auth.routes.js +50 -1
  44. package/dist/modules/auth/auth.routes.js.map +1 -1
  45. package/dist/modules/auth/auth.service.d.ts +39 -10
  46. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  47. package/dist/modules/auth/auth.service.js +38 -16
  48. package/dist/modules/auth/auth.service.js.map +1 -1
  49. package/dist/modules/database/core-schema.d.ts +124 -0
  50. package/dist/modules/database/core-schema.d.ts.map +1 -1
  51. package/dist/modules/database/core-schema.js +45 -0
  52. package/dist/modules/database/core-schema.js.map +1 -1
  53. package/dist/modules/groups/group-provision.service.d.ts +130 -0
  54. package/dist/modules/groups/group-provision.service.d.ts.map +1 -0
  55. package/dist/modules/groups/group-provision.service.js +288 -0
  56. package/dist/modules/groups/group-provision.service.js.map +1 -0
  57. package/dist/modules/groups/groups.contract.d.ts +106 -0
  58. package/dist/modules/groups/groups.contract.d.ts.map +1 -0
  59. package/dist/modules/groups/groups.contract.js +36 -0
  60. package/dist/modules/groups/groups.contract.js.map +1 -0
  61. package/dist/modules/groups/groups.routes.d.ts +42 -0
  62. package/dist/modules/groups/groups.routes.d.ts.map +1 -0
  63. package/dist/modules/groups/groups.routes.js +379 -0
  64. package/dist/modules/groups/groups.routes.js.map +1 -0
  65. package/dist/modules/groups/groups.service.d.ts +60 -0
  66. package/dist/modules/groups/groups.service.d.ts.map +1 -0
  67. package/dist/modules/groups/groups.service.js +172 -0
  68. package/dist/modules/groups/groups.service.js.map +1 -0
  69. package/dist/modules/groups/index.d.ts +7 -0
  70. package/dist/modules/groups/index.d.ts.map +1 -0
  71. package/dist/modules/groups/index.js +6 -0
  72. package/dist/modules/groups/index.js.map +1 -0
  73. package/dist/modules/groups/join-proposals.d.ts +53 -0
  74. package/dist/modules/groups/join-proposals.d.ts.map +1 -0
  75. package/dist/modules/groups/join-proposals.js +67 -0
  76. package/dist/modules/groups/join-proposals.js.map +1 -0
  77. package/dist/modules/groups/join-requests.service.d.ts +81 -0
  78. package/dist/modules/groups/join-requests.service.d.ts.map +1 -0
  79. package/dist/modules/groups/join-requests.service.js +135 -0
  80. package/dist/modules/groups/join-requests.service.js.map +1 -0
  81. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +20 -0
  82. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  83. package/dist/modules/secrets-vault/secrets-vault.routes.js +59 -11
  84. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  85. package/dist/modules/settings/deployment-settings.service.d.ts +129 -0
  86. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -0
  87. package/dist/modules/settings/deployment-settings.service.js +368 -0
  88. package/dist/modules/settings/deployment-settings.service.js.map +1 -0
  89. package/dist/modules/settings/setup.routes.d.ts +49 -0
  90. package/dist/modules/settings/setup.routes.d.ts.map +1 -0
  91. package/dist/modules/settings/setup.routes.js +292 -0
  92. package/dist/modules/settings/setup.routes.js.map +1 -0
  93. package/dist/modules/skills/index.d.ts +2 -1
  94. package/dist/modules/skills/index.d.ts.map +1 -1
  95. package/dist/modules/skills/index.js +1 -0
  96. package/dist/modules/skills/index.js.map +1 -1
  97. package/dist/modules/skills/pending-skills.service.d.ts +40 -0
  98. package/dist/modules/skills/pending-skills.service.d.ts.map +1 -0
  99. package/dist/modules/skills/pending-skills.service.js +147 -0
  100. package/dist/modules/skills/pending-skills.service.js.map +1 -0
  101. package/dist/modules/skills/skills.contract.d.ts +32 -3
  102. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  103. package/dist/modules/skills/skills.contract.js +1 -1
  104. package/dist/modules/skills/skills.routes.d.ts +3 -2
  105. package/dist/modules/skills/skills.routes.d.ts.map +1 -1
  106. package/dist/modules/skills/skills.routes.js +18 -1
  107. package/dist/modules/skills/skills.routes.js.map +1 -1
  108. package/dist/modules/skills/skills.service.d.ts +22 -0
  109. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  110. package/dist/modules/skills/skills.service.js +24 -12
  111. package/dist/modules/skills/skills.service.js.map +1 -1
  112. package/dist/modules/tool-manuals/index.d.ts +1 -1
  113. package/dist/modules/tool-manuals/index.d.ts.map +1 -1
  114. package/dist/modules/tool-manuals/index.js.map +1 -1
  115. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +47 -2
  116. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  117. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  118. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +4 -0
  119. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  120. package/dist/modules/tool-manuals/tool-manuals.routes.js +23 -0
  121. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  122. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +3 -1
  123. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  124. package/dist/modules/tool-manuals/tool-manuals.service.js +84 -17
  125. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  126. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  127. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -2
  128. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  129. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  130. package/dist/modules/workflow/agent-tools/workflow.tools.js +4 -2
  131. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  132. package/dist/modules/workflow/event-bus.d.ts +11 -0
  133. package/dist/modules/workflow/event-bus.d.ts.map +1 -1
  134. package/dist/modules/workflow/event-bus.js +22 -0
  135. package/dist/modules/workflow/event-bus.js.map +1 -1
  136. package/dist/modules/workflow/file-lock.service.d.ts +9 -0
  137. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  138. package/dist/modules/workflow/file-lock.service.js +16 -0
  139. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  140. package/dist/modules/workflow/git/branch-name.d.ts +1 -1
  141. package/dist/modules/workflow/git/branch-name.d.ts.map +1 -1
  142. package/dist/modules/workflow/git/branch-name.js +1 -1
  143. package/dist/modules/workflow/git/branch-name.js.map +1 -1
  144. package/dist/modules/workflow/git/git.service.d.ts +31 -2
  145. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  146. package/dist/modules/workflow/git/git.service.js +157 -54
  147. package/dist/modules/workflow/git/git.service.js.map +1 -1
  148. package/dist/modules/workflow/git/pull-request.service.d.ts +12 -6
  149. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  150. package/dist/modules/workflow/git/pull-request.service.js +19 -9
  151. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  152. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  153. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  154. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  155. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  156. package/dist/modules/workflow/workflow.routes.js +95 -14
  157. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  158. package/dist/modules/workflow/workflow.service.d.ts +97 -5
  159. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  160. package/dist/modules/workflow/workflow.service.js +393 -114
  161. package/dist/modules/workflow/workflow.service.js.map +1 -1
  162. package/dist/modules/workspace/bevel-ignore.d.ts +5 -0
  163. package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -1
  164. package/dist/modules/workspace/bevel-ignore.js +5 -1
  165. package/dist/modules/workspace/bevel-ignore.js.map +1 -1
  166. package/dist/modules/workspace/kb-seed.interface.d.ts +1 -1
  167. package/dist/modules/workspace/kb-seed.service.d.ts +60 -5
  168. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  169. package/dist/modules/workspace/kb-seed.service.js +228 -51
  170. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  171. package/dist/modules/workspace/workspace.routes.d.ts +2 -1
  172. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  173. package/dist/modules/workspace/workspace.routes.js +51 -18
  174. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  175. package/dist/modules/workspace/workspace.service.d.ts +16 -4
  176. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  177. package/dist/modules/workspace/workspace.service.js +36 -9
  178. package/dist/modules/workspace/workspace.service.js.map +1 -1
  179. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  180. package/dist/modules/workspace/workspace.tools.js +8 -3
  181. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  182. package/dist/shared/kb-layout.test.js +2 -2
  183. package/dist/test-setup.d.ts +2 -0
  184. package/dist/test-setup.d.ts.map +1 -0
  185. package/dist/test-setup.js +18 -0
  186. package/dist/test-setup.js.map +1 -0
  187. package/kb-template/.bevelignore +6 -2
  188. package/kb-template/AGENTS.md +272 -0
  189. package/kb-template/KnowledgeBase/How to get started.md +65 -0
  190. package/kb-template/access.md +36 -36
  191. package/migrations/0002_user_onboarding_done.sql +1 -0
  192. package/migrations/0003_deployment_settings.sql +9 -0
  193. package/migrations/meta/0001_snapshot.json +1486 -0
  194. package/migrations/meta/0002_snapshot.json +1493 -0
  195. package/migrations/meta/0003_snapshot.json +1552 -0
  196. package/migrations/meta/_journal.json +15 -1
  197. package/package.json +3 -3
  198. package/src/__tests__/branch-model.test.ts +79 -0
  199. package/src/__tests__/core-config.admin.test.ts +114 -0
  200. package/src/__tests__/database-url.test.ts +70 -0
  201. package/src/core/core-ports.ts +15 -0
  202. package/src/core/create-core-server.ts +55 -6
  203. package/src/core/create-core-services.ts +201 -36
  204. package/src/core-config.ts +139 -59
  205. package/src/modules/access/__tests__/access-declarations.test.ts +195 -0
  206. package/src/modules/access/__tests__/access-md-format.test.ts +191 -0
  207. package/src/modules/access/__tests__/access-splice.test.ts +21 -0
  208. package/src/modules/access/__tests__/access.routes.overrides.test.ts +214 -0
  209. package/src/modules/access/access-control.interface.ts +11 -0
  210. package/src/modules/access/access-control.service.ts +113 -15
  211. package/src/modules/access/access-declarations.ts +251 -0
  212. package/src/modules/access/access-mutation.service.ts +4 -4
  213. package/src/modules/access/access-splice.ts +64 -21
  214. package/src/modules/access/access.routes.ts +61 -0
  215. package/src/modules/access/creator-access.ts +22 -2
  216. package/src/modules/admin/admin-access.service.ts +4 -3
  217. package/src/modules/auth/__tests__/auth.service.test.ts +57 -0
  218. package/src/modules/auth/__tests__/onboarding-done.route.test.ts +147 -0
  219. package/src/modules/auth/auth.routes.ts +50 -1
  220. package/src/modules/auth/auth.service.ts +55 -17
  221. package/src/modules/database/core-schema.ts +46 -0
  222. package/src/modules/groups/__tests__/group-index.service.test.ts +225 -0
  223. package/src/modules/groups/__tests__/group-provision.service.test.ts +247 -0
  224. package/src/modules/groups/__tests__/groups.routes.test.ts +493 -0
  225. package/src/modules/groups/__tests__/join-proposals.test.ts +101 -0
  226. package/src/modules/groups/__tests__/join-requests.service.test.ts +175 -0
  227. package/src/modules/groups/group-provision.service.ts +328 -0
  228. package/src/modules/groups/groups.contract.ts +107 -0
  229. package/src/modules/groups/groups.routes.ts +433 -0
  230. package/src/modules/groups/groups.service.ts +193 -0
  231. package/src/modules/groups/index.ts +17 -0
  232. package/src/modules/groups/join-proposals.ts +114 -0
  233. package/src/modules/groups/join-requests.service.ts +185 -0
  234. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +283 -0
  235. package/src/modules/secrets-vault/secrets-vault.routes.ts +68 -12
  236. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +256 -0
  237. package/src/modules/settings/__tests__/setup.routes.test.ts +201 -0
  238. package/src/modules/settings/deployment-settings.service.ts +419 -0
  239. package/src/modules/settings/setup.routes.ts +317 -0
  240. package/src/modules/skills/__tests__/pending-skills.service.test.ts +240 -0
  241. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  242. package/src/modules/skills/index.ts +3 -0
  243. package/src/modules/skills/pending-skills.service.ts +168 -0
  244. package/src/modules/skills/skills.contract.ts +34 -3
  245. package/src/modules/skills/skills.routes.ts +23 -2
  246. package/src/modules/skills/skills.service.ts +24 -16
  247. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +262 -0
  248. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  249. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +61 -23
  250. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -156
  251. package/src/modules/tool-manuals/index.ts +2 -0
  252. package/src/modules/tool-manuals/tool-manuals.contract.ts +50 -2
  253. package/src/modules/tool-manuals/tool-manuals.routes.ts +21 -0
  254. package/src/modules/tool-manuals/tool-manuals.service.ts +86 -17
  255. package/src/modules/tool-manuals/tool-manuals.tools.ts +206 -202
  256. package/src/modules/workflow/__tests__/event-bus.test.ts +35 -0
  257. package/src/modules/workflow/__tests__/pending-commits.enqueue-if-absent.test.ts +82 -72
  258. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +295 -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 +311 -306
  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 +172 -64
  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 +365 -19
  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,251 @@
1
+ import type { FileTreeEntry, IWorkspaceService } from '@bevel-software/platform-shared';
2
+ import { WorkflowDomainError } from '../workflow/workflow.errors.js';
3
+ import { accessMdPathForFolder } from './access-mutation.service.js';
4
+ import {
5
+ EVERYONE_CANONICAL,
6
+ KNOWN_VERBS,
7
+ parseAccessFile,
8
+ parseOwnAccessEntries,
9
+ type ParsedEntry,
10
+ type Verb,
11
+ } from './access-control.service.js';
12
+
13
+ /**
14
+ * WHERE access rules are DECLARED inside a folder — a read-only inventory, not
15
+ * a second resolver.
16
+ *
17
+ * A group's `access.md` is never the whole story: resolution is closeness-first,
18
+ * so a rule written on a single skill or `.tool` inside the folder overrides the
19
+ * folder's rule for the principals it names. An admin looking at a group's share
20
+ * list and seeing "GTM Team can read" would otherwise have no way to know that
21
+ * one battlecard underneath says `deny read: everyone`.
22
+ *
23
+ * So this module answers exactly one question — *which files declare access
24
+ * rules under here, and what do they say* — and it answers it with the SAME
25
+ * parsers the resolver builds its model from (`parseAccessFile` for `access.md`,
26
+ * `parseOwnAccessEntries` for node frontmatter). Nothing here decides whether
27
+ * anybody can do anything; it reports declarations verbatim. That is the whole
28
+ * reason it can exist without touching `IAccessControl`, which enterprise
29
+ * implements: a display of the stored rules cannot drift from the rules that
30
+ * are enforced, because it is reading the same bytes through the same parse.
31
+ */
32
+
33
+ /** A principal named by a declaration, in wire form. */
34
+ export type DeclarationPrincipal =
35
+ | { kind: 'role'; role: string }
36
+ | { kind: 'user'; email: string; name: string }
37
+ | { kind: 'everyone' };
38
+
39
+ /** One `verb: principal` line of a declaration, deny-prefix preserved. */
40
+ export interface DeclarationEntry {
41
+ verb: Verb;
42
+ /** True when the line carried the literal `deny ` prefix. */
43
+ deny: boolean;
44
+ principal: DeclarationPrincipal;
45
+ }
46
+
47
+ export interface AccessDeclaration {
48
+ /** Repo-relative path of the file that DECLARES the rules. */
49
+ path: string;
50
+ /**
51
+ * Repo-relative path the rules GOVERN: the containing directory for an
52
+ * `access.md`, the file itself for node frontmatter. Distinct from `path`
53
+ * because that is what a reader needs to recognise ("the battlecards
54
+ * folder"), and what the caller read-filters rows on.
55
+ */
56
+ governs: string;
57
+ source: 'access-md' | 'frontmatter';
58
+ entries: DeclarationEntry[];
59
+ /**
60
+ * Set (with `entries: []`) when an `access.md` failed to parse. Surfaced
61
+ * rather than swallowed: a broken rule file is exactly the thing somebody
62
+ * managing access has to be told about, and the resolver's forgiveness makes
63
+ * it invisible everywhere else.
64
+ */
65
+ parseError?: string;
66
+ }
67
+
68
+ /**
69
+ * How many files one scan will read. A group folder is small by construction;
70
+ * this cap exists so a pathological folder (a whole ontology moved under
71
+ * `Groups/`) degrades into an honest partial answer instead of pinning the
72
+ * event loop. Callers surface `truncated` to the user.
73
+ */
74
+ export const DECLARATION_SCAN_FILE_CAP = 400;
75
+
76
+ /** 400 for a target that is not a folder. */
77
+ export class AccessDeclarationsError extends WorkflowDomainError {
78
+ constructor(message: string, status = 400) {
79
+ super(message, status);
80
+ this.name = 'AccessDeclarationsError';
81
+ }
82
+ }
83
+
84
+ /** Node files whose own frontmatter may declare verbs. */
85
+ function isNodeCandidate(name: string): boolean {
86
+ return name.endsWith('.md') || name.endsWith('.tool');
87
+ }
88
+
89
+ /** Walk a workspace-relative path down the file tree; null when absent. */
90
+ function findNode(root: FileTreeEntry, wsRelPath: string): FileTreeEntry | null {
91
+ if (wsRelPath === '') return root;
92
+ let node: FileTreeEntry = root;
93
+ for (const segment of wsRelPath.split('/').filter(Boolean)) {
94
+ const next = node.children?.find((c) => c.name === segment);
95
+ if (!next) return null;
96
+ node = next;
97
+ }
98
+ return node;
99
+ }
100
+
101
+ interface Candidate {
102
+ /** Repo-relative path of the declaring file. */
103
+ repoRel: string;
104
+ /** Workspace-relative path, i.e. what `readFile` takes. */
105
+ wsPath: string;
106
+ isAccessMd: boolean;
107
+ }
108
+
109
+ function collectCandidates(node: FileTreeEntry, kbPrefix: string, out: Candidate[]): void {
110
+ for (const child of node.children ?? []) {
111
+ if (child.type === 'directory') {
112
+ collectCandidates(child, kbPrefix, out);
113
+ continue;
114
+ }
115
+ const isAccessMd = child.name === 'access.md';
116
+ if (!isAccessMd && !isNodeCandidate(child.name)) continue;
117
+ if (!child.relativePath.startsWith(kbPrefix)) continue;
118
+ out.push({
119
+ repoRel: child.relativePath.slice(kbPrefix.length),
120
+ wsPath: child.relativePath,
121
+ isAccessMd,
122
+ });
123
+ }
124
+ }
125
+
126
+ /** The directory a `<dir>/access.md` governs (`''` for the repo root's own). */
127
+ function dirOf(repoRelPath: string): string {
128
+ const slash = repoRelPath.lastIndexOf('/');
129
+ return slash === -1 ? '' : repoRelPath.slice(0, slash);
130
+ }
131
+
132
+ /**
133
+ * `ParsedEntry` → wire principal. The canonical built-in `everyone` role
134
+ * becomes its own kind rather than a role named "everyone": it is not a
135
+ * roles.yaml role, it has no members to look up, and the UI says "Everyone"
136
+ * about it — collapsing it here keeps that decision out of three renderers.
137
+ */
138
+ function toPrincipal(entry: ParsedEntry): DeclarationPrincipal {
139
+ if (entry.kind === 'user') {
140
+ return { kind: 'user', email: entry.email, name: entry.displayName };
141
+ }
142
+ if (entry.role === EVERYONE_CANONICAL) return { kind: 'everyone' };
143
+ return { kind: 'role', role: entry.displayRole };
144
+ }
145
+
146
+ /** Flatten a per-verb entry map into wire entries, verb order then file order. */
147
+ function toEntries(byVerb: Record<Verb, ParsedEntry[]>): DeclarationEntry[] {
148
+ const out: DeclarationEntry[] = [];
149
+ for (const verb of KNOWN_VERBS) {
150
+ for (const entry of byVerb[verb] ?? []) {
151
+ out.push({ verb, deny: entry.deny, principal: toPrincipal(entry) });
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+
157
+ /**
158
+ * Every access declaration living INSIDE `folderRepoRel`.
159
+ *
160
+ * The folder's own `access.md` is deliberately excluded — that file is the
161
+ * folder's summary, already shown as the main share list, and repeating it in a
162
+ * list headed "item-specific rules" would read as a contradiction of itself.
163
+ *
164
+ * Failure modes are asymmetric on purpose, matching the resolver: a node whose
165
+ * frontmatter is malformed is skipped silently (a typo in a skill's `owner:`
166
+ * must not make the group's access page unreadable), while a malformed
167
+ * `access.md` is REPORTED with its `parseError` (that file was written to
168
+ * control access and currently controls nothing).
169
+ *
170
+ * Throws `AccessDeclarationsError` (400) when the target is a file. A target
171
+ * that does not exist in the clone yields an empty result — an unmigrated or
172
+ * not-yet-created folder is a normal state, not an error.
173
+ */
174
+ export async function listAccessDeclarationsUnder(
175
+ workspaceService: IWorkspaceService,
176
+ workspaceId: string,
177
+ kbDirName: string,
178
+ folderRepoRel: string,
179
+ ): Promise<{ overrides: AccessDeclaration[]; truncated: boolean }> {
180
+ const tree = await workspaceService.listFiles(workspaceId);
181
+ const kbPrefix = `${kbDirName}/`;
182
+ const wsFolderPath = folderRepoRel ? `${kbPrefix}${folderRepoRel}` : kbDirName;
183
+
184
+ const node = findNode(tree, wsFolderPath);
185
+ if (!node) return { overrides: [], truncated: false };
186
+ if (node.type !== 'directory') {
187
+ throw new AccessDeclarationsError('path must be a folder');
188
+ }
189
+
190
+ const ownAccessMd = accessMdPathForFolder(folderRepoRel);
191
+ const candidates: Candidate[] = [];
192
+ collectCandidates(node, kbPrefix, candidates);
193
+
194
+ const scannable = candidates
195
+ .filter((c) => c.repoRel !== ownAccessMd)
196
+ .sort((a, b) => (a.repoRel < b.repoRel ? -1 : a.repoRel > b.repoRel ? 1 : 0));
197
+ const truncated = scannable.length > DECLARATION_SCAN_FILE_CAP;
198
+ const scanned = truncated ? scannable.slice(0, DECLARATION_SCAN_FILE_CAP) : scannable;
199
+
200
+ const rows = await Promise.all(
201
+ scanned.map(async (candidate): Promise<AccessDeclaration | null> => {
202
+ let text: string;
203
+ try {
204
+ text = await workspaceService.readFile(workspaceId, candidate.wsPath);
205
+ } catch {
206
+ // The tree said it was there and the disk disagrees (a concurrent
207
+ // delete, a broken symlink). Nothing to declare — drop the row rather
208
+ // than fail the whole scan for one file.
209
+ return null;
210
+ }
211
+
212
+ if (candidate.isAccessMd) {
213
+ const parsed = parseAccessFile(text, candidate.repoRel);
214
+ if (!parsed.ok) {
215
+ return {
216
+ path: candidate.repoRel,
217
+ governs: dirOf(candidate.repoRel),
218
+ source: 'access-md',
219
+ entries: [],
220
+ parseError: parsed.errors.join('; '),
221
+ };
222
+ }
223
+ // An `access.md` that parses to nothing (empty, or only unknown keys)
224
+ // declares nothing: closeness-first resolution yields no verdict at that
225
+ // scope, so the parent's rules apply unchanged. Listing it under
226
+ // "item-specific rules" would announce an override that does not exist.
227
+ const entries = toEntries(parsed.file.entries);
228
+ if (entries.length === 0) return null;
229
+ return {
230
+ path: candidate.repoRel,
231
+ governs: dirOf(candidate.repoRel),
232
+ source: 'access-md',
233
+ entries,
234
+ };
235
+ }
236
+
237
+ const own = parseOwnAccessEntries(text);
238
+ if (!own) return null;
239
+ const entries = toEntries(own);
240
+ if (entries.length === 0) return null;
241
+ return {
242
+ path: candidate.repoRel,
243
+ governs: candidate.repoRel,
244
+ source: 'frontmatter',
245
+ entries,
246
+ };
247
+ }),
248
+ );
249
+
250
+ return { overrides: rows.filter((r): r is AccessDeclaration => r !== null), truncated };
251
+ }
@@ -144,7 +144,7 @@ export class AccessMutationService {
144
144
  const current = await this.readOrEmpty(workspaceId, editPath, kind === 'folder');
145
145
  let result;
146
146
  try {
147
- result = spliceGrant(current, verb, principal, { allowScalar });
147
+ result = spliceGrant(current, verb, principal, { allowScalar, target: kind === 'folder' ? 'folder' : 'node' });
148
148
  } catch (err) {
149
149
  throw this.toMutationError(err);
150
150
  }
@@ -196,7 +196,7 @@ export class AccessMutationService {
196
196
  try {
197
197
  const verbsToRevoke = verb ? [verb] : KNOWN_VERBS;
198
198
  for (const v of verbsToRevoke) {
199
- const r = spliceRevoke(next, v, principal);
199
+ const r = spliceRevoke(next, v, principal, { target: kind === 'folder' ? 'folder' : 'node' });
200
200
  next = r.text;
201
201
  changed = changed || r.changed;
202
202
  }
@@ -259,9 +259,9 @@ export class AccessMutationService {
259
259
  for (const v of verbsToDeny) {
260
260
  // (1) Strip any same-scope GRANT for this principal so grant-beats-deny
261
261
  // can't silently swallow the deny we're about to add.
262
- next = spliceRevoke(next, v, principal).text;
262
+ next = spliceRevoke(next, v, principal, { target: kind === 'folder' ? 'folder' : 'node' }).text;
263
263
  // (2) Add the deny under the same verb.
264
- next = spliceGrant(next, v, principal, { allowScalar, deny: true }).text;
264
+ next = spliceGrant(next, v, principal, { allowScalar, deny: true, target: kind === 'folder' ? 'folder' : 'node' }).text;
265
265
  }
266
266
  } catch (err) {
267
267
  throw this.toMutationError(err);
@@ -32,8 +32,22 @@ import {
32
32
  parseAccessEntry,
33
33
  canonicalEmail,
34
34
  canonicalRoleName,
35
+ accessMdDeclaresBodyRules,
35
36
  } from './access-control.service.js';
36
37
 
38
+ /**
39
+ * Which rule block a mutation edits.
40
+ *
41
+ * - `'node'` (default): the file's YAML frontmatter — node files, and every
42
+ * call that predates the two-format `access.md` story.
43
+ * - `'folder'`: the block that governs the CONTAINING FOLDER of an
44
+ * `access.md` — the body when the file is in the new (body-governed)
45
+ * format, the frontmatter otherwise. Callers mutating folder rules pass
46
+ * this so a grant can never land in a new-format file's frontmatter
47
+ * (which governs the FILE itself, not the folder).
48
+ */
49
+ export type SpliceTarget = 'node' | 'folder';
50
+
37
51
  /** A principal to grant/revoke, in the resolver's canonical terms. */
38
52
  export type Principal =
39
53
  | { kind: 'user'; email: string; displayName: string }
@@ -181,6 +195,28 @@ function joinFrontmatter(f: Frontmatter): string {
181
195
  return [...f.pre, ...f.fm, ...f.post].join(f.eol);
182
196
  }
183
197
 
198
+ /**
199
+ * The line region a mutation edits, per {@link SpliceTarget}. `lines` aliases
200
+ * the region's array (edited in place, like `f.fm` always was); `join()`
201
+ * reassembles the whole file around it.
202
+ */
203
+ function pickRegion(
204
+ text: string,
205
+ f: Frontmatter & { hasFrontmatter: boolean },
206
+ target: SpliceTarget,
207
+ ): { lines: string[]; join(): string } {
208
+ if (target === 'folder' && f.hasFrontmatter && accessMdDeclaresBodyRules(text)) {
209
+ // New format: post = [closing '---', ...body]. Edit the body lines.
210
+ const closing = f.post[0];
211
+ const body = f.post.slice(1);
212
+ return {
213
+ lines: body,
214
+ join: () => [...f.pre, ...f.fm, closing, ...body].join(f.eol),
215
+ };
216
+ }
217
+ return { lines: f.fm, join: () => joinFrontmatter(f) };
218
+ }
219
+
184
220
  /** Leading-space count of a line. */
185
221
  function indentOf(line: string): number {
186
222
  const m = line.match(/^( *)/);
@@ -311,7 +347,7 @@ export function spliceGrant(
311
347
  text: string,
312
348
  verb: Verb,
313
349
  rawPrincipal: Principal,
314
- opts: { allowScalar?: boolean; deny?: boolean } = {},
350
+ opts: { allowScalar?: boolean; deny?: boolean; target?: SpliceTarget } = {},
315
351
  ): SpliceResult {
316
352
  const principal = validatePrincipal(rawPrincipal);
317
353
  const deny = opts.deny ?? false;
@@ -329,8 +365,9 @@ export function spliceGrant(
329
365
  return { text: text2, changed: true };
330
366
  }
331
367
 
332
- const block = findVerbBlock(f.fm, verb);
333
- const existing = blockEntries(f.fm, block);
368
+ const region = pickRegion(text, f, opts.target ?? 'node');
369
+ const block = findVerbBlock(region.lines, verb);
370
+ const existing = blockEntries(region.lines, block);
334
371
 
335
372
  // Idempotency: an entry for this principal with the same deny-ness is already
336
373
  // present (a grant when granting, a deny when denying).
@@ -341,17 +378,17 @@ export function spliceGrant(
341
378
  const itemValue = renderEntry(principal, deny);
342
379
 
343
380
  if (block.keyLine === -1) {
344
- // Verb key absent — append a new block at the end of the frontmatter.
381
+ // Verb key absent — append a new block at the end of the region.
345
382
  const lines = opts.allowScalar ? [`${verb}: ${itemValue}`] : [`${verb}:`, ` - ${itemValue}`];
346
- f.fm.push(...lines);
347
- return { text: joinFrontmatter(f), changed: true };
383
+ region.lines.push(...lines);
384
+ return { text: region.join(), changed: true };
348
385
  }
349
386
 
350
387
  if (block.inlineScalarValue !== null) {
351
388
  // Scalar form — promote to a block list with both the old and new entry.
352
389
  const old = block.inlineScalarValue;
353
- f.fm.splice(block.keyLine, 1, `${verb}:`, ` - ${old}`, ` - ${itemValue}`);
354
- return { text: joinFrontmatter(f), changed: true };
390
+ region.lines.splice(block.keyLine, 1, `${verb}:`, ` - ${old}`, ` - ${itemValue}`);
391
+ return { text: region.join(), changed: true };
355
392
  }
356
393
 
357
394
  // Block-list (possibly inline `[]`). Insert after the last existing item, or
@@ -360,14 +397,14 @@ export function spliceGrant(
360
397
  const newLine = `${indent}- ${itemValue}`;
361
398
  if (block.inlineEmpty) {
362
399
  // `verb: []` -> `verb:` + one item.
363
- f.fm[block.keyLine] = `${' '.repeat(block.keyIndent)}${verb}:`;
364
- f.fm.splice(block.keyLine + 1, 0, newLine);
365
- return { text: joinFrontmatter(f), changed: true };
400
+ region.lines[block.keyLine] = `${' '.repeat(block.keyIndent)}${verb}:`;
401
+ region.lines.splice(block.keyLine + 1, 0, newLine);
402
+ return { text: region.join(), changed: true };
366
403
  }
367
404
  const insertAt =
368
405
  block.itemLines.length > 0 ? block.itemLines[block.itemLines.length - 1] + 1 : block.keyLine + 1;
369
- f.fm.splice(insertAt, 0, newLine);
370
- return { text: joinFrontmatter(f), changed: true };
406
+ region.lines.splice(insertAt, 0, newLine);
407
+ return { text: region.join(), changed: true };
371
408
  }
372
409
 
373
410
  /**
@@ -377,37 +414,43 @@ export function spliceGrant(
377
414
  * file still parses cleanly (the resolver reads `[]` as an empty list).
378
415
  * Preserves everything else.
379
416
  */
380
- export function spliceRevoke(text: string, verb: Verb, rawPrincipal: Principal): SpliceResult {
417
+ export function spliceRevoke(
418
+ text: string,
419
+ verb: Verb,
420
+ rawPrincipal: Principal,
421
+ opts: { target?: SpliceTarget } = {},
422
+ ): SpliceResult {
381
423
  const principal = validatePrincipal(rawPrincipal);
382
424
  const f = splitFrontmatter(text);
383
425
  if (!f.hasFrontmatter) return { text, changed: false };
384
426
 
385
- const block = findVerbBlock(f.fm, verb);
427
+ const region = pickRegion(text, f, opts.target ?? 'node');
428
+ const block = findVerbBlock(region.lines, verb);
386
429
  if (block.keyLine === -1) return { text, changed: false };
387
430
 
388
431
  if (block.inlineScalarValue !== null) {
389
432
  const r = parseAccessEntry(block.inlineScalarValue);
390
433
  if (r.ok && entryMatches(r.entry, principal)) {
391
434
  // Lone scalar entry removed -> collapse to empty list.
392
- f.fm[block.keyLine] = `${' '.repeat(block.keyIndent)}${verb}: []`;
393
- return { text: joinFrontmatter(f), changed: true };
435
+ region.lines[block.keyLine] = `${' '.repeat(block.keyIndent)}${verb}: []`;
436
+ return { text: region.join(), changed: true };
394
437
  }
395
438
  return { text, changed: false };
396
439
  }
397
440
 
398
- const entries = blockEntries(f.fm, block);
441
+ const entries = blockEntries(region.lines, block);
399
442
  const victims = entries.filter((e) => entryMatches(e.entry, principal)).map((e) => e.line);
400
443
  if (victims.length === 0) return { text, changed: false };
401
444
 
402
445
  // Delete from the bottom up so earlier indices stay valid.
403
446
  const remaining = block.itemLines.filter((l) => !victims.includes(l));
404
- for (const line of [...victims].sort((a, b) => b - a)) f.fm.splice(line, 1);
447
+ for (const line of [...victims].sort((a, b) => b - a)) region.lines.splice(line, 1);
405
448
 
406
449
  if (remaining.length === 0) {
407
450
  // Verb block is now empty — collapse `verb:` to `verb: []`. The key line
408
451
  // index is unchanged because we only removed lines that came after it.
409
- f.fm[block.keyLine] = `${' '.repeat(block.keyIndent)}${verb}: []`;
452
+ region.lines[block.keyLine] = `${' '.repeat(block.keyIndent)}${verb}: []`;
410
453
  }
411
- return { text: joinFrontmatter(f), changed: true };
454
+ return { text: region.join(), changed: true };
412
455
  }
413
456
 
@@ -20,6 +20,7 @@ import {
20
20
  type TargetKind,
21
21
  } from './access-mutation.service.js';
22
22
  import { canonicalRoleName, EVERYONE_CANONICAL, type Verb } from './access-control.service.js';
23
+ import { listAccessDeclarationsUnder } from './access-declarations.js';
23
24
  import { RolesAdminService, RolesAdminError } from './roles-admin.service.js';
24
25
  import type { Principal } from './access-splice.js';
25
26
  import type { Database } from '../database/connection.js';
@@ -171,6 +172,66 @@ export function createAccessRoutes(
171
172
  }
172
173
  });
173
174
 
175
+ /**
176
+ * GET /api/workspace/:id/access/overrides?path=<folder>
177
+ *
178
+ * Every access declaration living INSIDE a folder — the descendant
179
+ * `access.md` files and the node frontmatter that override the folder's own
180
+ * rules for the principals they name. Display-only: the group access surface
181
+ * shows it so nobody reads a folder's share list as the whole story.
182
+ *
183
+ * Gating is stricter than the sibling `GET /access`, which hands its eligible
184
+ * lists to any authenticated caller: here the caller must RESOLVE read on the
185
+ * folder (403 otherwise), and every returned row is additionally filtered by
186
+ * `canReadBatch` on what it governs. So the endpoint can only ever tell you
187
+ * about rules on things you can already see. It writes nothing and resolves
188
+ * nothing — it reports declarations through the resolver's own parsers.
189
+ */
190
+ router.get('/workspace/:id/access/overrides', async (req, res) => {
191
+ const user = await requireUser(req, res);
192
+ if (!user) return;
193
+
194
+ const rawPath = req.query.path;
195
+ if (typeof rawPath !== 'string' || !rawPath) {
196
+ res.status(400).json({ error: 'path query parameter is required' });
197
+ return;
198
+ }
199
+
200
+ try {
201
+ const folder = toRepoRelative(rawPath);
202
+ assertRepoRelativeTarget(folder, 'folder');
203
+
204
+ if (!(await accessControl.canRead(req.params.id, user.email, folder))) {
205
+ res.status(403).json({ error: 'You do not have access to this folder.' });
206
+ return;
207
+ }
208
+
209
+ const { overrides, truncated } = await listAccessDeclarationsUnder(
210
+ workspaceService,
211
+ req.params.id,
212
+ kbDirName,
213
+ folder,
214
+ );
215
+
216
+ // Read-filter per ROW. Folder read does not imply read on everything
217
+ // underneath — a `deny read` on one skill is precisely the kind of rule
218
+ // this endpoint lists, and echoing that skill's name back to the person
219
+ // it was denied to would leak the thing the rule exists to hide.
220
+ const governs = [...new Set(overrides.map((o) => o.governs))];
221
+ const readable = governs.length
222
+ ? await accessControl.canReadBatch(req.params.id, user.email, governs)
223
+ : new Map<string, boolean>();
224
+
225
+ res.json({
226
+ overrides: overrides.filter((o) => readable.get(o.governs) === true),
227
+ truncated,
228
+ });
229
+ } catch (err) {
230
+ const { status, body } = toHttpError(err);
231
+ res.status(status).json(body);
232
+ }
233
+ });
234
+
174
235
  /**
175
236
  * POST /api/workspace/:id/access/batch
176
237
  * Body: `{ paths: string[] }`. Returns `{ results: { [path]: boolean } }`.
@@ -87,6 +87,16 @@ export interface ICreatorAccess {
87
87
  noteAccessFileWritten(workspaceId: string): void;
88
88
  }
89
89
 
90
+ /**
91
+ * The splice-safe principal for a creator — exported because group
92
+ * provisioning (`GroupProvisionService`) writes the same `Name <email>`
93
+ * entries into the access.md files it seeds, and two spellings of the same
94
+ * person would read as two people.
95
+ */
96
+ export function creatorPrincipal(creator: Creator): Principal {
97
+ return { kind: 'user', email: creator.email, displayName: safeDisplayName(creator) };
98
+ }
99
+
90
100
  /**
91
101
  * `Name <email>`-safe display name: `validatePrincipal` rejects control
92
102
  * chars, `<`, `>`, and `#`, so strip those from the user's name and fall
@@ -159,6 +169,11 @@ export class CreatorAccessService implements ICreatorAccess {
159
169
  warnSkipped(rel, err);
160
170
  return null;
161
171
  }
172
+ // NOTE: a direct group folder (`Groups/<Name>`) is no longer special
173
+ // here. Groups — and personal folders — are made by the dedicated
174
+ // provisioning endpoint (`GroupProvisionService`), which writes the
175
+ // full ownership template itself; this generic read-grant only covers
176
+ // ad-hoc folder creation elsewhere in the tree.
162
177
  return {
163
178
  kind: 'seed-access-md',
164
179
  wsRelPath: `${this.kbDirName}/${acc}/access.md`,
@@ -166,7 +181,12 @@ export class CreatorAccessService implements ICreatorAccess {
166
181
  try {
167
182
  // Idempotent merge into whatever is on disk by write time — a
168
183
  // concurrent creator's grant survives; ours lands next to it.
169
- return spliceGrant(current, 'read', principal, { allowScalar: false }).text;
184
+ // `target: 'folder'` so a new-format access.md (body-governed)
185
+ // gets the grant in its FOLDER rules, never its self-frontmatter.
186
+ return spliceGrant(current, 'read', principal, {
187
+ allowScalar: false,
188
+ target: 'folder',
189
+ }).text;
170
190
  } catch (err) {
171
191
  warnSkipped(rel, err);
172
192
  return current;
@@ -249,7 +269,7 @@ export class CreatorAccessService implements ICreatorAccess {
249
269
  }
250
270
 
251
271
  private principalFor(creator: Creator): Principal {
252
- return { kind: 'user', email: creator.email, displayName: safeDisplayName(creator) };
272
+ return creatorPrincipal(creator);
253
273
  }
254
274
  }
255
275
 
@@ -17,7 +17,7 @@ export class AdminAccessService implements IAdminAccessService {
17
17
  private readonly accessControl: IAccessControl,
18
18
  private readonly workspaceService: WorkspaceService,
19
19
  /** Branch whose `roles.yaml` defines admins (the authoritative default branch). */
20
- private readonly branch: string,
20
+ private readonly branch: string | (() => string),
21
21
  /**
22
22
  * Emails treated as admin WITHOUT consulting `roles.yaml` — the env
23
23
  * bootstrap admin (`ADMIN_EMAIL`). A fresh deployment's KB may not list
@@ -36,8 +36,9 @@ export class AdminAccessService implements IAdminAccessService {
36
36
  // Ensure the authoritative clone is on disk, then resolve Admin-role
37
37
  // membership via the access model. Any failure (missing/invalid
38
38
  // roles.yaml, clone error) resolves to "not admin" — the safe default.
39
- await this.workspaceService.getOrCreateForBranch(this.branch);
40
- return await this.accessControl.canWrite(workspaceIdForBranch(this.branch), email, 'roles.yaml');
39
+ const branch = typeof this.branch === 'function' ? this.branch() : this.branch;
40
+ await this.workspaceService.getOrCreateForBranch(branch);
41
+ return await this.accessControl.canWrite(workspaceIdForBranch(branch), email, 'roles.yaml');
41
42
  } catch (err) {
42
43
  // Log so an admin lockout caused by a clone / roles.yaml failure is
43
44
  // diagnosable rather than a silent denial.
@@ -215,3 +215,60 @@ describe('AuthService.listAccounts', () => {
215
215
  expect(JSON.stringify(accounts)).not.toContain('scrypt:');
216
216
  });
217
217
  });
218
+
219
+ /**
220
+ * `ALLOWED_EMAIL_DOMAINS` is the SSO allow-list, and only that.
221
+ *
222
+ * It exists because SSO AUTO-PROVISIONS — `loginWithSso` upserts the account
223
+ * the first time the issuer authenticates someone, with nobody approving it.
224
+ * Against a multi-tenant issuer (Google, the Entra `common` endpoint) it is the
225
+ * only thing between "has an account somewhere" and "has an account here.
226
+ *
227
+ * The other two entry points do not have that property, which is why the guard
228
+ * was taken off them: an admin-created account is vetted by the act of creating
229
+ * it, and password login can only reach an account that already exists. The
230
+ * check gated nothing there — while being able to lock out a bootstrap admin
231
+ * whose own address sits outside the list, which the last test pins.
232
+ */
233
+ describe('AuthService — the SSO domain allow-list', () => {
234
+ const config = makeConfig({ allowedEmailDomains: ['bevel.software'] });
235
+
236
+ it('refuses an SSO sign-in from outside the allow-list', async () => {
237
+ const { db } = makeFakeDb([[]]);
238
+ await expect(
239
+ new AuthService(db, config).loginWithSso('someone@gmail.com', 'Someone'),
240
+ ).rejects.toThrow(/domain is not allowed/i);
241
+ });
242
+
243
+ it('admits a subdomain of an allowed domain', async () => {
244
+ const { db } = makeFakeDb([[{ ...ROW, email: 'eu@eu.bevel.software', name: 'EU' }]]);
245
+ const out = await new AuthService(db, config).loginWithSso('eu@eu.bevel.software', 'EU');
246
+ expect(out.user.email).toBe('eu@eu.bevel.software');
247
+ });
248
+
249
+ it('does NOT gate an account an admin creates', async () => {
250
+ const { db } = makeFakeDb([[{ ...ROW, email: 'contractor@gmail.com', name: 'Contractor' }]]);
251
+ const account = await new AuthService(db, config).createAccount(
252
+ 'contractor@gmail.com',
253
+ 'Contractor',
254
+ 'pw-longer-than-8',
255
+ );
256
+ expect(account.email).toBe('contractor@gmail.com');
257
+ });
258
+
259
+ it('does NOT lock the bootstrap admin out of their own deployment', async () => {
260
+ // The owner's address need not sit inside the SSO allow-list — the list is
261
+ // about who may provision themselves, not about who owns the deployment.
262
+ const outsideConfig = makeConfig({
263
+ allowedEmailDomains: ['bevel.software'],
264
+ adminEmail: 'root@gmail.com',
265
+ adminPassword: 'sup3r-secret',
266
+ });
267
+ const { db } = makeFakeDb([[{ ...ROW, email: 'root@gmail.com', name: 'root' }]]);
268
+ const out = await new AuthService(db, outsideConfig).loginWithPassword(
269
+ 'root@gmail.com',
270
+ 'sup3r-secret',
271
+ );
272
+ expect(out.user.email).toBe('root@gmail.com');
273
+ });
274
+ });