@bevel-software/platform-core-backend 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (720) hide show
  1. package/LICENSE +202 -0
  2. package/dist/assets.d.ts +5 -0
  3. package/dist/assets.d.ts.map +1 -0
  4. package/dist/assets.js +23 -0
  5. package/dist/assets.js.map +1 -0
  6. package/dist/core/core-ports.d.ts +83 -0
  7. package/dist/core/core-ports.d.ts.map +1 -0
  8. package/dist/core/core-ports.js +12 -0
  9. package/dist/core/core-ports.js.map +1 -0
  10. package/dist/core/create-core-server.d.ts +62 -0
  11. package/dist/core/create-core-server.d.ts.map +1 -0
  12. package/dist/core/create-core-server.js +204 -0
  13. package/dist/core/create-core-server.js.map +1 -0
  14. package/dist/core/create-core-services.d.ts +95 -0
  15. package/dist/core/create-core-services.d.ts.map +1 -0
  16. package/dist/core/create-core-services.js +352 -0
  17. package/dist/core/create-core-services.js.map +1 -0
  18. package/dist/core-config.d.ts +119 -0
  19. package/dist/core-config.d.ts.map +1 -0
  20. package/dist/core-config.js +240 -0
  21. package/dist/core-config.js.map +1 -0
  22. package/dist/index.d.ts +36 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +36 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/modules/access/access-control.interface.d.ts +348 -0
  27. package/dist/modules/access/access-control.interface.d.ts.map +1 -0
  28. package/dist/modules/access/access-control.interface.js +2 -0
  29. package/dist/modules/access/access-control.interface.js.map +1 -0
  30. package/dist/modules/access/access-control.service.d.ts +323 -0
  31. package/dist/modules/access/access-control.service.d.ts.map +1 -0
  32. package/dist/modules/access/access-control.service.js +1431 -0
  33. package/dist/modules/access/access-control.service.js.map +1 -0
  34. package/dist/modules/access/access-errors.d.ts +34 -0
  35. package/dist/modules/access/access-errors.d.ts.map +1 -0
  36. package/dist/modules/access/access-errors.js +40 -0
  37. package/dist/modules/access/access-errors.js.map +1 -0
  38. package/dist/modules/access/access-mutation.service.d.ts +176 -0
  39. package/dist/modules/access/access-mutation.service.d.ts.map +1 -0
  40. package/dist/modules/access/access-mutation.service.js +307 -0
  41. package/dist/modules/access/access-mutation.service.js.map +1 -0
  42. package/dist/modules/access/access-splice.d.ts +98 -0
  43. package/dist/modules/access/access-splice.d.ts.map +1 -0
  44. package/dist/modules/access/access-splice.js +346 -0
  45. package/dist/modules/access/access-splice.js.map +1 -0
  46. package/dist/modules/access/access.routes.d.ts +10 -0
  47. package/dist/modules/access/access.routes.d.ts.map +1 -0
  48. package/dist/modules/access/access.routes.js +757 -0
  49. package/dist/modules/access/access.routes.js.map +1 -0
  50. package/dist/modules/access/creator-access.d.ts +113 -0
  51. package/dist/modules/access/creator-access.d.ts.map +1 -0
  52. package/dist/modules/access/creator-access.js +212 -0
  53. package/dist/modules/access/creator-access.js.map +1 -0
  54. package/dist/modules/access/kb-read-filter.d.ts +41 -0
  55. package/dist/modules/access/kb-read-filter.d.ts.map +1 -0
  56. package/dist/modules/access/kb-read-filter.js +60 -0
  57. package/dist/modules/access/kb-read-filter.js.map +1 -0
  58. package/dist/modules/access/roles-admin.service.d.ts +240 -0
  59. package/dist/modules/access/roles-admin.service.d.ts.map +1 -0
  60. package/dist/modules/access/roles-admin.service.js +630 -0
  61. package/dist/modules/access/roles-admin.service.js.map +1 -0
  62. package/dist/modules/access/roles-edit.d.ts +91 -0
  63. package/dist/modules/access/roles-edit.d.ts.map +1 -0
  64. package/dist/modules/access/roles-edit.js +232 -0
  65. package/dist/modules/access/roles-edit.js.map +1 -0
  66. package/dist/modules/access/roles-yaml-guard.d.ts +56 -0
  67. package/dist/modules/access/roles-yaml-guard.d.ts.map +1 -0
  68. package/dist/modules/access/roles-yaml-guard.js +79 -0
  69. package/dist/modules/access/roles-yaml-guard.js.map +1 -0
  70. package/dist/modules/admin/admin-access.routes.d.ts +18 -0
  71. package/dist/modules/admin/admin-access.routes.d.ts.map +1 -0
  72. package/dist/modules/admin/admin-access.routes.js +26 -0
  73. package/dist/modules/admin/admin-access.routes.js.map +1 -0
  74. package/dist/modules/admin/admin-access.service.d.ts +24 -0
  75. package/dist/modules/admin/admin-access.service.d.ts.map +1 -0
  76. package/dist/modules/admin/admin-access.service.js +41 -0
  77. package/dist/modules/admin/admin-access.service.js.map +1 -0
  78. package/dist/modules/admin/admin.interface.d.ts +11 -0
  79. package/dist/modules/admin/admin.interface.d.ts.map +1 -0
  80. package/dist/modules/admin/admin.interface.js +2 -0
  81. package/dist/modules/admin/admin.interface.js.map +1 -0
  82. package/dist/modules/auth/account-erasure.service.d.ts +80 -0
  83. package/dist/modules/auth/account-erasure.service.d.ts.map +1 -0
  84. package/dist/modules/auth/account-erasure.service.js +91 -0
  85. package/dist/modules/auth/account-erasure.service.js.map +1 -0
  86. package/dist/modules/auth/auth.middleware.d.ts +23 -0
  87. package/dist/modules/auth/auth.middleware.d.ts.map +1 -0
  88. package/dist/modules/auth/auth.middleware.js +68 -0
  89. package/dist/modules/auth/auth.middleware.js.map +1 -0
  90. package/dist/modules/auth/auth.routes.d.ts +25 -0
  91. package/dist/modules/auth/auth.routes.d.ts.map +1 -0
  92. package/dist/modules/auth/auth.routes.js +72 -0
  93. package/dist/modules/auth/auth.routes.js.map +1 -0
  94. package/dist/modules/auth/auth.service.d.ts +76 -0
  95. package/dist/modules/auth/auth.service.d.ts.map +1 -0
  96. package/dist/modules/auth/auth.service.js +168 -0
  97. package/dist/modules/auth/auth.service.js.map +1 -0
  98. package/dist/modules/code-mode/code-mode-names.d.ts +16 -0
  99. package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -0
  100. package/dist/modules/code-mode/code-mode-names.js +30 -0
  101. package/dist/modules/code-mode/code-mode-names.js.map +1 -0
  102. package/dist/modules/code-mode/code-mode.tool.d.ts +13 -0
  103. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -0
  104. package/dist/modules/code-mode/code-mode.tool.js +112 -0
  105. package/dist/modules/code-mode/code-mode.tool.js.map +1 -0
  106. package/dist/modules/code-mode/index.d.ts +3 -0
  107. package/dist/modules/code-mode/index.d.ts.map +1 -0
  108. package/dist/modules/code-mode/index.js +3 -0
  109. package/dist/modules/code-mode/index.js.map +1 -0
  110. package/dist/modules/database/connection.d.ts +6 -0
  111. package/dist/modules/database/connection.d.ts.map +1 -0
  112. package/dist/modules/database/connection.js +12 -0
  113. package/dist/modules/database/connection.js.map +1 -0
  114. package/dist/modules/database/core-schema.d.ts +2273 -0
  115. package/dist/modules/database/core-schema.d.ts.map +1 -0
  116. package/dist/modules/database/core-schema.js +413 -0
  117. package/dist/modules/database/core-schema.js.map +1 -0
  118. package/dist/modules/database/migrate.d.ts +10 -0
  119. package/dist/modules/database/migrate.d.ts.map +1 -0
  120. package/dist/modules/database/migrate.js +40 -0
  121. package/dist/modules/database/migrate.js.map +1 -0
  122. package/dist/modules/database/schema.d.ts +12 -0
  123. package/dist/modules/database/schema.d.ts.map +1 -0
  124. package/dist/modules/database/schema.js +12 -0
  125. package/dist/modules/database/schema.js.map +1 -0
  126. package/dist/modules/diff/diff-paths.d.ts +2 -0
  127. package/dist/modules/diff/diff-paths.d.ts.map +1 -0
  128. package/dist/modules/diff/diff-paths.js +9 -0
  129. package/dist/modules/diff/diff-paths.js.map +1 -0
  130. package/dist/modules/diff/diff.config.d.ts +8 -0
  131. package/dist/modules/diff/diff.config.d.ts.map +1 -0
  132. package/dist/modules/diff/diff.config.js +13 -0
  133. package/dist/modules/diff/diff.config.js.map +1 -0
  134. package/dist/modules/diff/diff.interface.d.ts +73 -0
  135. package/dist/modules/diff/diff.interface.d.ts.map +1 -0
  136. package/dist/modules/diff/diff.interface.js +2 -0
  137. package/dist/modules/diff/diff.interface.js.map +1 -0
  138. package/dist/modules/diff/diff.routes.d.ts +35 -0
  139. package/dist/modules/diff/diff.routes.d.ts.map +1 -0
  140. package/dist/modules/diff/diff.routes.js +228 -0
  141. package/dist/modules/diff/diff.routes.js.map +1 -0
  142. package/dist/modules/diff/diff.service.d.ts +51 -0
  143. package/dist/modules/diff/diff.service.d.ts.map +1 -0
  144. package/dist/modules/diff/diff.service.js +489 -0
  145. package/dist/modules/diff/diff.service.js.map +1 -0
  146. package/dist/modules/diff/line-diff.d.ts +16 -0
  147. package/dist/modules/diff/line-diff.d.ts.map +1 -0
  148. package/dist/modules/diff/line-diff.js +67 -0
  149. package/dist/modules/diff/line-diff.js.map +1 -0
  150. package/dist/modules/mcp/mcp-auth.middleware.d.ts +27 -0
  151. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -0
  152. package/dist/modules/mcp/mcp-auth.middleware.js +116 -0
  153. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -0
  154. package/dist/modules/mcp/mcp-session-store.d.ts +74 -0
  155. package/dist/modules/mcp/mcp-session-store.d.ts.map +1 -0
  156. package/dist/modules/mcp/mcp-session-store.js +131 -0
  157. package/dist/modules/mcp/mcp-session-store.js.map +1 -0
  158. package/dist/modules/mcp/mcp.routes.d.ts +24 -0
  159. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -0
  160. package/dist/modules/mcp/mcp.routes.js +229 -0
  161. package/dist/modules/mcp/mcp.routes.js.map +1 -0
  162. package/dist/modules/mcp/mcp.service.d.ts +198 -0
  163. package/dist/modules/mcp/mcp.service.d.ts.map +1 -0
  164. package/dist/modules/mcp/mcp.service.js +840 -0
  165. package/dist/modules/mcp/mcp.service.js.map +1 -0
  166. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +76 -0
  167. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -0
  168. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +291 -0
  169. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -0
  170. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts +18 -0
  171. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts.map +1 -0
  172. package/dist/modules/mcp/oauth/oauth-consent.routes.js +52 -0
  173. package/dist/modules/mcp/oauth/oauth-consent.routes.js.map +1 -0
  174. package/dist/modules/mcp/oauth/oauth-state.d.ts +32 -0
  175. package/dist/modules/mcp/oauth/oauth-state.d.ts.map +1 -0
  176. package/dist/modules/mcp/oauth/oauth-state.js +34 -0
  177. package/dist/modules/mcp/oauth/oauth-state.js.map +1 -0
  178. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +92 -0
  179. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -0
  180. package/dist/modules/secrets-vault/db-secrets-vault.service.js +728 -0
  181. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -0
  182. package/dist/modules/secrets-vault/index.d.ts +7 -0
  183. package/dist/modules/secrets-vault/index.d.ts.map +1 -0
  184. package/dist/modules/secrets-vault/index.js +6 -0
  185. package/dist/modules/secrets-vault/index.js.map +1 -0
  186. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +63 -0
  187. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -0
  188. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +269 -0
  189. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -0
  190. package/dist/modules/secrets-vault/secrets-variable-loader.d.ts +46 -0
  191. package/dist/modules/secrets-vault/secrets-variable-loader.d.ts.map +1 -0
  192. package/dist/modules/secrets-vault/secrets-variable-loader.js +87 -0
  193. package/dist/modules/secrets-vault/secrets-variable-loader.js.map +1 -0
  194. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +210 -0
  195. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -0
  196. package/dist/modules/secrets-vault/secrets-vault.contract.js +90 -0
  197. package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -0
  198. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +30 -0
  199. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -0
  200. package/dist/modules/secrets-vault/secrets-vault.routes.js +490 -0
  201. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -0
  202. package/dist/modules/skills/index.d.ts +5 -0
  203. package/dist/modules/skills/index.d.ts.map +1 -0
  204. package/dist/modules/skills/index.js +4 -0
  205. package/dist/modules/skills/index.js.map +1 -0
  206. package/dist/modules/skills/skills.contract.d.ts +58 -0
  207. package/dist/modules/skills/skills.contract.d.ts.map +1 -0
  208. package/dist/modules/skills/skills.contract.js +10 -0
  209. package/dist/modules/skills/skills.contract.js.map +1 -0
  210. package/dist/modules/skills/skills.routes.d.ts +15 -0
  211. package/dist/modules/skills/skills.routes.d.ts.map +1 -0
  212. package/dist/modules/skills/skills.routes.js +34 -0
  213. package/dist/modules/skills/skills.routes.js.map +1 -0
  214. package/dist/modules/skills/skills.service.d.ts +21 -0
  215. package/dist/modules/skills/skills.service.d.ts.map +1 -0
  216. package/dist/modules/skills/skills.service.js +202 -0
  217. package/dist/modules/skills/skills.service.js.map +1 -0
  218. package/dist/modules/skills/skills.tools.d.ts +15 -0
  219. package/dist/modules/skills/skills.tools.d.ts.map +1 -0
  220. package/dist/modules/skills/skills.tools.js +96 -0
  221. package/dist/modules/skills/skills.tools.js.map +1 -0
  222. package/dist/modules/tool-auth/external-api-key.errors.d.ts +14 -0
  223. package/dist/modules/tool-auth/external-api-key.errors.d.ts.map +1 -0
  224. package/dist/modules/tool-auth/external-api-key.errors.js +23 -0
  225. package/dist/modules/tool-auth/external-api-key.errors.js.map +1 -0
  226. package/dist/modules/tool-auth/external-api-key.interface.d.ts +90 -0
  227. package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -0
  228. package/dist/modules/tool-auth/external-api-key.interface.js +2 -0
  229. package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -0
  230. package/dist/modules/tool-auth/external-api-key.service.d.ts +25 -0
  231. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -0
  232. package/dist/modules/tool-auth/external-api-key.service.js +164 -0
  233. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -0
  234. package/dist/modules/tool-auth/internal-token.service.d.ts +62 -0
  235. package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -0
  236. package/dist/modules/tool-auth/internal-token.service.js +85 -0
  237. package/dist/modules/tool-auth/internal-token.service.js.map +1 -0
  238. package/dist/modules/tool-auth/llm-usage-meter.d.ts +16 -0
  239. package/dist/modules/tool-auth/llm-usage-meter.d.ts.map +1 -0
  240. package/dist/modules/tool-auth/llm-usage-meter.js +6 -0
  241. package/dist/modules/tool-auth/llm-usage-meter.js.map +1 -0
  242. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +101 -0
  243. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -0
  244. package/dist/modules/tool-auth/tool-auth.middleware.js +166 -0
  245. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -0
  246. package/dist/modules/tool-helpers/index.d.ts +19 -0
  247. package/dist/modules/tool-helpers/index.d.ts.map +1 -0
  248. package/dist/modules/tool-helpers/index.js +18 -0
  249. package/dist/modules/tool-helpers/index.js.map +1 -0
  250. package/dist/modules/tool-helpers/tool-context.d.ts +35 -0
  251. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -0
  252. package/dist/modules/tool-helpers/tool-context.js +66 -0
  253. package/dist/modules/tool-helpers/tool-context.js.map +1 -0
  254. package/dist/modules/tool-helpers/tool-def.d.ts +33 -0
  255. package/dist/modules/tool-helpers/tool-def.d.ts.map +1 -0
  256. package/dist/modules/tool-helpers/tool-def.js +57 -0
  257. package/dist/modules/tool-helpers/tool-def.js.map +1 -0
  258. package/dist/modules/tool-helpers/tool-handler.d.ts +19 -0
  259. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -0
  260. package/dist/modules/tool-helpers/tool-handler.js +84 -0
  261. package/dist/modules/tool-helpers/tool-handler.js.map +1 -0
  262. package/dist/modules/tool-helpers/tool.contract.d.ts +64 -0
  263. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -0
  264. package/dist/modules/tool-helpers/tool.contract.js +24 -0
  265. package/dist/modules/tool-helpers/tool.contract.js.map +1 -0
  266. package/dist/modules/tool-helpers/validate-token.d.ts +31 -0
  267. package/dist/modules/tool-helpers/validate-token.d.ts.map +1 -0
  268. package/dist/modules/tool-helpers/validate-token.js +20 -0
  269. package/dist/modules/tool-helpers/validate-token.js.map +1 -0
  270. package/dist/modules/tool-manuals/index.d.ts +5 -0
  271. package/dist/modules/tool-manuals/index.d.ts.map +1 -0
  272. package/dist/modules/tool-manuals/index.js +5 -0
  273. package/dist/modules/tool-manuals/index.js.map +1 -0
  274. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +182 -0
  275. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -0
  276. package/dist/modules/tool-manuals/tool-manuals.contract.js +19 -0
  277. package/dist/modules/tool-manuals/tool-manuals.contract.js.map +1 -0
  278. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +24 -0
  279. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -0
  280. package/dist/modules/tool-manuals/tool-manuals.routes.js +102 -0
  281. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -0
  282. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +108 -0
  283. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -0
  284. package/dist/modules/tool-manuals/tool-manuals.service.js +710 -0
  285. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -0
  286. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +35 -0
  287. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -0
  288. package/dist/modules/tool-manuals/tool-manuals.tools.js +157 -0
  289. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -0
  290. package/dist/modules/tool-registry/manual.routes.d.ts +6 -0
  291. package/dist/modules/tool-registry/manual.routes.d.ts.map +1 -0
  292. package/dist/modules/tool-registry/manual.routes.js +76 -0
  293. package/dist/modules/tool-registry/manual.routes.js.map +1 -0
  294. package/dist/modules/tool-registry/tool-registry.d.ts +23 -0
  295. package/dist/modules/tool-registry/tool-registry.d.ts.map +1 -0
  296. package/dist/modules/tool-registry/tool-registry.js +46 -0
  297. package/dist/modules/tool-registry/tool-registry.js.map +1 -0
  298. package/dist/modules/tool-registry/tool.contract.d.ts +48 -0
  299. package/dist/modules/tool-registry/tool.contract.d.ts.map +1 -0
  300. package/dist/modules/tool-registry/tool.contract.js +2 -0
  301. package/dist/modules/tool-registry/tool.contract.js.map +1 -0
  302. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +14 -0
  303. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -0
  304. package/dist/modules/workflow/agent-tools/workflow.tools.js +437 -0
  305. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -0
  306. package/dist/modules/workflow/event-bus.d.ts +107 -0
  307. package/dist/modules/workflow/event-bus.d.ts.map +1 -0
  308. package/dist/modules/workflow/event-bus.js +224 -0
  309. package/dist/modules/workflow/event-bus.js.map +1 -0
  310. package/dist/modules/workflow/events.routes.d.ts +80 -0
  311. package/dist/modules/workflow/events.routes.d.ts.map +1 -0
  312. package/dist/modules/workflow/events.routes.js +216 -0
  313. package/dist/modules/workflow/events.routes.js.map +1 -0
  314. package/dist/modules/workflow/file-change-notifier.d.ts +38 -0
  315. package/dist/modules/workflow/file-change-notifier.d.ts.map +1 -0
  316. package/dist/modules/workflow/file-change-notifier.js +22 -0
  317. package/dist/modules/workflow/file-change-notifier.js.map +1 -0
  318. package/dist/modules/workflow/file-lock.service.d.ts +57 -0
  319. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -0
  320. package/dist/modules/workflow/file-lock.service.js +202 -0
  321. package/dist/modules/workflow/file-lock.service.js.map +1 -0
  322. package/dist/modules/workflow/git/branch-name.d.ts +10 -0
  323. package/dist/modules/workflow/git/branch-name.d.ts.map +1 -0
  324. package/dist/modules/workflow/git/branch-name.js +76 -0
  325. package/dist/modules/workflow/git/branch-name.js.map +1 -0
  326. package/dist/modules/workflow/git/clone-config.d.ts +61 -0
  327. package/dist/modules/workflow/git/clone-config.d.ts.map +1 -0
  328. package/dist/modules/workflow/git/clone-config.js +69 -0
  329. package/dist/modules/workflow/git/clone-config.js.map +1 -0
  330. package/dist/modules/workflow/git/git-version.d.ts +34 -0
  331. package/dist/modules/workflow/git/git-version.d.ts.map +1 -0
  332. package/dist/modules/workflow/git/git-version.js +63 -0
  333. package/dist/modules/workflow/git/git-version.js.map +1 -0
  334. package/dist/modules/workflow/git/git.service.d.ts +427 -0
  335. package/dist/modules/workflow/git/git.service.d.ts.map +1 -0
  336. package/dist/modules/workflow/git/git.service.js +1987 -0
  337. package/dist/modules/workflow/git/git.service.js.map +1 -0
  338. package/dist/modules/workflow/git/mutex.d.ts +11 -0
  339. package/dist/modules/workflow/git/mutex.d.ts.map +1 -0
  340. package/dist/modules/workflow/git/mutex.js +23 -0
  341. package/dist/modules/workflow/git/mutex.js.map +1 -0
  342. package/dist/modules/workflow/git/pull-request.service.d.ts +124 -0
  343. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -0
  344. package/dist/modules/workflow/git/pull-request.service.js +328 -0
  345. package/dist/modules/workflow/git/pull-request.service.js.map +1 -0
  346. package/dist/modules/workflow/locking-filesystem.d.ts +133 -0
  347. package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -0
  348. package/dist/modules/workflow/locking-filesystem.js +400 -0
  349. package/dist/modules/workflow/locking-filesystem.js.map +1 -0
  350. package/dist/modules/workflow/pending-commits.service.d.ts +221 -0
  351. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -0
  352. package/dist/modules/workflow/pending-commits.service.js +364 -0
  353. package/dist/modules/workflow/pending-commits.service.js.map +1 -0
  354. package/dist/modules/workflow/pending-commits.worker.d.ts +148 -0
  355. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -0
  356. package/dist/modules/workflow/pending-commits.worker.js +245 -0
  357. package/dist/modules/workflow/pending-commits.worker.js.map +1 -0
  358. package/dist/modules/workflow/read-only-filesystem.d.ts +24 -0
  359. package/dist/modules/workflow/read-only-filesystem.d.ts.map +1 -0
  360. package/dist/modules/workflow/read-only-filesystem.js +39 -0
  361. package/dist/modules/workflow/read-only-filesystem.js.map +1 -0
  362. package/dist/modules/workflow/recovery-bot.d.ts +31 -0
  363. package/dist/modules/workflow/recovery-bot.d.ts.map +1 -0
  364. package/dist/modules/workflow/recovery-bot.js +64 -0
  365. package/dist/modules/workflow/recovery-bot.js.map +1 -0
  366. package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts +137 -0
  367. package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts.map +1 -0
  368. package/dist/modules/workflow/review-workflow/review-workflow.interface.js +2 -0
  369. package/dist/modules/workflow/review-workflow/review-workflow.interface.js.map +1 -0
  370. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts +26 -0
  371. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -0
  372. package/dist/modules/workflow/review-workflow/review-workflow.service.js +636 -0
  373. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -0
  374. package/dist/modules/workflow/sanitize-error.d.ts +29 -0
  375. package/dist/modules/workflow/sanitize-error.d.ts.map +1 -0
  376. package/dist/modules/workflow/sanitize-error.js +60 -0
  377. package/dist/modules/workflow/sanitize-error.js.map +1 -0
  378. package/dist/modules/workflow/session-ontology.policy.d.ts +52 -0
  379. package/dist/modules/workflow/session-ontology.policy.d.ts.map +1 -0
  380. package/dist/modules/workflow/session-ontology.policy.js +62 -0
  381. package/dist/modules/workflow/session-ontology.policy.js.map +1 -0
  382. package/dist/modules/workflow/session-ontology.service.d.ts +100 -0
  383. package/dist/modules/workflow/session-ontology.service.d.ts.map +1 -0
  384. package/dist/modules/workflow/session-ontology.service.js +143 -0
  385. package/dist/modules/workflow/session-ontology.service.js.map +1 -0
  386. package/dist/modules/workflow/workflow-hooks.d.ts +87 -0
  387. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -0
  388. package/dist/modules/workflow/workflow-hooks.js +35 -0
  389. package/dist/modules/workflow/workflow-hooks.js.map +1 -0
  390. package/dist/modules/workflow/workflow.errors.d.ts +171 -0
  391. package/dist/modules/workflow/workflow.errors.d.ts.map +1 -0
  392. package/dist/modules/workflow/workflow.errors.js +260 -0
  393. package/dist/modules/workflow/workflow.errors.js.map +1 -0
  394. package/dist/modules/workflow/workflow.routes.d.ts +26 -0
  395. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -0
  396. package/dist/modules/workflow/workflow.routes.js +804 -0
  397. package/dist/modules/workflow/workflow.routes.js.map +1 -0
  398. package/dist/modules/workflow/workflow.service.d.ts +385 -0
  399. package/dist/modules/workflow/workflow.service.d.ts.map +1 -0
  400. package/dist/modules/workflow/workflow.service.js +1155 -0
  401. package/dist/modules/workflow/workflow.service.js.map +1 -0
  402. package/dist/modules/workspace/bevel-ignore.d.ts +25 -0
  403. package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -0
  404. package/dist/modules/workspace/bevel-ignore.js +57 -0
  405. package/dist/modules/workspace/bevel-ignore.js.map +1 -0
  406. package/dist/modules/workspace/kb-seed.interface.d.ts +36 -0
  407. package/dist/modules/workspace/kb-seed.interface.d.ts.map +1 -0
  408. package/dist/modules/workspace/kb-seed.interface.js +2 -0
  409. package/dist/modules/workspace/kb-seed.interface.js.map +1 -0
  410. package/dist/modules/workspace/kb-seed.service.d.ts +70 -0
  411. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -0
  412. package/dist/modules/workspace/kb-seed.service.js +325 -0
  413. package/dist/modules/workspace/kb-seed.service.js.map +1 -0
  414. package/dist/modules/workspace/routine-write-policy.d.ts +49 -0
  415. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -0
  416. package/dist/modules/workspace/routine-write-policy.js +46 -0
  417. package/dist/modules/workspace/routine-write-policy.js.map +1 -0
  418. package/dist/modules/workspace/session-ontology.gate.d.ts +112 -0
  419. package/dist/modules/workspace/session-ontology.gate.d.ts.map +1 -0
  420. package/dist/modules/workspace/session-ontology.gate.js +161 -0
  421. package/dist/modules/workspace/session-ontology.gate.js.map +1 -0
  422. package/dist/modules/workspace/session-sink.d.ts +25 -0
  423. package/dist/modules/workspace/session-sink.d.ts.map +1 -0
  424. package/dist/modules/workspace/session-sink.js +8 -0
  425. package/dist/modules/workspace/session-sink.js.map +1 -0
  426. package/dist/modules/workspace/spill-store.d.ts +24 -0
  427. package/dist/modules/workspace/spill-store.d.ts.map +1 -0
  428. package/dist/modules/workspace/spill-store.js +87 -0
  429. package/dist/modules/workspace/spill-store.js.map +1 -0
  430. package/dist/modules/workspace/workspace.routes.d.ts +25 -0
  431. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -0
  432. package/dist/modules/workspace/workspace.routes.js +1046 -0
  433. package/dist/modules/workspace/workspace.routes.js.map +1 -0
  434. package/dist/modules/workspace/workspace.service.d.ts +339 -0
  435. package/dist/modules/workspace/workspace.service.d.ts.map +1 -0
  436. package/dist/modules/workspace/workspace.service.js +1221 -0
  437. package/dist/modules/workspace/workspace.service.js.map +1 -0
  438. package/dist/modules/workspace/workspace.tools.d.ts +19 -0
  439. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -0
  440. package/dist/modules/workspace/workspace.tools.js +767 -0
  441. package/dist/modules/workspace/workspace.tools.js.map +1 -0
  442. package/dist/shared/frontmatter-id.d.ts +35 -0
  443. package/dist/shared/frontmatter-id.d.ts.map +1 -0
  444. package/dist/shared/frontmatter-id.js +68 -0
  445. package/dist/shared/frontmatter-id.js.map +1 -0
  446. package/dist/shared/fs-walk.d.ts +9 -0
  447. package/dist/shared/fs-walk.d.ts.map +1 -0
  448. package/dist/shared/fs-walk.js +34 -0
  449. package/dist/shared/fs-walk.js.map +1 -0
  450. package/dist/shared/hash-email.d.ts +11 -0
  451. package/dist/shared/hash-email.d.ts.map +1 -0
  452. package/dist/shared/hash-email.js +14 -0
  453. package/dist/shared/hash-email.js.map +1 -0
  454. package/dist/shared/kb-layout.d.ts +38 -0
  455. package/dist/shared/kb-layout.d.ts.map +1 -0
  456. package/dist/shared/kb-layout.js +102 -0
  457. package/dist/shared/kb-layout.js.map +1 -0
  458. package/dist/shared/kb-layout.test.d.ts +2 -0
  459. package/dist/shared/kb-layout.test.d.ts.map +1 -0
  460. package/dist/shared/kb-layout.test.js +67 -0
  461. package/dist/shared/kb-layout.test.js.map +1 -0
  462. package/dist/shared/ssrf.d.ts +20 -0
  463. package/dist/shared/ssrf.d.ts.map +1 -0
  464. package/dist/shared/ssrf.js +89 -0
  465. package/dist/shared/ssrf.js.map +1 -0
  466. package/dist/shared/token-crypto.d.ts +16 -0
  467. package/dist/shared/token-crypto.d.ts.map +1 -0
  468. package/dist/shared/token-crypto.js +49 -0
  469. package/dist/shared/token-crypto.js.map +1 -0
  470. package/dist/shared/ttl-cache.d.ts +16 -0
  471. package/dist/shared/ttl-cache.d.ts.map +1 -0
  472. package/dist/shared/ttl-cache.js +27 -0
  473. package/dist/shared/ttl-cache.js.map +1 -0
  474. package/dist/shared/utcp-namespace.d.ts +46 -0
  475. package/dist/shared/utcp-namespace.d.ts.map +1 -0
  476. package/dist/shared/utcp-namespace.js +82 -0
  477. package/dist/shared/utcp-namespace.js.map +1 -0
  478. package/dist/version.d.ts +17 -0
  479. package/dist/version.d.ts.map +1 -0
  480. package/dist/version.js +19 -0
  481. package/dist/version.js.map +1 -0
  482. package/kb-template/.bevelignore +21 -0
  483. package/kb-template/Agents/.gitkeep +0 -0
  484. package/kb-template/Agents/README.md +38 -0
  485. package/kb-template/CLAUDE.md +419 -0
  486. package/kb-template/Data/.gitkeep +0 -0
  487. package/kb-template/Data/README.md +39 -0
  488. package/kb-template/KnowledgeBase/.gitkeep +0 -0
  489. package/kb-template/KnowledgeBase/README.md +37 -0
  490. package/kb-template/Pipelines/.gitkeep +0 -0
  491. package/kb-template/Pipelines/README.md +38 -0
  492. package/kb-template/Skills/.gitkeep +0 -0
  493. package/kb-template/Skills/README.md +32 -0
  494. package/kb-template/Tools/.gitkeep +0 -0
  495. package/kb-template/Tools/README.md +42 -0
  496. package/kb-template/access.md +36 -0
  497. package/migrations/0000_core_init.sql +243 -0
  498. package/migrations/meta/0000_snapshot.json +1480 -0
  499. package/migrations/meta/_journal.json +13 -0
  500. package/package.json +80 -0
  501. package/src/assets.ts +25 -0
  502. package/src/core/core-ports.ts +91 -0
  503. package/src/core/create-core-server.ts +338 -0
  504. package/src/core/create-core-services.ts +501 -0
  505. package/src/core-config.ts +257 -0
  506. package/src/index.ts +69 -0
  507. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -0
  508. package/src/modules/access/__tests__/access-control.read-batch.test.ts +132 -0
  509. package/src/modules/access/__tests__/access-control.service.test.ts +1011 -0
  510. package/src/modules/access/__tests__/access-mutation.service.test.ts +439 -0
  511. package/src/modules/access/__tests__/access-splice.test.ts +247 -0
  512. package/src/modules/access/__tests__/access.routes.revoke.test.ts +407 -0
  513. package/src/modules/access/__tests__/creator-access.test.ts +217 -0
  514. package/src/modules/access/__tests__/grant-sources.test.ts +204 -0
  515. package/src/modules/access/__tests__/kb-read-filter.test.ts +70 -0
  516. package/src/modules/access/__tests__/roles-admin.service.test.ts +469 -0
  517. package/src/modules/access/__tests__/roles-edit.test.ts +138 -0
  518. package/src/modules/access/__tests__/roles-yaml-guard.test.ts +70 -0
  519. package/src/modules/access/__tests__/roles.routes.test.ts +234 -0
  520. package/src/modules/access/access-control.interface.ts +383 -0
  521. package/src/modules/access/access-control.service.ts +1794 -0
  522. package/src/modules/access/access-errors.ts +60 -0
  523. package/src/modules/access/access-mutation.service.ts +369 -0
  524. package/src/modules/access/access-splice.ts +413 -0
  525. package/src/modules/access/access.routes.ts +894 -0
  526. package/src/modules/access/creator-access.ts +270 -0
  527. package/src/modules/access/kb-read-filter.ts +76 -0
  528. package/src/modules/access/roles-admin.service.ts +741 -0
  529. package/src/modules/access/roles-edit.ts +248 -0
  530. package/src/modules/access/roles-yaml-guard.ts +89 -0
  531. package/src/modules/admin/admin-access.routes.ts +29 -0
  532. package/src/modules/admin/admin-access.service.ts +38 -0
  533. package/src/modules/admin/admin.interface.ts +10 -0
  534. package/src/modules/auth/account-erasure.service.ts +183 -0
  535. package/src/modules/auth/auth.middleware.ts +78 -0
  536. package/src/modules/auth/auth.routes.ts +97 -0
  537. package/src/modules/auth/auth.service.ts +199 -0
  538. package/src/modules/code-mode/code-mode-names.ts +38 -0
  539. package/src/modules/code-mode/code-mode.tool.ts +117 -0
  540. package/src/modules/code-mode/index.ts +6 -0
  541. package/src/modules/database/connection.ts +15 -0
  542. package/src/modules/database/core-schema.ts +431 -0
  543. package/src/modules/database/migrate.ts +43 -0
  544. package/src/modules/database/schema.ts +11 -0
  545. package/src/modules/diff/__tests__/diff.routes.read-gate.test.ts +87 -0
  546. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -0
  547. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +184 -0
  548. package/src/modules/diff/__tests__/diff.service.test.ts +202 -0
  549. package/src/modules/diff/diff-paths.ts +9 -0
  550. package/src/modules/diff/diff.config.ts +12 -0
  551. package/src/modules/diff/diff.interface.ts +78 -0
  552. package/src/modules/diff/diff.routes.ts +277 -0
  553. package/src/modules/diff/diff.service.ts +508 -0
  554. package/src/modules/diff/line-diff.ts +79 -0
  555. package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +287 -0
  556. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +249 -0
  557. package/src/modules/mcp/__tests__/mcp-proxy-helpers.test.ts +253 -0
  558. package/src/modules/mcp/__tests__/mcp-session-store.test.ts +151 -0
  559. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +150 -0
  560. package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +92 -0
  561. package/src/modules/mcp/__tests__/mcp.service.test.ts +414 -0
  562. package/src/modules/mcp/mcp-auth.middleware.ts +126 -0
  563. package/src/modules/mcp/mcp-session-store.ts +162 -0
  564. package/src/modules/mcp/mcp.routes.ts +263 -0
  565. package/src/modules/mcp/mcp.service.ts +973 -0
  566. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +401 -0
  567. package/src/modules/mcp/oauth/oauth-consent.routes.ts +60 -0
  568. package/src/modules/mcp/oauth/oauth-state.ts +61 -0
  569. package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +277 -0
  570. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +206 -0
  571. package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +178 -0
  572. package/src/modules/secrets-vault/__tests__/scopes-covered.test.ts +101 -0
  573. package/src/modules/secrets-vault/__tests__/secrets-variable-loader.test.ts +54 -0
  574. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -0
  575. package/src/modules/secrets-vault/db-secrets-vault.service.ts +792 -0
  576. package/src/modules/secrets-vault/index.ts +25 -0
  577. package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +289 -0
  578. package/src/modules/secrets-vault/secrets-variable-loader.ts +104 -0
  579. package/src/modules/secrets-vault/secrets-vault.contract.ts +270 -0
  580. package/src/modules/secrets-vault/secrets-vault.routes.ts +526 -0
  581. package/src/modules/skills/__tests__/skills.service.test.ts +176 -0
  582. package/src/modules/skills/index.ts +10 -0
  583. package/src/modules/skills/skills.contract.ts +54 -0
  584. package/src/modules/skills/skills.routes.ts +38 -0
  585. package/src/modules/skills/skills.service.ts +245 -0
  586. package/src/modules/skills/skills.tools.ts +121 -0
  587. package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +358 -0
  588. package/src/modules/tool-auth/__tests__/internal-token.service.test.ts +79 -0
  589. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +88 -0
  590. package/src/modules/tool-auth/external-api-key.errors.ts +25 -0
  591. package/src/modules/tool-auth/external-api-key.interface.ts +99 -0
  592. package/src/modules/tool-auth/external-api-key.service.ts +209 -0
  593. package/src/modules/tool-auth/internal-token.service.ts +124 -0
  594. package/src/modules/tool-auth/llm-usage-meter.ts +19 -0
  595. package/src/modules/tool-auth/tool-auth.middleware.ts +220 -0
  596. package/src/modules/tool-helpers/__tests__/phase0-loopback-spike.test.ts +129 -0
  597. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +95 -0
  598. package/src/modules/tool-helpers/__tests__/tools-surface.test.ts +146 -0
  599. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +75 -0
  600. package/src/modules/tool-helpers/index.ts +18 -0
  601. package/src/modules/tool-helpers/tool-context.ts +98 -0
  602. package/src/modules/tool-helpers/tool-def.ts +73 -0
  603. package/src/modules/tool-helpers/tool-handler.ts +91 -0
  604. package/src/modules/tool-helpers/tool.contract.ts +85 -0
  605. package/src/modules/tool-helpers/validate-token.ts +50 -0
  606. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +200 -0
  607. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +499 -0
  608. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -0
  609. package/src/modules/tool-manuals/index.ts +12 -0
  610. package/src/modules/tool-manuals/tool-manuals.contract.ts +183 -0
  611. package/src/modules/tool-manuals/tool-manuals.routes.ts +110 -0
  612. package/src/modules/tool-manuals/tool-manuals.service.ts +781 -0
  613. package/src/modules/tool-manuals/tool-manuals.tools.ts +202 -0
  614. package/src/modules/tool-registry/__tests__/tool-registry.test.ts +64 -0
  615. package/src/modules/tool-registry/manual.routes.ts +87 -0
  616. package/src/modules/tool-registry/tool-registry.ts +55 -0
  617. package/src/modules/tool-registry/tool.contract.ts +52 -0
  618. package/src/modules/workflow/__tests__/event-bus.test.ts +322 -0
  619. package/src/modules/workflow/__tests__/file-change-notifier.test.ts +51 -0
  620. package/src/modules/workflow/__tests__/format-affected-owners.test.ts +67 -0
  621. package/src/modules/workflow/__tests__/locking-filesystem.test.ts +445 -0
  622. package/src/modules/workflow/__tests__/pending-commits.canonical.test.ts +36 -0
  623. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +243 -0
  624. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +308 -0
  625. package/src/modules/workflow/__tests__/sanitize-error.test.ts +67 -0
  626. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +62 -0
  627. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +200 -0
  628. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +232 -0
  629. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +380 -0
  630. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +340 -0
  631. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +192 -0
  632. package/src/modules/workflow/agent-tools/workflow.tools.ts +502 -0
  633. package/src/modules/workflow/event-bus.ts +262 -0
  634. package/src/modules/workflow/events.routes.ts +266 -0
  635. package/src/modules/workflow/file-change-notifier.ts +54 -0
  636. package/src/modules/workflow/file-lock.service.ts +269 -0
  637. package/src/modules/workflow/git/__tests__/branch-name.test.ts +86 -0
  638. package/src/modules/workflow/git/__tests__/branchAuthor.test.ts +80 -0
  639. package/src/modules/workflow/git/__tests__/clone-config.test.ts +15 -0
  640. package/src/modules/workflow/git/__tests__/git-test-helpers.ts +47 -0
  641. package/src/modules/workflow/git/__tests__/git-version.failure.test.ts +24 -0
  642. package/src/modules/workflow/git/__tests__/git-version.test.ts +49 -0
  643. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +389 -0
  644. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +124 -0
  645. package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +105 -0
  646. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +211 -0
  647. package/src/modules/workflow/git/__tests__/git.service.createBranch.test.ts +154 -0
  648. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +320 -0
  649. package/src/modules/workflow/git/__tests__/git.service.diffFileAtCommit.test.ts +202 -0
  650. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +453 -0
  651. package/src/modules/workflow/git/__tests__/git.service.list-branches.test.ts +198 -0
  652. package/src/modules/workflow/git/__tests__/git.service.listTrackedFiles.test.ts +125 -0
  653. package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +219 -0
  654. package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +145 -0
  655. package/src/modules/workflow/git/__tests__/git.service.pendingChanges.test.ts +240 -0
  656. package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +272 -0
  657. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +232 -0
  658. package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +101 -0
  659. package/src/modules/workflow/git/__tests__/git.service.status.test.ts +127 -0
  660. package/src/modules/workflow/git/__tests__/mutex.test.ts +78 -0
  661. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +225 -0
  662. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +101 -0
  663. package/src/modules/workflow/git/branch-name.ts +70 -0
  664. package/src/modules/workflow/git/clone-config.ts +71 -0
  665. package/src/modules/workflow/git/git-version.ts +70 -0
  666. package/src/modules/workflow/git/git.service.ts +2248 -0
  667. package/src/modules/workflow/git/mutex.ts +22 -0
  668. package/src/modules/workflow/git/pull-request.service.ts +447 -0
  669. package/src/modules/workflow/locking-filesystem.ts +489 -0
  670. package/src/modules/workflow/pending-commits.service.ts +444 -0
  671. package/src/modules/workflow/pending-commits.worker.ts +361 -0
  672. package/src/modules/workflow/read-only-filesystem.ts +54 -0
  673. package/src/modules/workflow/recovery-bot.ts +73 -0
  674. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +560 -0
  675. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +176 -0
  676. package/src/modules/workflow/review-workflow/__tests__/merge-gate.test.ts +258 -0
  677. package/src/modules/workflow/review-workflow/review-workflow.interface.ts +199 -0
  678. package/src/modules/workflow/review-workflow/review-workflow.service.ts +806 -0
  679. package/src/modules/workflow/sanitize-error.ts +62 -0
  680. package/src/modules/workflow/session-ontology.policy.ts +70 -0
  681. package/src/modules/workflow/session-ontology.service.ts +178 -0
  682. package/src/modules/workflow/workflow-hooks.ts +101 -0
  683. package/src/modules/workflow/workflow.errors.ts +285 -0
  684. package/src/modules/workflow/workflow.routes.ts +858 -0
  685. package/src/modules/workflow/workflow.service.ts +1555 -0
  686. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +213 -0
  687. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +68 -0
  688. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -0
  689. package/src/modules/workspace/__tests__/spill-store.test.ts +43 -0
  690. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +233 -0
  691. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +176 -0
  692. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +303 -0
  693. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +276 -0
  694. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +120 -0
  695. package/src/modules/workspace/__tests__/workspace.service.test.ts +565 -0
  696. package/src/modules/workspace/__tests__/workspace.tools.test.ts +504 -0
  697. package/src/modules/workspace/bevel-ignore.ts +62 -0
  698. package/src/modules/workspace/kb-seed.interface.ts +36 -0
  699. package/src/modules/workspace/kb-seed.service.ts +361 -0
  700. package/src/modules/workspace/routine-write-policy.ts +79 -0
  701. package/src/modules/workspace/session-ontology.gate.ts +189 -0
  702. package/src/modules/workspace/session-sink.ts +25 -0
  703. package/src/modules/workspace/spill-store.ts +87 -0
  704. package/src/modules/workspace/workspace.routes.ts +1114 -0
  705. package/src/modules/workspace/workspace.service.ts +1343 -0
  706. package/src/modules/workspace/workspace.tools.ts +885 -0
  707. package/src/shared/__tests__/frontmatter-id.test.ts +88 -0
  708. package/src/shared/__tests__/fs-walk.test.ts +42 -0
  709. package/src/shared/__tests__/ssrf.test.ts +53 -0
  710. package/src/shared/__tests__/utcp-namespace.test.ts +19 -0
  711. package/src/shared/frontmatter-id.ts +72 -0
  712. package/src/shared/fs-walk.ts +30 -0
  713. package/src/shared/hash-email.ts +14 -0
  714. package/src/shared/kb-layout.test.ts +88 -0
  715. package/src/shared/kb-layout.ts +101 -0
  716. package/src/shared/ssrf.ts +78 -0
  717. package/src/shared/token-crypto.ts +54 -0
  718. package/src/shared/ttl-cache.ts +27 -0
  719. package/src/shared/utcp-namespace.ts +88 -0
  720. package/src/version.ts +19 -0
@@ -0,0 +1,1794 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { execFile, spawn } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { createHash } from 'node:crypto';
6
+
7
+ import type { WorkspaceService } from '../workspace/workspace.service.js';
8
+ import type {
9
+ IAccessControl,
10
+ AccessTargetKind,
11
+ GrantPrincipal,
12
+ GrantSource,
13
+ GrantSources,
14
+ } from './access-control.interface.js';
15
+ import { AccessConfigError } from './access-errors.js';
16
+
17
+ const execFileAsync = promisify(execFile);
18
+
19
+ /**
20
+ * Read many objects from a git repo in ONE `git cat-file --batch` process.
21
+ * `specs` are `<ref>:<path>` lines; the result array is index-aligned with
22
+ * them — the blob's text, or null when the spec is missing/ambiguous at the
23
+ * ref or resolves to a non-blob (e.g. a directory). Output protocol per
24
+ * request: `<oid> <type> <size>\n<size bytes>\n`, or `<spec> missing\n`
25
+ * (the spec may itself contain spaces, hence the endsWith checks).
26
+ */
27
+ function catFileBatch(repoDir: string, specs: string[]): Promise<(string | null)[]> {
28
+ return new Promise((resolve, reject) => {
29
+ const child = spawn('git', ['-C', repoDir, 'cat-file', '--batch'], {
30
+ stdio: ['pipe', 'pipe', 'pipe'],
31
+ });
32
+ const chunks: Buffer[] = [];
33
+ const errChunks: Buffer[] = [];
34
+ child.stdout.on('data', (c: Buffer) => chunks.push(c));
35
+ child.stderr.on('data', (c: Buffer) => errChunks.push(c));
36
+ // A dying git can close stdin mid-write; the 'close' handler below still
37
+ // fires with the exit code, which is the error we want to surface.
38
+ child.stdin.on('error', () => undefined);
39
+ child.on('error', reject);
40
+ child.on('close', (code) => {
41
+ if (code !== 0) {
42
+ reject(new Error(`git cat-file --batch exited ${code}: ${Buffer.concat(errChunks).toString('utf-8').trim()}`));
43
+ return;
44
+ }
45
+ try {
46
+ const out = Buffer.concat(chunks);
47
+ const results: (string | null)[] = [];
48
+ let off = 0;
49
+ for (let i = 0; i < specs.length; i++) {
50
+ const nl = out.indexOf(0x0a, off);
51
+ if (nl < 0) throw new Error('unexpected end of git cat-file output');
52
+ const header = out.subarray(off, nl).toString('utf-8');
53
+ off = nl + 1;
54
+ if (header.endsWith(' missing') || header.endsWith(' ambiguous')) {
55
+ results.push(null);
56
+ continue;
57
+ }
58
+ const parts = header.split(' ');
59
+ const size = Number(parts[2]);
60
+ if (parts.length !== 3 || !Number.isInteger(size) || size < 0) {
61
+ throw new Error(`unexpected git cat-file header: ${header}`);
62
+ }
63
+ // Non-blob (a directory path resolves to a tree) → null, but the
64
+ // payload still has to be skipped to stay aligned.
65
+ results.push(parts[1] === 'blob' ? out.subarray(off, off + size).toString('utf-8') : null);
66
+ off += size + 1; // payload + trailing LF
67
+ }
68
+ resolve(results);
69
+ } catch (err) {
70
+ reject(err);
71
+ }
72
+ });
73
+ child.stdin.write(`${specs.join('\n')}\n`);
74
+ child.stdin.end();
75
+ });
76
+ }
77
+
78
+ /** Mirrors `shared/hash-email.ts`. Duplicated to avoid a cross-cutting import. */
79
+ function sha256Email(email: string): string {
80
+ return createHash('sha256').update(email.trim().toLowerCase()).digest('hex');
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Constants — kept in lockstep with knowledge-base/lib/access-control.js
85
+ // ---------------------------------------------------------------------------
86
+
87
+ export const ADMIN_CANONICAL = 'admin';
88
+ /**
89
+ * Verbs the resolver understands in an `access.md` frontmatter. Each verb
90
+ * is a list of grants (role or `Name <email>` references, optionally
91
+ * prefixed with `deny `). Keep `Verb` and `KNOWN_VERBS` in lockstep —
92
+ * `AccessFile.entries` is statically keyed on this union.
93
+ *
94
+ * `read` controls who may VIEW a path (the file viewer, embed surface, and
95
+ * the agent's read tools). It is **default-deny**: a path with no effective
96
+ * `read:` or `owner:` grant is not readable. To make content public, list the
97
+ * built-in role `everyone` under `read:`.
98
+ *
99
+ * The verbs nest: `owner` is a superset of `read` + `write` + `download`, and
100
+ * `write` is itself a superset of `read` (anyone who can edit can view). An
101
+ * `owner` grant therefore confers all three lower verbs, a `write` grant
102
+ * additionally confers `read`, and `owner` also marks the principal as a
103
+ * contact point for the node (surfaced in the UI so users know who to ask).
104
+ * See `sourceVerbsFor` for how these implications fold into resolution.
105
+ */
106
+ export const KNOWN_VERBS = ['read', 'write', 'download', 'owner'] as const;
107
+ export type Verb = (typeof KNOWN_VERBS)[number];
108
+ const KNOWN_VERBS_SET: ReadonlySet<string> = new Set<string>(KNOWN_VERBS);
109
+ export const EVERYONE_CANONICAL = 'everyone';
110
+ /** Display name for the built-in `everyone` role in the share UI. */
111
+ export const EVERYONE_DISPLAY = 'Everyone';
112
+
113
+ /**
114
+ * Verbs whose entries contribute to resolving `verb`, target verb first.
115
+ * `owner` implies `read`, `write`, and `download`; `write` additionally
116
+ * implies `read`. So resolving `read` folds in `write` and `owner`, resolving
117
+ * `write`/`download` folds in `owner`, and resolving `owner` uses only `owner`.
118
+ *
119
+ * The implication is **grant-only** (see `resolveAtPath`): a superset grant
120
+ * confers the lower verb, but a superset *denial* does not — `deny write` says
121
+ * nothing about `read`, so it never strips a separate read grant. The target
122
+ * verb itself contributes both its grants and its denials.
123
+ */
124
+ export function sourceVerbsFor(verb: Verb): Verb[] {
125
+ switch (verb) {
126
+ case 'owner':
127
+ return ['owner'];
128
+ case 'read':
129
+ return ['read', 'write', 'owner'];
130
+ default:
131
+ return [verb, 'owner'];
132
+ }
133
+ }
134
+ export const RESERVED_ROLE_NAMES = new Set(['deny', EVERYONE_CANONICAL]);
135
+ export const DENY_PREFIX = 'deny ';
136
+
137
+ export const USER_REF_REGEX = /^(.+?)\s+<\s*([^<>\s]+@[^<>\s]+)\s*>\s*$/;
138
+ export const EMAIL_REGEX = /^[^<>\s@]+@[^<>\s@]+\.[^<>\s@]+$/;
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Domain types
142
+ // ---------------------------------------------------------------------------
143
+
144
+ export interface RoleEntry {
145
+ kind: 'role';
146
+ role: string; // canonical (lowercase, single-spaced)
147
+ displayRole: string; // original casing/spacing
148
+ deny: boolean;
149
+ }
150
+
151
+ export interface UserEntry {
152
+ kind: 'user';
153
+ email: string; // canonical (lowercased, trimmed)
154
+ displayName: string;
155
+ deny: boolean;
156
+ }
157
+
158
+ export type ParsedEntry = RoleEntry | UserEntry;
159
+
160
+ interface AccessFile {
161
+ /** repo-relative POSIX path — e.g. `access.md`, `Knowledge/Sales/access.md`. */
162
+ path: string;
163
+ /** repo-relative POSIX directory — `''` for root. */
164
+ dir: string;
165
+ /**
166
+ * Verb → list of grants/denials. Always carries an entry for every
167
+ * known verb (empty array when the verb wasn't declared in the file) so
168
+ * the resolver doesn't need to null-check at every chain step.
169
+ */
170
+ entries: Record<Verb, ParsedEntry[]>;
171
+ }
172
+
173
+ function emptyEntries(): Record<Verb, ParsedEntry[]> {
174
+ const out = {} as Record<Verb, ParsedEntry[]>;
175
+ for (const v of KNOWN_VERBS) out[v] = [];
176
+ return out;
177
+ }
178
+
179
+ export function isAccessMdPath(p: string): boolean {
180
+ return p === 'access.md' || p.endsWith('/access.md');
181
+ }
182
+
183
+ interface RolesIndex {
184
+ byCanonical: Map<string, { displayName: string; emails: Set<string> }>;
185
+ byEmail: Map<string, Set<string>>;
186
+ }
187
+
188
+ interface AccessModel {
189
+ roles: RolesIndex;
190
+ accessFilesByDir: Map<string, AccessFile>;
191
+ }
192
+
193
+ function isBuiltInRole(canonicalRole: string): boolean {
194
+ return canonicalRole === EVERYONE_CANONICAL;
195
+ }
196
+
197
+ function roleKnown(roles: RolesIndex, canonicalRole: string): boolean {
198
+ return roles.byCanonical.has(canonicalRole) || isBuiltInRole(canonicalRole);
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Tiny YAML subset parser — handles only block mappings + block sequences
203
+ // of plain scalars. See `knowledge-base/lib/access-control.js` for the
204
+ // reference implementation (this file is the TypeScript port).
205
+ // ---------------------------------------------------------------------------
206
+
207
+ type YamlValue = string | YamlValue[] | { [key: string]: YamlValue } | null;
208
+
209
+ interface YamlOk {
210
+ ok: true;
211
+ value: YamlValue;
212
+ }
213
+
214
+ interface YamlErr {
215
+ ok: false;
216
+ error: string;
217
+ }
218
+
219
+ function stripComment(line: string): string {
220
+ let inWs = true;
221
+ for (let i = 0; i < line.length; i++) {
222
+ const ch = line[i];
223
+ if (ch === '#' && (inWs || (i > 0 && /\s/.test(line[i - 1])))) {
224
+ return line.slice(0, i);
225
+ }
226
+ if (!/\s/.test(ch)) inWs = false;
227
+ }
228
+ return line;
229
+ }
230
+
231
+ interface Token {
232
+ lineNum: number;
233
+ indent: number;
234
+ kind: 'kv' | 'item';
235
+ key?: string;
236
+ value: string;
237
+ }
238
+
239
+ function tokenise(text: string): YamlErr | { ok: true; tokens: Token[] } {
240
+ const lines = text.split(/\r?\n/);
241
+ const tokens: Token[] = [];
242
+ for (let i = 0; i < lines.length; i++) {
243
+ const stripped = stripComment(lines[i]).replace(/\s+$/, '');
244
+ if (!stripped.trim()) continue;
245
+ const indentMatch = stripped.match(/^( *)/);
246
+ const indent = indentMatch ? indentMatch[1].length : 0;
247
+ const content = stripped.slice(indent);
248
+ const lineNum = i + 1;
249
+
250
+ if (content.startsWith('- ') || content === '-') {
251
+ const value = content === '-' ? '' : content.slice(2).trim();
252
+ tokens.push({ lineNum, indent, kind: 'item', value });
253
+ continue;
254
+ }
255
+ const colonIdx = content.indexOf(':');
256
+ if (colonIdx < 0) {
257
+ return { ok: false, error: `line ${lineNum}: expected 'key:' or '- value' but got '${content}'` };
258
+ }
259
+ const key = content.slice(0, colonIdx).trim();
260
+ const valuePart = content.slice(colonIdx + 1).trim();
261
+ if (!key) return { ok: false, error: `line ${lineNum}: empty mapping key` };
262
+ tokens.push({ lineNum, indent, kind: 'kv', key, value: valuePart });
263
+ }
264
+ return { ok: true, tokens };
265
+ }
266
+
267
+ export function parseYamlSubset(text: string): YamlOk | YamlErr {
268
+ const tok = tokenise(text);
269
+ if (!tok.ok) return tok;
270
+ if (tok.tokens.length === 0) return { ok: true, value: {} };
271
+
272
+ type Frame = { indent: number; container: YamlValue[] | { [key: string]: YamlValue }; kind: 'map' | 'list' };
273
+ const root: { [key: string]: YamlValue } = {};
274
+ const stack: Frame[] = [{ indent: -1, container: root, kind: 'map' }];
275
+
276
+ for (let t = 0; t < tok.tokens.length; t++) {
277
+ const cur = tok.tokens[t];
278
+ while (stack.length > 1 && stack[stack.length - 1].indent >= cur.indent) {
279
+ stack.pop();
280
+ }
281
+ const top = stack[stack.length - 1];
282
+
283
+ if (cur.kind === 'item') {
284
+ if (top.kind !== 'list') {
285
+ return {
286
+ ok: false,
287
+ error: `line ${cur.lineNum}: list item with no enclosing list (indent ${cur.indent})`,
288
+ };
289
+ }
290
+ (top.container as YamlValue[]).push(cur.value);
291
+ continue;
292
+ }
293
+
294
+ if (top.kind !== 'map') {
295
+ return {
296
+ ok: false,
297
+ error: `line ${cur.lineNum}: mapping key '${cur.key}' inside a list — not supported`,
298
+ };
299
+ }
300
+ const map = top.container as { [key: string]: YamlValue };
301
+ const key = cur.key as string;
302
+ if (Object.prototype.hasOwnProperty.call(map, key)) {
303
+ return { ok: false, error: `line ${cur.lineNum}: duplicate key '${key}'` };
304
+ }
305
+
306
+ if (cur.value !== '') {
307
+ // Inline empty collections are the only flow-style YAML we accept, so
308
+ // `owner: []` / `download: []` read as an empty list rather than the
309
+ // scalar string "[]".
310
+ if (cur.value === '[]') {
311
+ map[key] = [];
312
+ continue;
313
+ }
314
+ map[key] = cur.value;
315
+ continue;
316
+ }
317
+
318
+ const next = tok.tokens[t + 1];
319
+ if (!next || next.indent <= cur.indent) {
320
+ map[key] = null;
321
+ continue;
322
+ }
323
+ if (next.kind === 'item') {
324
+ const list: YamlValue[] = [];
325
+ map[key] = list;
326
+ stack.push({ indent: cur.indent, container: list, kind: 'list' });
327
+ } else {
328
+ const sub: { [key: string]: YamlValue } = {};
329
+ map[key] = sub;
330
+ stack.push({ indent: cur.indent, container: sub, kind: 'map' });
331
+ }
332
+ }
333
+
334
+ return { ok: true, value: root };
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // Frontmatter extraction
339
+ // ---------------------------------------------------------------------------
340
+
341
+ export function extractFrontmatter(
342
+ text: string,
343
+ ): { ok: true; frontmatter: string } | { ok: false; error: string } {
344
+ const lines = text.split(/\r?\n/);
345
+ if (lines.length === 0 || lines[0].trim() !== '---') {
346
+ return { ok: false, error: 'expected `---` on the first line' };
347
+ }
348
+ for (let i = 1; i < lines.length; i++) {
349
+ if (lines[i].trim() === '---') {
350
+ return { ok: true, frontmatter: lines.slice(1, i).join('\n') };
351
+ }
352
+ }
353
+ return { ok: false, error: 'unterminated frontmatter — no closing `---` found' };
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Canonicalisation
358
+ // ---------------------------------------------------------------------------
359
+
360
+ export function canonicalRoleName(name: string): string {
361
+ return name.trim().toLowerCase().replace(/\s+/g, ' ');
362
+ }
363
+
364
+ export function canonicalEmail(email: string): string {
365
+ return email.trim().toLowerCase();
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Entry parser
370
+ // ---------------------------------------------------------------------------
371
+
372
+ export function parseAccessEntry(
373
+ raw: unknown,
374
+ ): { ok: true; entry: ParsedEntry } | { ok: false; error: string } {
375
+ if (typeof raw !== 'string') return { ok: false, error: 'entry must be a string' };
376
+ const trimmed = raw.trim();
377
+ if (!trimmed) return { ok: false, error: 'empty entry' };
378
+
379
+ let deny = false;
380
+ let body = trimmed;
381
+ if (trimmed.startsWith(DENY_PREFIX)) {
382
+ deny = true;
383
+ body = trimmed.slice(DENY_PREFIX.length).trim();
384
+ if (!body) return { ok: false, error: `'deny' with no principal` };
385
+ }
386
+
387
+ const userMatch = body.match(USER_REF_REGEX);
388
+ if (userMatch) {
389
+ const displayName = userMatch[1].trim();
390
+ const email = canonicalEmail(userMatch[2]);
391
+ if (!displayName) return { ok: false, error: `user reference '${body}' has no name` };
392
+ if (!EMAIL_REGEX.test(email)) {
393
+ return { ok: false, error: `user reference '${body}' has malformed email '${email}'` };
394
+ }
395
+ return { ok: true, entry: { kind: 'user', email, displayName, deny } };
396
+ }
397
+
398
+ if (body.includes('<') || body.includes('>')) {
399
+ return {
400
+ ok: false,
401
+ error: `entry '${body}' looks like a user reference but doesn't match 'Name <email>' shape`,
402
+ };
403
+ }
404
+
405
+ const role = canonicalRoleName(body);
406
+ if (!role) return { ok: false, error: `empty role name in entry '${raw}'` };
407
+ return { ok: true, entry: { kind: 'role', role, displayRole: body, deny } };
408
+ }
409
+
410
+ // ---------------------------------------------------------------------------
411
+ // roles.yaml + access.md parsers
412
+ // ---------------------------------------------------------------------------
413
+
414
+ export function parseRolesYaml(
415
+ text: string,
416
+ ): { ok: true; index: RolesIndex } | { ok: false; errors: string[] } {
417
+ const parsed = parseYamlSubset(text);
418
+ if (!parsed.ok) return { ok: false, errors: [`roles.yaml: ${parsed.error}`] };
419
+
420
+ const errors: string[] = [];
421
+ const root = parsed.value;
422
+ if (root == null || typeof root !== 'object' || Array.isArray(root)) {
423
+ return { ok: false, errors: [`roles.yaml: must be a top-level mapping`] };
424
+ }
425
+
426
+ const rolesNode = (root as Record<string, YamlValue>).roles;
427
+ if (rolesNode == null || typeof rolesNode !== 'object' || Array.isArray(rolesNode)) {
428
+ return { ok: false, errors: [`roles.yaml: missing top-level 'roles:' mapping`] };
429
+ }
430
+
431
+ const index: RolesIndex = {
432
+ byCanonical: new Map(),
433
+ byEmail: new Map(),
434
+ };
435
+
436
+ for (const [displayName, value] of Object.entries(rolesNode as Record<string, YamlValue>)) {
437
+ const canonical = canonicalRoleName(displayName);
438
+ if (!canonical) {
439
+ errors.push(`roles.yaml: empty role name`);
440
+ continue;
441
+ }
442
+ if (RESERVED_ROLE_NAMES.has(canonical)) {
443
+ errors.push(
444
+ `roles.yaml: role '${displayName}' uses reserved name '${canonical}' — this token has special meaning in access entries and cannot be a roles.yaml role`,
445
+ );
446
+ continue;
447
+ }
448
+ if (index.byCanonical.has(canonical)) {
449
+ const prev = index.byCanonical.get(canonical)!.displayName;
450
+ errors.push(
451
+ `roles.yaml: role '${displayName}' canonicalises to '${canonical}', which is already declared as '${prev}'`,
452
+ );
453
+ continue;
454
+ }
455
+ if (!Array.isArray(value)) {
456
+ errors.push(`roles.yaml: role '${displayName}' must be a list of emails`);
457
+ continue;
458
+ }
459
+ const emails = new Set<string>();
460
+ for (const rawEmail of value) {
461
+ if (typeof rawEmail !== 'string') {
462
+ errors.push(`roles.yaml: role '${displayName}' has a non-string entry`);
463
+ continue;
464
+ }
465
+ const email = canonicalEmail(rawEmail);
466
+ if (!EMAIL_REGEX.test(email)) {
467
+ errors.push(`roles.yaml: role '${displayName}' has malformed email '${rawEmail}'`);
468
+ continue;
469
+ }
470
+ emails.add(email);
471
+ let set = index.byEmail.get(email);
472
+ if (!set) {
473
+ set = new Set();
474
+ index.byEmail.set(email, set);
475
+ }
476
+ set.add(canonical);
477
+ }
478
+ index.byCanonical.set(canonical, { displayName: displayName.trim(), emails });
479
+ }
480
+
481
+ if (!index.byCanonical.has(ADMIN_CANONICAL)) {
482
+ errors.push(`roles.yaml: must declare at least one 'Admin' role`);
483
+ } else if (index.byCanonical.get(ADMIN_CANONICAL)!.emails.size === 0) {
484
+ errors.push(`roles.yaml: 'Admin' role has no emails`);
485
+ }
486
+
487
+ if (errors.length) return { ok: false, errors };
488
+ return { ok: true, index };
489
+ }
490
+
491
+ function parseAccessFile(
492
+ text: string,
493
+ relativePath: string,
494
+ ): { ok: true; file: AccessFile; warnings: string[] } | { ok: false; errors: string[] } {
495
+ const fm = extractFrontmatter(text);
496
+ if (!fm.ok) return { ok: false, errors: [`${relativePath}: ${fm.error}`] };
497
+ const parsed = parseYamlSubset(fm.frontmatter);
498
+ if (!parsed.ok) return { ok: false, errors: [`${relativePath}: ${parsed.error}`] };
499
+
500
+ const root = parsed.value;
501
+ if (root == null || typeof root !== 'object' || Array.isArray(root)) {
502
+ return { ok: false, errors: [`${relativePath}: frontmatter must be a mapping`] };
503
+ }
504
+
505
+ const errors: string[] = [];
506
+ const warnings: string[] = [];
507
+ const entries = emptyEntries();
508
+
509
+ for (const [key, value] of Object.entries(root as Record<string, YamlValue>)) {
510
+ if (!KNOWN_VERBS_SET.has(key)) {
511
+ // Forgiving by design: unknown keys (typos, future verbs, custom
512
+ // metadata an operator chose to colocate) must not break the access
513
+ // tree. Warn so operators see the typo in logs, then move on as if
514
+ // the key didn't exist. The verbs we DO understand still parse.
515
+ warnings.push(
516
+ `${relativePath}: unknown access key '${key}' — ignored (known: ${[...KNOWN_VERBS].join(', ')})`,
517
+ );
518
+ continue;
519
+ }
520
+ const verb = key as Verb;
521
+ if (!Array.isArray(value)) {
522
+ errors.push(`${relativePath}: '${key}:' must be a list`);
523
+ continue;
524
+ }
525
+ const list: ParsedEntry[] = [];
526
+ for (const raw of value) {
527
+ const result = parseAccessEntry(raw);
528
+ if (!result.ok) {
529
+ errors.push(`${relativePath}: ${result.error}`);
530
+ continue;
531
+ }
532
+ list.push(result.entry);
533
+ }
534
+ entries[verb] = list;
535
+ }
536
+
537
+ if (errors.length) return { ok: false, errors };
538
+
539
+ const slash = relativePath.lastIndexOf('/');
540
+ const dir = slash === -1 ? '' : relativePath.slice(0, slash);
541
+ return { ok: true, file: { path: relativePath, dir, entries }, warnings };
542
+ }
543
+
544
+ // ---------------------------------------------------------------------------
545
+ // Resolver — walks repo root → file dir, accumulating per-principal state.
546
+ // ---------------------------------------------------------------------------
547
+
548
+ type GrantState = 'grant' | 'denied';
549
+
550
+ /**
551
+ * Per-verb access entries declared in a single node file's *own* frontmatter
552
+ * (the same YAML block that carries `nodeType:`). This is the most-specific
553
+ * scope — applied after every directory `access.md` in the chain.
554
+ */
555
+ type OwnEntries = Record<Verb, ParsedEntry[]>;
556
+
557
+ /**
558
+ * Parse the access verbs a node file declares in its own YAML frontmatter.
559
+ * Returns the per-verb entry lists, or null when the file has no frontmatter
560
+ * or declares no access verb at all.
561
+ *
562
+ * Forgiving by design — a node's frontmatter legitimately carries non-access
563
+ * keys (notably `nodeType:`), so unknown keys are ignored, and a malformed
564
+ * entry is dropped rather than failing the file. A typo in a node's `owner:`
565
+ * must never make the node unreadable; it just doesn't grant anything.
566
+ */
567
+ export function parseOwnAccessEntries(text: string): OwnEntries | null {
568
+ const fm = extractFrontmatter(text);
569
+ if (!fm.ok) return null;
570
+ const parsed = parseYamlSubset(fm.frontmatter);
571
+ if (!parsed.ok) return null;
572
+ const root = parsed.value;
573
+ if (root == null || typeof root !== 'object' || Array.isArray(root)) return null;
574
+
575
+ const entries = emptyEntries();
576
+ let sawVerb = false;
577
+ for (const [key, value] of Object.entries(root as Record<string, YamlValue>)) {
578
+ if (!KNOWN_VERBS_SET.has(key)) continue; // ignore nodeType, etc.
579
+ // Accept both the list form (`owner:\n - A\n - B`) and the convenience
580
+ // single-value scalar form (`owner: Test <test@test.com>`) — the latter
581
+ // is the natural way to name one owner in a node's own frontmatter.
582
+ // Anything else (a mapping, null) is malformed → skip forgivingly.
583
+ let raws: YamlValue[];
584
+ if (Array.isArray(value)) raws = value;
585
+ else if (typeof value === 'string' && value.trim()) raws = [value];
586
+ else continue;
587
+ const verb = key as Verb;
588
+ const list: ParsedEntry[] = [];
589
+ for (const raw of raws) {
590
+ const result = parseAccessEntry(raw);
591
+ if (result.ok) list.push(result.entry);
592
+ }
593
+ entries[verb] = list;
594
+ sawVerb = true;
595
+ }
596
+ return sawVerb ? entries : null;
597
+ }
598
+
599
+ /**
600
+ * Where a single access scope's rules live. `'own'` is the node's own
601
+ * frontmatter (the most-specific scope, only present for a file target);
602
+ * otherwise it's the repo-relative `access.md` path that governs the scope
603
+ * (e.g. `access.md`, `Knowledge/Sales/access.md`). `grantSources` reads this
604
+ * to tell the dialog WHERE a principal's access comes from. */
605
+ export type ScopeSource = { kind: 'own' } | { kind: 'access-md'; path: string };
606
+
607
+ /**
608
+ * The grant/deny state a single access scope (one `access.md` or a node's own
609
+ * frontmatter) declares for `verb`, keyed by principal. Superset grants are
610
+ * already folded in (grant-only — see `buildScope`), so `byRole`/`byEmail`
611
+ * hold the *effective* verdict each named principal gets at this one scope.
612
+ *
613
+ * `source` identifies the file the scope's rules come from (see `ScopeSource`),
614
+ * so a caller can map a per-scope verdict back to the editable file.
615
+ */
616
+ interface AccessScope {
617
+ byRole: Map<string, GrantState>;
618
+ byEmail: Map<string, GrantState>;
619
+ source: ScopeSource;
620
+ }
621
+
622
+ /**
623
+ * Resolve the per-scope verdicts for `verb` at `relativePath`, ordered
624
+ * **closest-to-the-file first**: the node's own frontmatter, then its
625
+ * directory's `access.md`, then each parent up to the repo root.
626
+ *
627
+ * Permission resolution honours closeness *before* tier: the first scope that
628
+ * yields any verdict for a principal wins, and only ties *within* that scope
629
+ * fall back to the email > role > everyone ordering (see
630
+ * `hasPermissionResolved`). `collapseScopes` flattens this into the
631
+ * closest-wins-per-principal view the display helpers use.
632
+ */
633
+ function resolveScopes(
634
+ model: AccessModel,
635
+ verb: Verb,
636
+ relativePath: string,
637
+ fileOwn?: OwnEntries | null,
638
+ ): AccessScope[] {
639
+ const target = verb;
640
+ const supersets = sourceVerbsFor(verb).filter((v) => v !== verb);
641
+
642
+ // Build one scope's effective verdicts. The target verb contributes both
643
+ // grants and denials; superset verbs contribute grants only (a `deny write`
644
+ // never strips `read`). Within the scope a grant always wins over a deny of
645
+ // the same principal, so a same-file superset grant overrides a target deny
646
+ // (e.g. `owner:` beats `deny write`, `write:` beats `deny read`).
647
+ const buildScope = (
648
+ entries: Record<Verb, ParsedEntry[]>,
649
+ filterRoles: boolean,
650
+ source: ScopeSource,
651
+ ): AccessScope => {
652
+ const byRole = new Map<string, GrantState>();
653
+ const byEmail = new Map<string, GrantState>();
654
+ const set = (entry: ParsedEntry, state: GrantState) => {
655
+ const map = entry.kind === 'role' ? byRole : byEmail;
656
+ const key = entry.kind === 'role' ? entry.role : entry.email;
657
+ if (map.get(key) === 'grant') return; // a grant in this scope sticks
658
+ map.set(key, state);
659
+ };
660
+ for (const entry of entries[target]) {
661
+ if (filterRoles && entry.kind === 'role' && !roleKnown(model.roles, entry.role)) continue;
662
+ set(entry, entry.deny ? 'denied' : 'grant');
663
+ }
664
+ for (const src of supersets) {
665
+ for (const entry of entries[src]) {
666
+ if (entry.deny) continue; // grant-only fold
667
+ if (filterRoles && entry.kind === 'role' && !roleKnown(model.roles, entry.role)) continue;
668
+ set(entry, 'grant');
669
+ }
670
+ }
671
+ return { byRole, byEmail, source };
672
+ };
673
+
674
+ const scopes: AccessScope[] = [];
675
+ // The node's own frontmatter is the most specific scope. Role refs there are
676
+ // not pre-filtered (the dir chain is, in loadModel), so drop unknown roles.
677
+ if (fileOwn) scopes.push(buildScope(fileOwn, true, { kind: 'own' }));
678
+ const chain = dirChainFor(relativePath);
679
+ for (let i = chain.length - 1; i >= 0; i--) {
680
+ const file = model.accessFilesByDir.get(chain[i]);
681
+ if (file) scopes.push(buildScope(file.entries, false, { kind: 'access-md', path: file.path }));
682
+ }
683
+ return scopes;
684
+ }
685
+
686
+ /** The collapsed closest-wins view: per-principal verdicts with no single
687
+ * source (it's a flattening across scopes). */
688
+ type CollapsedScope = Pick<AccessScope, 'byRole' | 'byEmail'>;
689
+
690
+ /**
691
+ * Flatten ordered scopes (closest→farthest) into a single closest-wins
692
+ * per-principal view. Used by the display / eligibility helpers, which only
693
+ * need "what's this principal's effective verdict?" — not the scope-by-scope
694
+ * precedence the permission decision applies.
695
+ */
696
+ function collapseScopes(scopes: AccessScope[]): CollapsedScope {
697
+ const byRole = new Map<string, GrantState>();
698
+ const byEmail = new Map<string, GrantState>();
699
+ for (let i = scopes.length - 1; i >= 0; i--) {
700
+ for (const [k, v] of scopes[i].byRole) byRole.set(k, v);
701
+ for (const [k, v] of scopes[i].byEmail) byEmail.set(k, v);
702
+ }
703
+ return { byRole, byEmail };
704
+ }
705
+
706
+ function resolveAtPath(
707
+ model: AccessModel,
708
+ verb: Verb,
709
+ relativePath: string,
710
+ fileOwn?: OwnEntries | null,
711
+ ): CollapsedScope {
712
+ return collapseScopes(resolveScopes(model, verb, relativePath, fileOwn));
713
+ }
714
+
715
+ function isAdminEmail(model: AccessModel, email: string): boolean {
716
+ const roles = model.roles.byEmail.get(email);
717
+ return !!roles && roles.has(ADMIN_CANONICAL);
718
+ }
719
+
720
+ /**
721
+ * Resolve whether `userEmail` has `verb` on `relativePath`.
722
+ *
723
+ * Precedence is **closeness first, tier second**: scopes are walked from the
724
+ * node's own frontmatter outward to the repo root, and the first scope that
725
+ * yields any verdict for the caller decides. Only *within* a single scope do
726
+ * the tiers break the tie, most-specific first: a direct email entry beats a
727
+ * role entry, which beats the built-in `everyone` role. When two of the
728
+ * caller's roles conflict at the same scope, grant wins (a deny on one role
729
+ * does not undo a grant via another). A closer scope's `everyone` grant
730
+ * therefore overrides a farther scope's email deny, and vice versa.
731
+ * Default-deny: no verdict at any scope → no access.
732
+ *
733
+ * Two hardcoded overrides for `write`, applied before scope resolution:
734
+ * - `roles.yaml` is Admin-only, regardless of any access.md content.
735
+ * Hard rule — the file that decides who's an admin can't be edited by
736
+ * a non-admin without creating a privilege-escalation loop.
737
+ * - Any `access.md` file is always writable by admins, even if the file
738
+ * itself excludes them or fails to parse cleanly. These are the rescue
739
+ * mechanism for the rest of the tree — without this, a typo in
740
+ * `access.md` could permanently lock admins out of fixing it.
741
+ *
742
+ * No special-cases for `read`/`download` — they fall through to scope resolution.
743
+ */
744
+ function hasPermissionResolved(
745
+ model: AccessModel,
746
+ verb: Verb,
747
+ userEmail: string,
748
+ relativePath: string,
749
+ fileOwn?: OwnEntries | null,
750
+ ): boolean {
751
+ const email = canonicalEmail(userEmail);
752
+
753
+ if (verb === 'write') {
754
+ if (relativePath === 'roles.yaml') return isAdminEmail(model, email);
755
+ if (isAccessMdPath(relativePath) && isAdminEmail(model, email)) return true;
756
+ }
757
+
758
+ const userRoles = model.roles.byEmail.get(email);
759
+ const scopes = resolveScopes(model, verb, relativePath, fileOwn);
760
+
761
+ for (const scope of scopes) {
762
+ // Tier 1 — direct email entry is the most specific verdict at this scope.
763
+ const direct = scope.byEmail.get(email);
764
+ if (direct) return direct === 'grant';
765
+
766
+ // Tier 2 — the caller's roles. A grant via any one of them wins over a deny
767
+ // via another at this same scope (a `deny Engineer` doesn't undo an
768
+ // unrelated `Admin` grant).
769
+ if (userRoles && userRoles.size) {
770
+ let grant = false;
771
+ let deny = false;
772
+ for (const r of userRoles) {
773
+ const s = scope.byRole.get(r);
774
+ if (s === 'denied') deny = true;
775
+ else if (s === 'grant') grant = true;
776
+ }
777
+ if (grant) return true;
778
+ if (deny) return false;
779
+ }
780
+
781
+ // Tier 3 — the built-in `everyone` role.
782
+ const everyone = scope.byRole.get(EVERYONE_CANONICAL);
783
+ if (everyone) return everyone === 'grant';
784
+
785
+ // No verdict at this scope — fall through to the next (farther) one.
786
+ }
787
+
788
+ return false;
789
+ }
790
+
791
+ /**
792
+ * The `access.md` that a FOLDER target's own grants live in — its direct scope.
793
+ * Root (`''`) → `access.md`; otherwise `<dir>/access.md`. (Mirrors
794
+ * `accessMdPathForFolder` in the mutation service; duplicated here to keep the
795
+ * resolver free of a write-side import.)
796
+ */
797
+ function ownAccessMdForFolder(repoRelDir: string): string {
798
+ return repoRelDir ? `${repoRelDir}/access.md` : 'access.md';
799
+ }
800
+
801
+ /**
802
+ * Map the scope that granted a principal to a `GrantSource`, given the target.
803
+ * The scope's own source (`own` frontmatter vs an `access.md` path) plus the
804
+ * target kind decides direct-vs-ancestor:
805
+ * - file target: own-frontmatter scope → direct; any `access.md` → ancestor.
806
+ * - folder target: the folder's OWN `access.md` → direct; any other → ancestor.
807
+ */
808
+ function scopeToGrantSource(
809
+ scope: AccessScope,
810
+ kind: AccessTargetKind,
811
+ relativePath: string,
812
+ ): GrantSource {
813
+ if (scope.source.kind === 'own') return { kind: 'direct' };
814
+ const path = scope.source.path;
815
+ const ownPath = kind === 'folder' ? ownAccessMdForFolder(relativePath) : null;
816
+ if (ownPath !== null && path === ownPath) return { kind: 'direct' };
817
+ return { kind: 'ancestor', path };
818
+ }
819
+
820
+ /**
821
+ * Resolve EVERY file scope that names a principal for `verb`, ordered
822
+ * closest-first — the source-returning twin of `hasPermissionResolved`, but
823
+ * returning the WHOLE list of removable entries rather than just the winner.
824
+ *
825
+ * Only the principal's OWN named entry yields a source — a `user` by their email,
826
+ * a `role` by its role token. A grant that reaches the user via a group they
827
+ * belong to, the built-in `everyone`, or admin-rescue is NOT their entry, so it
828
+ * never adds a source (the group/role shows as its own row instead). The list is:
829
+ * - `[]` (verb omitted by the caller) when the principal effectively holds no
830
+ * `verb` via a named entry: they're not named, OR their CLOSEST own-email
831
+ * verdict is a `deny` (cut off — any farther grant is dead), OR they only
832
+ * resolve via a group/everyone/rescue.
833
+ * - otherwise `[closest, …farther]` — each scope where the principal's own
834
+ * entry GRANTS the verb, closest-first, up to (but not including) a closer
835
+ * own-email `deny`. `[0]` is the effective source.
836
+ *
837
+ * Why all of them, not just `[0]`: the dialog must tell "granted here" apart from
838
+ * "granted here AND also inherited from a parent" (both collapse to `direct`
839
+ * under closest-wins), and the revoke flow needs the inherited remainder that
840
+ * survives removing the direct entry. A group/everyone grant at some scope does
841
+ * not add a source AND does not hide a farther own-entry the principal is named
842
+ * in (removing it is still meaningful if the group grant is later removed).
843
+ */
844
+ function resolveGrantSourcesForVerb(
845
+ model: AccessModel,
846
+ verb: Verb,
847
+ kind: AccessTargetKind,
848
+ relativePath: string,
849
+ principal: GrantPrincipal,
850
+ fileOwn?: OwnEntries | null,
851
+ ): GrantSource[] {
852
+ const scopes = resolveScopes(model, verb, relativePath, fileOwn);
853
+ const out: GrantSource[] = [];
854
+
855
+ if (principal.kind === 'role') {
856
+ const role = canonicalRoleName(principal.role);
857
+ for (const scope of scopes) {
858
+ const s = scope.byRole.get(role);
859
+ if (s === 'denied') break; // a closer deny of this role cuts off farther grants
860
+ if (s === 'grant') out.push(scopeToGrantSource(scope, kind, relativePath));
861
+ }
862
+ return out;
863
+ }
864
+
865
+ const email = canonicalEmail(principal.email);
866
+ for (const scope of scopes) {
867
+ // The principal's OWN email entry at this scope is the only thing that yields
868
+ // a removable source. A grant adds it; a deny cuts off everything farther
869
+ // (closeness-first: a closer deny shadows farther grants).
870
+ const direct = scope.byEmail.get(email);
871
+ if (direct === 'denied') break;
872
+ if (direct === 'grant') out.push(scopeToGrantSource(scope, kind, relativePath));
873
+ // A group/everyone grant or deny at this scope is NOT this user's own entry:
874
+ // it neither adds a source nor hides a farther own-entry, so we keep walking.
875
+ }
876
+ return out;
877
+ }
878
+
879
+ /**
880
+ * Build the repo-root → path chain of directory scopes that govern
881
+ * `relativePath`, e.g. `Knowledge/Sales/Foo.md` →
882
+ * `['', 'Knowledge', 'Knowledge/Sales', 'Knowledge/Sales/Foo.md']`.
883
+ *
884
+ * Every segment is included, INCLUDING the leaf. For a file leaf the leaf
885
+ * entry simply never matches a directory key in `accessFilesByDir` (those are
886
+ * keyed by the access.md's parent dir), so it's a harmless no-op. For a
887
+ * DIRECTORY leaf (e.g. resolving `canRead('Knowledge/Secret')` for a folder),
888
+ * including it means `Knowledge/Secret/access.md`'s own rules apply to the
889
+ * folder itself — without this, a folder's own read grant would not apply to
890
+ * the directory node that names it.
891
+ */
892
+ function dirChainFor(relativePath: string): string[] {
893
+ const chain: string[] = [''];
894
+ let acc = '';
895
+ for (const p of relativePath.split('/')) {
896
+ if (!p) continue;
897
+ acc = acc ? `${acc}/${p}` : p;
898
+ chain.push(acc);
899
+ }
900
+ return chain;
901
+ }
902
+
903
+ /**
904
+ * Whether this path is readable by every signed-in user via the built-in
905
+ * `everyone` role. Used only for display semantics: when this is true
906
+ * the UI can say "everyone can see this" instead of listing a meaningless
907
+ * role set. Any effective denial that carves someone out keeps the node
908
+ * reported as restricted.
909
+ */
910
+ function canEveryoneReadResolved(
911
+ model: AccessModel,
912
+ relativePath: string,
913
+ fileOwn?: OwnEntries | null,
914
+ ): boolean {
915
+ const { byRole, byEmail } = resolveAtPath(model, 'read', relativePath, fileOwn);
916
+ // Baseline: an unnamed signed-in user (no email/role entries) reads only via
917
+ // `everyone`. As a single principal, its collapsed verdict is its closest —
918
+ // exactly what that user resolves to.
919
+ if (byRole.get(EVERYONE_CANONICAL) !== 'grant') return false;
920
+ // Every *named* principal must also still read. A collapsed deny may have
921
+ // been shadowed by a closer-scope `everyone` grant, so re-resolve each
922
+ // candidate through the closeness-first gate rather than trusting the
923
+ // collapsed deny state. (Role members live in roles.yaml; inline-named users
924
+ // come from `byEmail`.)
925
+ const candidates = new Set<string>();
926
+ for (const email of model.roles.byEmail.keys()) candidates.add(email);
927
+ for (const email of byEmail.keys()) candidates.add(email);
928
+ for (const email of candidates) {
929
+ if (!hasPermissionResolved(model, 'read', email, relativePath, fileOwn)) return false;
930
+ }
931
+ return true;
932
+ }
933
+
934
+ /**
935
+ * Resolve whether `userEmail` may READ `relativePath`.
936
+ *
937
+ * Read is default-deny: a path is readable only when resolution grants the
938
+ * caller `read` directly, via one of their roles, via the built-in `everyone`
939
+ * role, or via `owner:` (owners implicitly read).
940
+ *
941
+ * No admin rescue (mirrors `download`): admins read a node only if an
942
+ * `access.md` lists them — directly, via a role, through `everyone`, or as an
943
+ * owner.
944
+ */
945
+ function canReadResolved(
946
+ model: AccessModel,
947
+ userEmail: string,
948
+ relativePath: string,
949
+ fileOwn?: OwnEntries | null,
950
+ ): boolean {
951
+ return hasPermissionResolved(model, 'read', userEmail, relativePath, fileOwn);
952
+ }
953
+
954
+ function eligibleHoldersResolved(
955
+ model: AccessModel,
956
+ verb: Verb,
957
+ relativePath: string,
958
+ fileOwn?: OwnEntries | null,
959
+ ): { roles: string[]; users: { name: string; email: string }[] } {
960
+ const { byRole, byEmail } = resolveAtPath(model, verb, relativePath, fileOwn);
961
+
962
+ const roleSet = new Set<string>();
963
+ for (const [canonical, state] of byRole) {
964
+ if (state !== 'grant') continue;
965
+ const role = model.roles.byCanonical.get(canonical);
966
+ roleSet.add(role ? role.displayName : canonical);
967
+ }
968
+
969
+ // Mirror the admin overrides applied in `hasPermissionResolved`: write on
970
+ // `roles.yaml` and on any `access.md` is granted to Admin even if the
971
+ // file content doesn't list it. Without surfacing that here, the
972
+ // "restricted to …" banner under-reports who can actually fix a bad
973
+ // access.md (it'd say "restricted to no one" when Admin is the answer).
974
+ if (
975
+ verb === 'write' &&
976
+ (relativePath === 'roles.yaml' || isAccessMdPath(relativePath))
977
+ ) {
978
+ const adminRole = model.roles.byCanonical.get(ADMIN_CANONICAL);
979
+ roleSet.add(adminRole ? adminRole.displayName : ADMIN_CANONICAL);
980
+ }
981
+
982
+ const roles = [...roleSet].sort();
983
+
984
+ const users: { name: string; email: string }[] = [];
985
+ for (const [email, state] of byEmail) {
986
+ if (state !== 'grant') continue;
987
+ users.push({ name: '', email });
988
+ }
989
+ users.sort((a, b) => a.email.localeCompare(b.email));
990
+
991
+ return { roles, users };
992
+ }
993
+
994
+ /**
995
+ * Expand the eligible-writer set to the underlying emails.
996
+ *
997
+ * Algorithm: for every email in roles.yaml, ask `canWriteResolved` whether
998
+ * that email can write this path. That walks both the role-level and
999
+ * email-level state with the correct precedence (user-level entries trump
1000
+ * role-level), so we don't have to reimplement the resolution logic — we
1001
+ * just enumerate candidates and let the resolver answer each one.
1002
+ *
1003
+ * Direct user grants (emails named inline with no `roles.yaml` entry) are
1004
+ * picked up from `byEmail` separately.
1005
+ */
1006
+ function eligibleHolderEmailsResolved(
1007
+ model: AccessModel,
1008
+ verb: Verb,
1009
+ relativePath: string,
1010
+ fileOwn?: OwnEntries | null,
1011
+ ): Map<string, { name: string; email: string }> {
1012
+ const { byEmail } = resolveAtPath(model, verb, relativePath, fileOwn);
1013
+ const out = new Map<string, { name: string; email: string }>();
1014
+
1015
+ // Candidate emails: all emails in roles.yaml, plus everyone named directly
1016
+ // in an access.md entry at this path's scope (whether granted or denied —
1017
+ // `hasPermissionResolved` filters denials out below). The built-in
1018
+ // `everyone` role can grant arbitrary signed-in users, but this finite
1019
+ // expansion can only return emails the access tree explicitly names.
1020
+ const candidates = new Set<string>();
1021
+ for (const email of model.roles.byEmail.keys()) candidates.add(email);
1022
+ for (const email of byEmail.keys()) candidates.add(email);
1023
+
1024
+ for (const email of candidates) {
1025
+ if (hasPermissionResolved(model, verb, email, relativePath, fileOwn)) {
1026
+ out.set(email, { name: '', email });
1027
+ }
1028
+ }
1029
+ return out;
1030
+ }
1031
+
1032
+ /**
1033
+ * The set of explicitly-named individuals (roles.yaml members + emails named
1034
+ * inline at this path's scope) who do NOT hold `verb` here. This is the
1035
+ * general exclusion set used by the merge gate to subtract from a blanket
1036
+ * `everyone` grant: it catches denials at *any* tier — a `deny email`, a
1037
+ * `deny role` covering one of the user's roles, or a `deny everyone` carve-out
1038
+ * — not just direct user denials, because each candidate is run through full
1039
+ * scope resolution. (The complement of `eligibleHolderEmailsResolved` over the
1040
+ * same candidate set.)
1041
+ */
1042
+ function ineligibleNamedEmailsResolved(
1043
+ model: AccessModel,
1044
+ verb: Verb,
1045
+ relativePath: string,
1046
+ fileOwn?: OwnEntries | null,
1047
+ ): Set<string> {
1048
+ const { byEmail } = resolveAtPath(model, verb, relativePath, fileOwn);
1049
+ const candidates = new Set<string>();
1050
+ for (const email of model.roles.byEmail.keys()) candidates.add(email);
1051
+ for (const email of byEmail.keys()) candidates.add(email);
1052
+
1053
+ const out = new Set<string>();
1054
+ for (const email of candidates) {
1055
+ if (!hasPermissionResolved(model, verb, email, relativePath, fileOwn)) out.add(email);
1056
+ }
1057
+ return out;
1058
+ }
1059
+
1060
+ // ---------------------------------------------------------------------------
1061
+ // Service
1062
+ // ---------------------------------------------------------------------------
1063
+
1064
+ export class AccessControlService implements IAccessControl {
1065
+ /**
1066
+ * Per-workspace cache: `model` is the resolved access tree and `loadedAt`
1067
+ * is when we computed it. We re-load on a TTL — the validator runs on
1068
+ * every commit so stale state surfaces quickly anyway, and the read cost
1069
+ * is small (a few small files).
1070
+ */
1071
+ private readonly cache = new Map<string, { model: AccessModel; loadedAt: number }>();
1072
+ private static readonly CACHE_TTL_MS = 5_000;
1073
+
1074
+ /**
1075
+ * Per-workspace memo of node frontmatter access entries (keyed by
1076
+ * repo-relative path), for the BATCH checks — the explorer tree resolves
1077
+ * every KB file on each load, which would otherwise cost one `fs.readFile`
1078
+ * per node per tree build. Dropped by `invalidate()` — which fires on every
1079
+ * commit / pull / branch switch, i.e. on every path a frontmatter edit can
1080
+ * land through — so that is the PRIMARY freshness mechanism; the TTL is
1081
+ * only a long backstop against a missed invalidation. It is deliberately
1082
+ * NOT the model cache's 5s TTL: at 5s every tree build was effectively
1083
+ * cold (~one full-KB read sweep per build). Single-file gates (`canRead` /
1084
+ * `canWrite`) stay uncached — the write gate reads disk-fresh on purpose.
1085
+ */
1086
+ private readonly ownEntriesCache = new Map<
1087
+ string,
1088
+ { loadedAt: number; byPath: Map<string, OwnEntries | null> }
1089
+ >();
1090
+ private static readonly OWN_ENTRIES_TTL_MS = 5 * 60_000;
1091
+
1092
+ constructor(
1093
+ private readonly workspaceService: WorkspaceService,
1094
+ private readonly kbDirName: string,
1095
+ ) {}
1096
+
1097
+ /**
1098
+ * Drop a workspace's cached model + frontmatter memo. Call after operations
1099
+ * that mutate the working tree — commit, push, pull, branch switch.
1100
+ */
1101
+ invalidate(workspaceId: string): void {
1102
+ this.cache.delete(workspaceId);
1103
+ this.ownEntriesCache.delete(workspaceId);
1104
+ }
1105
+
1106
+ /** Memoized `readOwnEntries` for the batch paths; see `ownEntriesCache`. */
1107
+ private async cachedOwnEntries(
1108
+ workspaceId: string,
1109
+ repoDir: string,
1110
+ relativePath: string,
1111
+ ): Promise<OwnEntries | null> {
1112
+ let ws = this.ownEntriesCache.get(workspaceId);
1113
+ if (!ws || Date.now() - ws.loadedAt > AccessControlService.OWN_ENTRIES_TTL_MS) {
1114
+ ws = { loadedAt: Date.now(), byPath: new Map() };
1115
+ this.ownEntriesCache.set(workspaceId, ws);
1116
+ }
1117
+ const hit = ws.byPath.get(relativePath);
1118
+ if (hit !== undefined || ws.byPath.has(relativePath)) return hit ?? null;
1119
+ const own = await this.readOwnEntries(repoDir, relativePath);
1120
+ ws.byPath.set(relativePath, own);
1121
+ return own;
1122
+ }
1123
+
1124
+ /**
1125
+ * Validate a candidate `roles.yaml` against the resolver's OWN parser without
1126
+ * writing it. See `IAccessControl.validateRolesYaml` — this is the gate that
1127
+ * makes a malformed-`roles.yaml` admin lockout structurally impossible: the
1128
+ * roles-admin service runs it on every candidate before committing, and
1129
+ * because it IS `parseRolesYaml`, any text that passes here is loadable by
1130
+ * `loadModel`.
1131
+ */
1132
+ validateRolesYaml(text: string): { ok: true } | { ok: false; errors: string[] } {
1133
+ const parsed = parseRolesYaml(text);
1134
+ return parsed.ok ? { ok: true } : { ok: false, errors: parsed.errors };
1135
+ }
1136
+
1137
+ /**
1138
+ * Advisory scan of folder `access.md` files for references to a role (by
1139
+ * canonical name). See `IAccessControl.referencesToRole` — undercounts node
1140
+ * frontmatter by design; powers the delete warning only, never the rename
1141
+ * gate.
1142
+ */
1143
+ async referencesToRole(
1144
+ workspaceId: string,
1145
+ canonicalRole: string,
1146
+ ): Promise<{ path: string; verb: string }[]> {
1147
+ const model = await this.loadModel(workspaceId);
1148
+ const out: { path: string; verb: string }[] = [];
1149
+ for (const file of model.accessFilesByDir.values()) {
1150
+ for (const verb of KNOWN_VERBS) {
1151
+ for (const entry of file.entries[verb]) {
1152
+ if (entry.kind === 'role' && entry.role === canonicalRole) {
1153
+ out.push({ path: file.path, verb });
1154
+ }
1155
+ }
1156
+ }
1157
+ }
1158
+ return out;
1159
+ }
1160
+
1161
+ async canWrite(
1162
+ workspaceId: string,
1163
+ userEmail: string,
1164
+ relativePath: string,
1165
+ ): Promise<boolean> {
1166
+ const model = await this.loadModel(workspaceId);
1167
+ const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
1168
+ return hasPermissionResolved(model, 'write', userEmail, relativePath, own);
1169
+ }
1170
+
1171
+ async canRead(
1172
+ workspaceId: string,
1173
+ userEmail: string,
1174
+ relativePath: string,
1175
+ ): Promise<boolean> {
1176
+ const model = await this.loadModel(workspaceId);
1177
+ const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
1178
+ return canReadResolved(model, userEmail, relativePath, own);
1179
+ }
1180
+
1181
+ async canReadBatch(
1182
+ workspaceId: string,
1183
+ userEmail: string,
1184
+ relativePaths: string[],
1185
+ ): Promise<Map<string, boolean>> {
1186
+ const model = await this.loadModel(workspaceId);
1187
+ const repoDir = await this.repoDir(workspaceId);
1188
+ // Read each path's own-entries in parallel rather than serially (the batch
1189
+ // path otherwise bottlenecks on per-file disk latency), memoized per
1190
+ // workspace so repeat tree builds skip the disk entirely.
1191
+ const owns = await Promise.all(relativePaths.map((p) => this.cachedOwnEntries(workspaceId, repoDir, p)));
1192
+ const result = new Map<string, boolean>();
1193
+ relativePaths.forEach((p, i) => {
1194
+ result.set(p, canReadResolved(model, userEmail, p, owns[i]));
1195
+ });
1196
+ return result;
1197
+ }
1198
+
1199
+ async canWriteBatch(
1200
+ workspaceId: string,
1201
+ userEmail: string,
1202
+ relativePaths: string[],
1203
+ ): Promise<Map<string, boolean>> {
1204
+ const model = await this.loadModel(workspaceId);
1205
+ const repoDir = await this.repoDir(workspaceId);
1206
+ // Parallel own-entries reads, memoized — same shape as `canReadBatch`.
1207
+ const owns = await Promise.all(relativePaths.map((p) => this.cachedOwnEntries(workspaceId, repoDir, p)));
1208
+ const result = new Map<string, boolean>();
1209
+ relativePaths.forEach((p, i) => {
1210
+ result.set(p, hasPermissionResolved(model, 'write', userEmail, p, owns[i]));
1211
+ });
1212
+ return result;
1213
+ }
1214
+
1215
+ async canDownload(
1216
+ workspaceId: string,
1217
+ userEmail: string,
1218
+ relativePath: string,
1219
+ ): Promise<boolean> {
1220
+ const model = await this.loadModel(workspaceId);
1221
+ const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
1222
+ return hasPermissionResolved(model, 'download', userEmail, relativePath, own);
1223
+ }
1224
+
1225
+ async canOwner(
1226
+ workspaceId: string,
1227
+ userEmail: string,
1228
+ relativePath: string,
1229
+ ): Promise<boolean> {
1230
+ const model = await this.loadModel(workspaceId);
1231
+ const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
1232
+ return hasPermissionResolved(model, 'owner', userEmail, relativePath, own);
1233
+ }
1234
+
1235
+ async eligibleOwners(
1236
+ workspaceId: string,
1237
+ relativePath: string,
1238
+ ): Promise<{ roles: string[]; users: { name: string; email: string }[] }> {
1239
+ const model = await this.loadModel(workspaceId);
1240
+ const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
1241
+ return eligibleHoldersResolved(model, 'owner', relativePath, own);
1242
+ }
1243
+
1244
+ async eligibleWriters(
1245
+ workspaceId: string,
1246
+ relativePath: string,
1247
+ ): Promise<{ roles: string[]; users: { name: string; email: string }[] }> {
1248
+ const model = await this.loadModel(workspaceId);
1249
+ const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
1250
+ return eligibleHoldersResolved(model, 'write', relativePath, own);
1251
+ }
1252
+
1253
+ async eligibleReaders(
1254
+ workspaceId: string,
1255
+ relativePath: string,
1256
+ ): Promise<{ restricted: boolean; roles: string[]; users: { name: string; email: string }[] }> {
1257
+ const model = await this.loadModel(workspaceId);
1258
+ const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
1259
+ // When `read: everyone` applies cleanly, the node is readable by all users
1260
+ // and the role/user lists are meaningless. Otherwise return the explicit
1261
+ // reader set; it may be empty for a default-denied path with no grants.
1262
+ if (canEveryoneReadResolved(model, relativePath, own)) {
1263
+ return { restricted: false, roles: [], users: [] };
1264
+ }
1265
+ const { roles, users } = eligibleHoldersResolved(model, 'read', relativePath, own);
1266
+ return { restricted: true, roles, users };
1267
+ }
1268
+
1269
+ async eligibleDownloaders(
1270
+ workspaceId: string,
1271
+ relativePath: string,
1272
+ ): Promise<{ roles: string[]; users: { name: string; email: string }[] }> {
1273
+ const model = await this.loadModel(workspaceId);
1274
+ const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
1275
+ return eligibleHoldersResolved(model, 'download', relativePath, own);
1276
+ }
1277
+
1278
+ async eligibleWriterEmails(
1279
+ workspaceId: string,
1280
+ relativePath: string,
1281
+ ): Promise<Map<string, { name: string; email: string }>> {
1282
+ const model = await this.loadModel(workspaceId);
1283
+ const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
1284
+ return eligibleHolderEmailsResolved(model, 'write', relativePath, own);
1285
+ }
1286
+
1287
+ async eligibleOwnerEmails(
1288
+ workspaceId: string,
1289
+ relativePath: string,
1290
+ ): Promise<Map<string, { name: string; email: string }>> {
1291
+ const model = await this.loadModel(workspaceId);
1292
+ const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
1293
+ return eligibleHolderEmailsResolved(model, 'owner', relativePath, own);
1294
+ }
1295
+
1296
+ async grantSources(
1297
+ workspaceId: string,
1298
+ kind: AccessTargetKind,
1299
+ relativePath: string,
1300
+ principal: GrantPrincipal,
1301
+ ): Promise<GrantSources> {
1302
+ const model = await this.loadModel(workspaceId);
1303
+ // A file target consults its own frontmatter as the most-specific scope; a
1304
+ // folder target's most-specific scope is its own access.md (in the dir
1305
+ // chain), so it passes no fileOwn.
1306
+ const own =
1307
+ kind === 'file'
1308
+ ? await this.readOwnEntries(await this.repoDir(workspaceId), relativePath)
1309
+ : null;
1310
+ const out: GrantSources = {};
1311
+ for (const verb of KNOWN_VERBS) {
1312
+ const sources = resolveGrantSourcesForVerb(model, verb, kind, relativePath, principal, own);
1313
+ if (sources.length > 0) out[verb] = sources;
1314
+ }
1315
+ return out;
1316
+ }
1317
+
1318
+ async kbPrincipals(
1319
+ workspaceId: string,
1320
+ ): Promise<{ groups: string[]; people: { name: string; email: string }[] }> {
1321
+ let model: AccessModel;
1322
+ try {
1323
+ model = await this.loadModel(workspaceId);
1324
+ } catch {
1325
+ return { groups: [], people: [] };
1326
+ }
1327
+ // Groups = the built-in `everyone` role plus every declared role's display
1328
+ // name. `everyone` is surfaced so the share UI can grant public read; the
1329
+ // grant route gates it to the `read` verb only (write/owner/download
1330
+ // everyone stay a direct-access.md edit).
1331
+ const groups = [
1332
+ EVERYONE_DISPLAY,
1333
+ ...[...model.roles.byCanonical.values()].map((r) => r.displayName),
1334
+ ];
1335
+ // People = roles.yaml member emails (name-less) ∪ access.md `Name <email>`
1336
+ // grants (named). The login-only users table is unioned in by the caller.
1337
+ const byEmail = new Map<string, string>(); // email -> display name ('' if unknown)
1338
+ for (const email of model.roles.byEmail.keys()) {
1339
+ if (!byEmail.has(email)) byEmail.set(email, '');
1340
+ }
1341
+ for (const file of model.accessFilesByDir.values()) {
1342
+ for (const verb of KNOWN_VERBS) {
1343
+ for (const entry of file.entries[verb]) {
1344
+ if (entry.kind === 'user' && entry.displayName && !byEmail.get(entry.email)) {
1345
+ byEmail.set(entry.email, entry.displayName);
1346
+ }
1347
+ }
1348
+ }
1349
+ }
1350
+ const people = [...byEmail.entries()].map(([email, name]) => ({
1351
+ name: name || email.split('@')[0],
1352
+ email,
1353
+ }));
1354
+ return { groups, people };
1355
+ }
1356
+
1357
+ async findEmailByHash(
1358
+ workspaceId: string,
1359
+ hash: string,
1360
+ ): Promise<{ email: string; displayName: string } | null> {
1361
+ let model: AccessModel;
1362
+ try {
1363
+ model = await this.loadModel(workspaceId);
1364
+ } catch {
1365
+ return null;
1366
+ }
1367
+
1368
+ // Build a name index from access.md user grants — those carry an
1369
+ // explicit `Name <email>` so we can show "Felix Kissel" instead of
1370
+ // "felix.kissel". roles.yaml only has emails, no names. Scan every
1371
+ // verb's entries so a user named only under `download:` still
1372
+ // contributes their display name.
1373
+ const namesByEmail = new Map<string, string>();
1374
+ for (const file of model.accessFilesByDir.values()) {
1375
+ for (const verb of KNOWN_VERBS) {
1376
+ for (const entry of file.entries[verb]) {
1377
+ if (entry.kind === 'user' && entry.displayName && !namesByEmail.has(entry.email)) {
1378
+ namesByEmail.set(entry.email, entry.displayName);
1379
+ }
1380
+ }
1381
+ }
1382
+ }
1383
+
1384
+ const candidates = new Set<string>([
1385
+ ...model.roles.byEmail.keys(),
1386
+ ...namesByEmail.keys(),
1387
+ ]);
1388
+ for (const email of candidates) {
1389
+ if (sha256Email(email) === hash) {
1390
+ const displayName = namesByEmail.get(email) ?? email.split('@')[0];
1391
+ return { email, displayName };
1392
+ }
1393
+ }
1394
+ return null;
1395
+ }
1396
+
1397
+ async canWriteAtRef(
1398
+ workspaceId: string,
1399
+ ref: string,
1400
+ userEmail: string,
1401
+ relativePath: string,
1402
+ ): Promise<boolean | null> {
1403
+ const loaded = await this.loadModelAtRef(workspaceId, ref);
1404
+ if (!loaded) return null;
1405
+ const repoDir = await this.repoDir(workspaceId);
1406
+ const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
1407
+ return hasPermissionResolved(loaded.model, 'write', userEmail, relativePath, own);
1408
+ }
1409
+
1410
+ async canReadAtRef(
1411
+ workspaceId: string,
1412
+ ref: string,
1413
+ userEmail: string,
1414
+ relativePath: string,
1415
+ ): Promise<boolean | null> {
1416
+ const loaded = await this.loadModelAtRef(workspaceId, ref);
1417
+ if (!loaded) return null;
1418
+ const repoDir = await this.repoDir(workspaceId);
1419
+ const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
1420
+ return canReadResolved(loaded.model, userEmail, relativePath, own);
1421
+ }
1422
+
1423
+ async canWriteBatchAtRef(
1424
+ workspaceId: string,
1425
+ ref: string,
1426
+ userEmail: string,
1427
+ relativePaths: string[],
1428
+ ): Promise<Map<string, boolean> | null> {
1429
+ const loaded = await this.loadModelAtRef(workspaceId, ref);
1430
+ if (!loaded) return null;
1431
+ const repoDir = await this.repoDir(workspaceId);
1432
+ // One `git cat-file --batch` for the whole path set — a per-path `git
1433
+ // show` spawn made CR owner-routing take minutes on large change sets.
1434
+ const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
1435
+ const result = new Map<string, boolean>();
1436
+ for (const p of relativePaths) {
1437
+ result.set(p, hasPermissionResolved(loaded.model, 'write', userEmail, p, owns.get(p) ?? null));
1438
+ }
1439
+ return result;
1440
+ }
1441
+
1442
+ async eligibleWritersAtRef(
1443
+ workspaceId: string,
1444
+ ref: string,
1445
+ relativePath: string,
1446
+ ): Promise<{ roles: string[]; users: { name: string; email: string }[] } | null> {
1447
+ const loaded = await this.loadModelAtRef(workspaceId, ref);
1448
+ if (!loaded) return null;
1449
+ const repoDir = await this.repoDir(workspaceId);
1450
+ const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
1451
+ return eligibleHoldersResolved(loaded.model, 'write', relativePath, own);
1452
+ }
1453
+
1454
+ async eligibleWritersForPathsAtRef(
1455
+ workspaceId: string,
1456
+ ref: string,
1457
+ relativePaths: string[],
1458
+ ): Promise<Map<
1459
+ string,
1460
+ {
1461
+ roles: string[];
1462
+ users: { name: string; email: string }[];
1463
+ emails: Set<string>;
1464
+ excludedEmails?: Set<string>;
1465
+ }
1466
+ > | null> {
1467
+ const loaded = await this.loadModelAtRef(workspaceId, ref);
1468
+ if (!loaded) return null;
1469
+ const repoDir = await this.repoDir(workspaceId);
1470
+ const result = new Map<
1471
+ string,
1472
+ {
1473
+ roles: string[];
1474
+ users: { name: string; email: string }[];
1475
+ emails: Set<string>;
1476
+ excludedEmails?: Set<string>;
1477
+ }
1478
+ >();
1479
+ // One `git cat-file --batch` for the whole path set — see canWriteBatchAtRef.
1480
+ const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
1481
+ for (const p of relativePaths) {
1482
+ const own = owns.get(p) ?? null;
1483
+ const display = eligibleHoldersResolved(loaded.model, 'write', p, own);
1484
+ const emails = new Set(eligibleHolderEmailsResolved(loaded.model, 'write', p, own).keys());
1485
+ const excludedEmails = ineligibleNamedEmailsResolved(loaded.model, 'write', p, own);
1486
+ result.set(p, { roles: display.roles, users: display.users, emails, excludedEmails });
1487
+ }
1488
+ return result;
1489
+ }
1490
+
1491
+ // -------------------------------------------------------------------------
1492
+ // Model loading
1493
+ // -------------------------------------------------------------------------
1494
+
1495
+ private async loadModel(workspaceId: string): Promise<AccessModel> {
1496
+ const cached = this.cache.get(workspaceId);
1497
+ if (cached && Date.now() - cached.loadedAt < AccessControlService.CACHE_TTL_MS) {
1498
+ return cached.model;
1499
+ }
1500
+
1501
+ const repoDir = await this.repoDir(workspaceId);
1502
+
1503
+ let rolesYaml: string;
1504
+ try {
1505
+ rolesYaml = await fs.readFile(path.join(repoDir, 'roles.yaml'), 'utf-8');
1506
+ } catch {
1507
+ throw new AccessConfigError([`roles.yaml not found in ${this.kbDirName}/`]);
1508
+ }
1509
+
1510
+ const rolesParsed = parseRolesYaml(rolesYaml);
1511
+ if (!rolesParsed.ok) throw new AccessConfigError(rolesParsed.errors);
1512
+
1513
+ const accessFiles = new Map<string, AccessFile>();
1514
+
1515
+ // Walk the entire repo for `access.md` files. The access tree is
1516
+ // structure-agnostic — any `access.md` at any depth (including repo
1517
+ // root) participates, no matter the folder layout. The recursive
1518
+ // walker skips VCS metadata + `node_modules`; everything else is fair
1519
+ // game. Missing root `access.md` is OK at runtime — default-deny
1520
+ // applies (the validator surfaces it as a warning separately).
1521
+ //
1522
+ // Per-file failures (malformed YAML, bad role refs, unknown verbs)
1523
+ // are logged + the offending file is dropped from the model — they
1524
+ // do NOT throw `AccessConfigError`. A typo in one nested access.md
1525
+ // must not 500 the entire editor; admins can still write `access.md`
1526
+ // / `roles.yaml` because `hasPermissionResolved` admin-rescues those
1527
+ // paths, so the bad config remains fixable from inside the app.
1528
+ await this.collectAccessFiles(repoDir, '', accessFiles);
1529
+
1530
+ // Validate role refs against roles.yaml. `everyone` is a built-in role and
1531
+ // is valid without a roles.yaml entry. Unknown refs are dropped from the
1532
+ // parsed entry list (along with a warn log) so the rest of the file still
1533
+ // applies.
1534
+ for (const [, file] of accessFiles) {
1535
+ for (const verb of KNOWN_VERBS) {
1536
+ file.entries[verb] = file.entries[verb].filter((entry) => {
1537
+ if (entry.kind === 'role' && !roleKnown(rolesParsed.index, entry.role)) {
1538
+ console.warn(
1539
+ `[access] ${file.path}: '${verb}' references unknown role '${entry.displayRole}' — entry ignored`,
1540
+ );
1541
+ return false;
1542
+ }
1543
+ return true;
1544
+ });
1545
+ }
1546
+ }
1547
+
1548
+ const model: AccessModel = {
1549
+ roles: rolesParsed.index,
1550
+ accessFilesByDir: accessFiles,
1551
+ };
1552
+ this.cache.set(workspaceId, { model, loadedAt: Date.now() });
1553
+ return model;
1554
+ }
1555
+
1556
+ private async collectAccessFiles(
1557
+ absDir: string,
1558
+ relDir: string,
1559
+ out: Map<string, AccessFile>,
1560
+ ): Promise<void> {
1561
+ let entries;
1562
+ try {
1563
+ entries = await fs.readdir(absDir, { withFileTypes: true });
1564
+ } catch {
1565
+ return;
1566
+ }
1567
+ for (const entry of entries) {
1568
+ // Skip VCS metadata + vendored deps. Hidden dirs (`.git`, `.vscode`,
1569
+ // etc.) and `node_modules` can't host KB rules and are often huge —
1570
+ // walking them would slow every cache miss without benefit.
1571
+ if (entry.name.startsWith('.')) continue;
1572
+ if (entry.name === 'node_modules') continue;
1573
+ const abs = path.join(absDir, entry.name);
1574
+ const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
1575
+ if (entry.isDirectory()) {
1576
+ await this.collectAccessFiles(abs, rel, out);
1577
+ } else if (entry.isFile() && entry.name === 'access.md') {
1578
+ const text = await fs.readFile(abs, 'utf-8');
1579
+ const parsed = parseAccessFile(text, rel);
1580
+ if (!parsed.ok) {
1581
+ // Treat as if the file didn't exist for resolution purposes.
1582
+ // Admin-rescue on access.md paths still lets an admin fix it.
1583
+ for (const e of parsed.errors) console.warn(`[access] ${e} — file ignored`);
1584
+ continue;
1585
+ }
1586
+ for (const w of parsed.warnings) console.warn(`[access] ${w}`);
1587
+ if (out.has(parsed.file.dir)) {
1588
+ console.warn(
1589
+ `[access] ${rel}: duplicate access.md for directory '${parsed.file.dir}' — keeping the first one seen`,
1590
+ );
1591
+ continue;
1592
+ }
1593
+ out.set(parsed.file.dir, parsed.file);
1594
+ }
1595
+ }
1596
+ }
1597
+
1598
+ private async repoDir(workspaceId: string): Promise<string> {
1599
+ const wsDir = await this.workspaceService.getWorkspacePath(workspaceId);
1600
+ return path.join(wsDir, this.kbDirName);
1601
+ }
1602
+
1603
+ /**
1604
+ * Read the access verbs a node file declares in its own frontmatter from the
1605
+ * working tree. Returns null when the file has no per-file access config
1606
+ * (the common case). `access.md` and `roles.yaml` are skipped — `access.md`
1607
+ * frontmatter governs its directory (handled by the dir chain), not itself,
1608
+ * and `roles.yaml` is not a node.
1609
+ */
1610
+ private async readOwnEntries(
1611
+ repoDir: string,
1612
+ relativePath: string,
1613
+ ): Promise<OwnEntries | null> {
1614
+ if (isAccessMdPath(relativePath) || relativePath === 'roles.yaml') return null;
1615
+ let text: string;
1616
+ try {
1617
+ text = await fs.readFile(path.join(repoDir, relativePath), 'utf-8');
1618
+ } catch {
1619
+ return null;
1620
+ }
1621
+ return parseOwnAccessEntries(text);
1622
+ }
1623
+
1624
+ /**
1625
+ * Same as `readOwnEntries` but reads the file at a specific git ref. The ref
1626
+ * MUST be the same one the dir model was loaded at — otherwise a user could
1627
+ * grant themselves rights by editing a file's frontmatter in a branch the
1628
+ * gate isn't reading from.
1629
+ */
1630
+ private async readOwnEntriesAtRef(
1631
+ repoDir: string,
1632
+ ref: string,
1633
+ relativePath: string,
1634
+ ): Promise<OwnEntries | null> {
1635
+ if (isAccessMdPath(relativePath) || relativePath === 'roles.yaml') return null;
1636
+ const text = await this.showAtRef(repoDir, ref, relativePath);
1637
+ if (text === null) return null;
1638
+ return parseOwnAccessEntries(text);
1639
+ }
1640
+
1641
+ /**
1642
+ * Batched `readOwnEntriesAtRef`: ONE `git cat-file --batch` process reads
1643
+ * every path's blob at `ref`, instead of one `git show` SPAWN per path —
1644
+ * the difference between minutes and sub-second when a change request
1645
+ * touches hundreds of files. Paths missing at the ref, non-blob paths, and
1646
+ * `access.md`/`roles.yaml` (which carry no own-entries) resolve to null,
1647
+ * mirroring the single-path helper.
1648
+ */
1649
+ private async readOwnEntriesAtRefBatch(
1650
+ repoDir: string,
1651
+ ref: string,
1652
+ relativePaths: string[],
1653
+ ): Promise<Map<string, OwnEntries | null>> {
1654
+ const result = new Map<string, OwnEntries | null>();
1655
+ const wanted: string[] = [];
1656
+ for (const p of relativePaths) {
1657
+ if (isAccessMdPath(p) || p === 'roles.yaml') result.set(p, null);
1658
+ else if (!wanted.includes(p)) wanted.push(p);
1659
+ }
1660
+ if (wanted.length === 0) return result;
1661
+ const texts = await catFileBatch(repoDir, wanted.map((p) => `${ref}:${p}`));
1662
+ wanted.forEach((p, i) => {
1663
+ const text = texts[i];
1664
+ result.set(p, text === null ? null : parseOwnAccessEntries(text));
1665
+ });
1666
+ return result;
1667
+ }
1668
+
1669
+ /**
1670
+ * Resolve a candidate ref to one git accepts. Falls back to `origin/<ref>`
1671
+ * for short branch names, matching the heuristic in
1672
+ * `WorkspaceService.readFileAtRef`.
1673
+ */
1674
+ private refCandidates(ref: string): string[] {
1675
+ if (ref.startsWith('origin/') || ref.startsWith('refs/')) return [ref];
1676
+ return [ref, `origin/${ref}`];
1677
+ }
1678
+
1679
+ /**
1680
+ * Read a file's content at a specific ref via `git show <ref>:<path>`.
1681
+ * Returns null when the file is absent on that ref (or the ref doesn't
1682
+ * resolve).
1683
+ */
1684
+ private async showAtRef(repoDir: string, ref: string, relativePath: string): Promise<string | null> {
1685
+ try {
1686
+ const { stdout } = await execFileAsync(
1687
+ 'git',
1688
+ ['-C', repoDir, 'show', `${ref}:${relativePath}`],
1689
+ { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 },
1690
+ );
1691
+ return stdout;
1692
+ } catch {
1693
+ return null;
1694
+ }
1695
+ }
1696
+
1697
+ /**
1698
+ * List every `access.md` path that exists at any depth as of `ref`.
1699
+ * The access tree is structure-agnostic: any `access.md` participates
1700
+ * regardless of where it sits. Uses `git ls-tree -r --name-only`.
1701
+ */
1702
+ private async listAccessFilesAtRef(repoDir: string, ref: string): Promise<string[]> {
1703
+ try {
1704
+ const { stdout } = await execFileAsync(
1705
+ 'git',
1706
+ ['-C', repoDir, 'ls-tree', '-r', '--name-only', ref],
1707
+ { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 },
1708
+ );
1709
+ const out: string[] = [];
1710
+ for (const line of stdout.split('\n')) {
1711
+ const p = line.trim();
1712
+ if (!p) continue;
1713
+ if (p === 'access.md' || p.endsWith('/access.md')) out.push(p);
1714
+ }
1715
+ return out;
1716
+ } catch {
1717
+ return [];
1718
+ }
1719
+ }
1720
+
1721
+ /**
1722
+ * Build the access model from the access tree as it exists on a specific
1723
+ * ref. The ref is the authoritative source — typically `origin/<base>`
1724
+ * for PR-time decisions, so a malicious user can't grant themselves
1725
+ * approval rights by editing access.md in their working tree (or even on
1726
+ * an unmerged branch).
1727
+ *
1728
+ * Returns null when the ref doesn't carry usable access config (no
1729
+ * `roles.yaml`, or roles.yaml is malformed). Callers treat null as
1730
+ * "can't determine eligibility, deny." That includes the bootstrap case
1731
+ * where access.md hasn't been merged to the target branch yet — admins
1732
+ * must commit the initial config directly to the protected branch before
1733
+ * the gate becomes useful. NEVER fall back to the working tree here: a
1734
+ * working-tree fallback lets anyone with edit access to access.md grant
1735
+ * themselves approval rights.
1736
+ */
1737
+ private async loadModelAtRef(
1738
+ workspaceId: string,
1739
+ ref: string,
1740
+ ): Promise<{ model: AccessModel; resolvedRef: string } | null> {
1741
+ const repoDir = await this.repoDir(workspaceId);
1742
+
1743
+ // Refresh remote-tracking refs so a PR branch we've never personally
1744
+ // checked out is resolvable. Best-effort — a fetch failure shouldn't
1745
+ // block the lookup if the ref already exists locally.
1746
+ await this.workspaceService.ensureRemotesFetched(workspaceId).catch(() => undefined);
1747
+
1748
+ let rolesYaml: string | null = null;
1749
+ let resolvedRef: string | null = null;
1750
+ for (const candidate of this.refCandidates(ref)) {
1751
+ const text = await this.showAtRef(repoDir, candidate, 'roles.yaml');
1752
+ if (text !== null) {
1753
+ rolesYaml = text;
1754
+ resolvedRef = candidate;
1755
+ break;
1756
+ }
1757
+ }
1758
+ if (!rolesYaml || !resolvedRef) return null;
1759
+
1760
+ const rolesParsed = parseRolesYaml(rolesYaml);
1761
+ if (!rolesParsed.ok) return null;
1762
+
1763
+ const accessFiles = new Map<string, AccessFile>();
1764
+ const accessPaths = await this.listAccessFilesAtRef(repoDir, resolvedRef);
1765
+ for (const p of accessPaths) {
1766
+ const text = await this.showAtRef(repoDir, resolvedRef, p);
1767
+ if (text === null) continue;
1768
+ const parsed = parseAccessFile(text, p);
1769
+ if (!parsed.ok) continue;
1770
+ // Last-writer-wins on dir collisions; the at-ref path should never
1771
+ // collide in a healthy tree because the working-tree validator forbids
1772
+ // duplicates, but be defensive.
1773
+ accessFiles.set(parsed.file.dir, parsed.file);
1774
+ }
1775
+
1776
+ // Mirror `loadModel`: drop entries whose role ref is unknown to roles.yaml,
1777
+ // while preserving the built-in `everyone` role.
1778
+ for (const [, file] of accessFiles) {
1779
+ for (const verb of KNOWN_VERBS) {
1780
+ file.entries[verb] = file.entries[verb].filter(
1781
+ (entry) => entry.kind !== 'role' || roleKnown(rolesParsed.index, entry.role),
1782
+ );
1783
+ }
1784
+ }
1785
+
1786
+ return {
1787
+ model: {
1788
+ roles: rolesParsed.index,
1789
+ accessFilesByDir: accessFiles,
1790
+ },
1791
+ resolvedRef,
1792
+ };
1793
+ }
1794
+ }