@bevel-software/platform-core-backend 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (385) hide show
  1. package/THIRD-PARTY-NOTICES.md +6 -6
  2. package/dist/core/catalog-cache-invalidation.d.ts +29 -0
  3. package/dist/core/catalog-cache-invalidation.d.ts.map +1 -0
  4. package/dist/core/catalog-cache-invalidation.js +71 -0
  5. package/dist/core/catalog-cache-invalidation.js.map +1 -0
  6. package/dist/core/create-core-server.d.ts.map +1 -1
  7. package/dist/core/create-core-server.js +99 -8
  8. package/dist/core/create-core-server.js.map +1 -1
  9. package/dist/core/create-core-services.d.ts +18 -1
  10. package/dist/core/create-core-services.d.ts.map +1 -1
  11. package/dist/core/create-core-services.js +96 -43
  12. package/dist/core/create-core-services.js.map +1 -1
  13. package/dist/modules/access/access-control.interface.d.ts +18 -6
  14. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  15. package/dist/modules/access/access-control.service.d.ts +26 -1
  16. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  17. package/dist/modules/access/access-control.service.js +213 -71
  18. package/dist/modules/access/access-control.service.js.map +1 -1
  19. package/dist/modules/access/access.routes.d.ts.map +1 -1
  20. package/dist/modules/access/access.routes.js +59 -7
  21. package/dist/modules/access/access.routes.js.map +1 -1
  22. package/dist/modules/access-model/access-grammar.d.ts +51 -1
  23. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  24. package/dist/modules/access-model/access-grammar.js +95 -1
  25. package/dist/modules/access-model/access-grammar.js.map +1 -1
  26. package/dist/modules/access-model/group-files.d.ts.map +1 -1
  27. package/dist/modules/access-model/group-files.js +4 -1
  28. package/dist/modules/access-model/group-files.js.map +1 -1
  29. package/dist/modules/access-model/plugin-principals.d.ts +49 -0
  30. package/dist/modules/access-model/plugin-principals.d.ts.map +1 -0
  31. package/dist/modules/access-model/plugin-principals.js +125 -0
  32. package/dist/modules/access-model/plugin-principals.js.map +1 -0
  33. package/dist/modules/database/core-schema.d.ts +326 -0
  34. package/dist/modules/database/core-schema.d.ts.map +1 -1
  35. package/dist/modules/database/core-schema.js +52 -0
  36. package/dist/modules/database/core-schema.js.map +1 -1
  37. package/dist/modules/kb-sync/index.d.ts +6 -0
  38. package/dist/modules/kb-sync/index.d.ts.map +1 -0
  39. package/dist/modules/kb-sync/index.js +5 -0
  40. package/dist/modules/kb-sync/index.js.map +1 -0
  41. package/dist/modules/kb-sync/kb-sync.interface.d.ts +77 -0
  42. package/dist/modules/kb-sync/kb-sync.interface.d.ts.map +1 -0
  43. package/dist/modules/kb-sync/kb-sync.interface.js +2 -0
  44. package/dist/modules/kb-sync/kb-sync.interface.js.map +1 -0
  45. package/dist/modules/kb-sync/kb-sync.routes.d.ts +70 -0
  46. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -0
  47. package/dist/modules/kb-sync/kb-sync.routes.js +135 -0
  48. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -0
  49. package/dist/modules/kb-sync/kb-sync.service.d.ts +45 -0
  50. package/dist/modules/kb-sync/kb-sync.service.d.ts.map +1 -0
  51. package/dist/modules/kb-sync/kb-sync.service.js +183 -0
  52. package/dist/modules/kb-sync/kb-sync.service.js.map +1 -0
  53. package/dist/modules/kb-sync/sync-auth.d.ts +59 -0
  54. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -0
  55. package/dist/modules/kb-sync/sync-auth.js +62 -0
  56. package/dist/modules/kb-sync/sync-auth.js.map +1 -0
  57. package/dist/modules/kb-sync/sync-payload.d.ts +32 -0
  58. package/dist/modules/kb-sync/sync-payload.d.ts.map +1 -0
  59. package/dist/modules/kb-sync/sync-payload.js +105 -0
  60. package/dist/modules/kb-sync/sync-payload.js.map +1 -0
  61. package/dist/modules/marketplace/git-http.routes.d.ts +42 -0
  62. package/dist/modules/marketplace/git-http.routes.d.ts.map +1 -0
  63. package/dist/modules/marketplace/git-http.routes.js +190 -0
  64. package/dist/modules/marketplace/git-http.routes.js.map +1 -0
  65. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.d.ts +23 -0
  66. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.d.ts.map +1 -0
  67. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.js +59 -0
  68. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.js.map +1 -0
  69. package/dist/modules/marketplace/github-facade/github-facade-codes.store.d.ts +59 -0
  70. package/dist/modules/marketplace/github-facade/github-facade-codes.store.d.ts.map +1 -0
  71. package/dist/modules/marketplace/github-facade/github-facade-codes.store.js +96 -0
  72. package/dist/modules/marketplace/github-facade/github-facade-codes.store.js.map +1 -0
  73. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.d.ts +99 -0
  74. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.d.ts.map +1 -0
  75. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.js +147 -0
  76. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.js.map +1 -0
  77. package/dist/modules/marketplace/github-facade/github-facade.routes.d.ts +47 -0
  78. package/dist/modules/marketplace/github-facade/github-facade.routes.d.ts.map +1 -0
  79. package/dist/modules/marketplace/github-facade/github-facade.routes.js +228 -0
  80. package/dist/modules/marketplace/github-facade/github-facade.routes.js.map +1 -0
  81. package/dist/modules/marketplace/github-facade/github-facade.service.d.ts +109 -0
  82. package/dist/modules/marketplace/github-facade/github-facade.service.d.ts.map +1 -0
  83. package/dist/modules/marketplace/github-facade/github-facade.service.js +172 -0
  84. package/dist/modules/marketplace/github-facade/github-facade.service.js.map +1 -0
  85. package/dist/modules/marketplace/github-facade/index.d.ts +6 -0
  86. package/dist/modules/marketplace/github-facade/index.d.ts.map +1 -0
  87. package/dist/modules/marketplace/github-facade/index.js +6 -0
  88. package/dist/modules/marketplace/github-facade/index.js.map +1 -0
  89. package/dist/modules/marketplace/index.d.ts +4 -0
  90. package/dist/modules/marketplace/index.d.ts.map +1 -0
  91. package/dist/modules/marketplace/index.js +4 -0
  92. package/dist/modules/marketplace/index.js.map +1 -0
  93. package/dist/modules/marketplace/marketplace-repo.service.d.ts +122 -0
  94. package/dist/modules/marketplace/marketplace-repo.service.d.ts.map +1 -0
  95. package/dist/modules/marketplace/marketplace-repo.service.js +300 -0
  96. package/dist/modules/marketplace/marketplace-repo.service.js.map +1 -0
  97. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  98. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +4 -0
  99. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  100. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts +14 -8
  101. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts.map +1 -1
  102. package/dist/modules/mcp/oauth/oauth-consent.routes.js +13 -1
  103. package/dist/modules/mcp/oauth/oauth-consent.routes.js.map +1 -1
  104. package/dist/modules/mcp/oauth/oauth-state.d.ts +13 -2
  105. package/dist/modules/mcp/oauth/oauth-state.d.ts.map +1 -1
  106. package/dist/modules/mcp/oauth/oauth-state.js +3 -1
  107. package/dist/modules/mcp/oauth/oauth-state.js.map +1 -1
  108. package/dist/modules/plugins/compile/compile-marketplace.d.ts +97 -0
  109. package/dist/modules/plugins/compile/compile-marketplace.d.ts.map +1 -0
  110. package/dist/modules/plugins/compile/compile-marketplace.js +240 -0
  111. package/dist/modules/plugins/compile/compile-marketplace.js.map +1 -0
  112. package/dist/modules/plugins/compile/marketplace-compiler.service.d.ts +66 -0
  113. package/dist/modules/plugins/compile/marketplace-compiler.service.d.ts.map +1 -0
  114. package/dist/modules/plugins/compile/marketplace-compiler.service.js +132 -0
  115. package/dist/modules/plugins/compile/marketplace-compiler.service.js.map +1 -0
  116. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.d.ts +37 -0
  117. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.d.ts.map +1 -0
  118. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.js +135 -0
  119. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.js.map +1 -0
  120. package/dist/modules/plugins/discovery/bundle-dialect/registry.d.ts +45 -0
  121. package/dist/modules/plugins/discovery/bundle-dialect/registry.d.ts.map +1 -0
  122. package/dist/modules/plugins/discovery/bundle-dialect/registry.js +147 -0
  123. package/dist/modules/plugins/discovery/bundle-dialect/registry.js.map +1 -0
  124. package/dist/modules/plugins/discovery/kb-plugin-source.d.ts +37 -0
  125. package/dist/modules/plugins/discovery/kb-plugin-source.d.ts.map +1 -0
  126. package/dist/modules/plugins/discovery/kb-plugin-source.js +107 -0
  127. package/dist/modules/plugins/discovery/kb-plugin-source.js.map +1 -0
  128. package/dist/modules/plugins/discovery/native.source.d.ts +15 -0
  129. package/dist/modules/plugins/discovery/native.source.d.ts.map +1 -0
  130. package/dist/modules/plugins/discovery/native.source.js +102 -0
  131. package/dist/modules/plugins/discovery/native.source.js.map +1 -0
  132. package/dist/modules/plugins/discovery/plugin-source.d.ts +96 -0
  133. package/dist/modules/plugins/discovery/plugin-source.d.ts.map +1 -0
  134. package/dist/modules/plugins/discovery/plugin-source.js +2 -0
  135. package/dist/modules/plugins/discovery/plugin-source.js.map +1 -0
  136. package/dist/modules/plugins/index.d.ts +10 -1
  137. package/dist/modules/plugins/index.d.ts.map +1 -1
  138. package/dist/modules/plugins/index.js +8 -0
  139. package/dist/modules/plugins/index.js.map +1 -1
  140. package/dist/modules/plugins/plugin-links.d.ts +66 -0
  141. package/dist/modules/plugins/plugin-links.d.ts.map +1 -0
  142. package/dist/modules/plugins/plugin-links.js +160 -0
  143. package/dist/modules/plugins/plugin-links.js.map +1 -0
  144. package/dist/modules/plugins/plugin-links.service.d.ts +114 -0
  145. package/dist/modules/plugins/plugin-links.service.d.ts.map +1 -0
  146. package/dist/modules/plugins/plugin-links.service.js +304 -0
  147. package/dist/modules/plugins/plugin-links.service.js.map +1 -0
  148. package/dist/modules/plugins/plugin-provision.service.d.ts +39 -3
  149. package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -1
  150. package/dist/modules/plugins/plugin-provision.service.js +152 -52
  151. package/dist/modules/plugins/plugin-provision.service.js.map +1 -1
  152. package/dist/modules/plugins/plugin-rename.service.d.ts +69 -0
  153. package/dist/modules/plugins/plugin-rename.service.d.ts.map +1 -0
  154. package/dist/modules/plugins/plugin-rename.service.js +245 -0
  155. package/dist/modules/plugins/plugin-rename.service.js.map +1 -0
  156. package/dist/modules/plugins/plugins.contract.d.ts +27 -1
  157. package/dist/modules/plugins/plugins.contract.d.ts.map +1 -1
  158. package/dist/modules/plugins/plugins.routes.d.ts +7 -1
  159. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  160. package/dist/modules/plugins/plugins.routes.js +120 -12
  161. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  162. package/dist/modules/plugins/plugins.service.d.ts +50 -3
  163. package/dist/modules/plugins/plugins.service.d.ts.map +1 -1
  164. package/dist/modules/plugins/plugins.service.js +108 -47
  165. package/dist/modules/plugins/plugins.service.js.map +1 -1
  166. package/dist/modules/settings/deployment-settings.service.d.ts +9 -0
  167. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  168. package/dist/modules/settings/deployment-settings.service.js +82 -2
  169. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  170. package/dist/modules/settings/setup.routes.d.ts +26 -0
  171. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  172. package/dist/modules/settings/setup.routes.js +9 -1
  173. package/dist/modules/settings/setup.routes.js.map +1 -1
  174. package/dist/modules/skills/index.d.ts +1 -0
  175. package/dist/modules/skills/index.d.ts.map +1 -1
  176. package/dist/modules/skills/index.js +1 -0
  177. package/dist/modules/skills/index.js.map +1 -1
  178. package/dist/modules/skills/pending-skills.service.d.ts.map +1 -1
  179. package/dist/modules/skills/pending-skills.service.js +9 -4
  180. package/dist/modules/skills/pending-skills.service.js.map +1 -1
  181. package/dist/modules/skills/skill-access-requests.routes.d.ts +38 -0
  182. package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -0
  183. package/dist/modules/skills/skill-access-requests.routes.js +168 -0
  184. package/dist/modules/skills/skill-access-requests.routes.js.map +1 -0
  185. package/dist/modules/skills/skills.contract.d.ts +31 -0
  186. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  187. package/dist/modules/skills/skills.routes.d.ts +28 -3
  188. package/dist/modules/skills/skills.routes.d.ts.map +1 -1
  189. package/dist/modules/skills/skills.routes.js +42 -3
  190. package/dist/modules/skills/skills.routes.js.map +1 -1
  191. package/dist/modules/skills/skills.service.d.ts +4 -0
  192. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  193. package/dist/modules/skills/skills.service.js +83 -22
  194. package/dist/modules/skills/skills.service.js.map +1 -1
  195. package/dist/modules/tool-auth/external-api-key.interface.d.ts +18 -1
  196. package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -1
  197. package/dist/modules/tool-auth/external-api-key.interface.js +2 -1
  198. package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -1
  199. package/dist/modules/tool-auth/external-api-key.service.d.ts +13 -4
  200. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  201. package/dist/modules/tool-auth/external-api-key.service.js +26 -9
  202. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  203. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +36 -0
  204. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  205. package/dist/modules/tool-manuals/mcp-json-discovery.js +130 -92
  206. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  207. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +6 -1
  208. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  209. package/dist/modules/tool-manuals/tool-manuals.service.js +18 -25
  210. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  211. package/dist/modules/workflow/git/git.service.d.ts +40 -2
  212. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  213. package/dist/modules/workflow/git/git.service.js +227 -54
  214. package/dist/modules/workflow/git/git.service.js.map +1 -1
  215. package/dist/modules/workflow/git/pull-request.service.d.ts +6 -0
  216. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  217. package/dist/modules/workflow/git/pull-request.service.js +8 -0
  218. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  219. package/dist/modules/workflow/workflow.service.d.ts +52 -1
  220. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  221. package/dist/modules/workflow/workflow.service.js +242 -9
  222. package/dist/modules/workflow/workflow.service.js.map +1 -1
  223. package/dist/modules/workspace/startup/steps/plugin-manifests.step.d.ts +35 -0
  224. package/dist/modules/workspace/startup/steps/plugin-manifests.step.d.ts.map +1 -0
  225. package/dist/modules/workspace/startup/steps/plugin-manifests.step.js +121 -0
  226. package/dist/modules/workspace/startup/steps/plugin-manifests.step.js.map +1 -0
  227. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  228. package/dist/modules/workspace/startup/steps/seed-tree.js +46 -2
  229. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  230. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -1
  231. package/dist/modules/workspace/startup/steps/template-files.step.js +112 -25
  232. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -1
  233. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  234. package/dist/modules/workspace/workspace.routes.js +9 -9
  235. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  236. package/dist/modules/workspace/workspace.service.d.ts +33 -0
  237. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  238. package/dist/modules/workspace/workspace.service.js +110 -16
  239. package/dist/modules/workspace/workspace.service.js.map +1 -1
  240. package/dist/shared/domain-errors.d.ts +20 -0
  241. package/dist/shared/domain-errors.d.ts.map +1 -1
  242. package/dist/shared/domain-errors.js +29 -0
  243. package/dist/shared/domain-errors.js.map +1 -1
  244. package/dist/shared/fs-errors.d.ts +8 -0
  245. package/dist/shared/fs-errors.d.ts.map +1 -0
  246. package/dist/shared/fs-errors.js +11 -0
  247. package/dist/shared/fs-errors.js.map +1 -0
  248. package/dist/shared/fs-walk.d.ts +20 -6
  249. package/dist/shared/fs-walk.d.ts.map +1 -1
  250. package/dist/shared/fs-walk.js +35 -27
  251. package/dist/shared/fs-walk.js.map +1 -1
  252. package/dist/shared/kb-walk.d.ts +52 -0
  253. package/dist/shared/kb-walk.d.ts.map +1 -0
  254. package/dist/shared/kb-walk.js +76 -0
  255. package/dist/shared/kb-walk.js.map +1 -0
  256. package/dist/shared/path-order.d.ts +18 -0
  257. package/dist/shared/path-order.d.ts.map +1 -0
  258. package/dist/shared/path-order.js +35 -0
  259. package/dist/shared/path-order.js.map +1 -0
  260. package/dist/shared/ttl-cache.d.ts +28 -1
  261. package/dist/shared/ttl-cache.d.ts.map +1 -1
  262. package/dist/shared/ttl-cache.js +33 -1
  263. package/dist/shared/ttl-cache.js.map +1 -1
  264. package/kb-template/.bevelignore +1 -1
  265. package/kb-template/AGENTS.md +95 -30
  266. package/kb-template/Skills/.gitkeep +0 -0
  267. package/migrations/0005_github_facade.sql +25 -0
  268. package/migrations/meta/0005_snapshot.json +1717 -0
  269. package/migrations/meta/_journal.json +7 -0
  270. package/package.json +6 -6
  271. package/src/__tests__/kb-layout-config.test.ts +110 -0
  272. package/src/core/__tests__/catalog-cache-invalidation.test.ts +146 -0
  273. package/src/core/catalog-cache-invalidation.ts +92 -0
  274. package/src/core/create-core-server.ts +123 -7
  275. package/src/core/create-core-services.ts +162 -41
  276. package/src/modules/access/__tests__/access-control.invalidate-race.test.ts +86 -0
  277. package/src/modules/access/__tests__/access-control.service.test.ts +19 -3
  278. package/src/modules/access/__tests__/access-plugin-principals.test.ts +368 -0
  279. package/src/modules/access/__tests__/groups-admin.service.test.ts +8 -0
  280. package/src/modules/access/access-control.interface.ts +20 -9
  281. package/src/modules/access/access-control.service.ts +240 -78
  282. package/src/modules/access/access.routes.ts +96 -14
  283. package/src/modules/access-model/__tests__/access-grammar.test.ts +18 -0
  284. package/src/modules/access-model/__tests__/plugin-token-canonical.test.ts +40 -0
  285. package/src/modules/access-model/access-grammar.ts +114 -2
  286. package/src/modules/access-model/group-files.ts +4 -0
  287. package/src/modules/access-model/plugin-principals.ts +137 -0
  288. package/src/modules/database/core-schema.ts +54 -0
  289. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +297 -0
  290. package/src/modules/kb-sync/__tests__/kb-sync.service.test.ts +295 -0
  291. package/src/modules/kb-sync/__tests__/sync-auth.test.ts +118 -0
  292. package/src/modules/kb-sync/__tests__/sync-payload.test.ts +148 -0
  293. package/src/modules/kb-sync/index.ts +19 -0
  294. package/src/modules/kb-sync/kb-sync.interface.ts +79 -0
  295. package/src/modules/kb-sync/kb-sync.routes.ts +197 -0
  296. package/src/modules/kb-sync/kb-sync.service.ts +201 -0
  297. package/src/modules/kb-sync/sync-auth.ts +118 -0
  298. package/src/modules/kb-sync/sync-payload.ts +143 -0
  299. package/src/modules/marketplace/__tests__/github-facade.test.ts +443 -0
  300. package/src/modules/marketplace/__tests__/marketplace-git.test.ts +179 -0
  301. package/src/modules/marketplace/git-http.routes.ts +193 -0
  302. package/src/modules/marketplace/github-facade/github-facade-admin.routes.ts +78 -0
  303. package/src/modules/marketplace/github-facade/github-facade-codes.store.ts +145 -0
  304. package/src/modules/marketplace/github-facade/github-facade-credentials.service.ts +208 -0
  305. package/src/modules/marketplace/github-facade/github-facade.routes.ts +268 -0
  306. package/src/modules/marketplace/github-facade/github-facade.service.ts +221 -0
  307. package/src/modules/marketplace/github-facade/index.ts +25 -0
  308. package/src/modules/marketplace/index.ts +3 -0
  309. package/src/modules/marketplace/marketplace-repo.service.ts +337 -0
  310. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -0
  311. package/src/modules/mcp/oauth/oauth-consent.routes.ts +24 -2
  312. package/src/modules/mcp/oauth/oauth-state.ts +15 -3
  313. package/src/modules/plugins/__tests__/bundle-dialect.e2e.test.ts +163 -0
  314. package/src/modules/plugins/__tests__/compile-marketplace.test.ts +294 -0
  315. package/src/modules/plugins/__tests__/plugin-index.service.test.ts +56 -12
  316. package/src/modules/plugins/__tests__/plugin-links.service.test.ts +287 -0
  317. package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +205 -5
  318. package/src/modules/plugins/__tests__/plugin-rename.service.test.ts +394 -0
  319. package/src/modules/plugins/__tests__/plugins.routes.test.ts +80 -33
  320. package/src/modules/plugins/compile/compile-marketplace.ts +403 -0
  321. package/src/modules/plugins/compile/marketplace-compiler.service.ts +150 -0
  322. package/src/modules/plugins/discovery/__tests__/kb-plugin-source.test.ts +329 -0
  323. package/src/modules/plugins/discovery/bundle-dialect/bundle.source.ts +140 -0
  324. package/src/modules/plugins/discovery/bundle-dialect/registry.ts +175 -0
  325. package/src/modules/plugins/discovery/kb-plugin-source.ts +114 -0
  326. package/src/modules/plugins/discovery/native.source.ts +122 -0
  327. package/src/modules/plugins/discovery/plugin-source.ts +99 -0
  328. package/src/modules/plugins/index.ts +10 -0
  329. package/src/modules/plugins/plugin-links.service.ts +363 -0
  330. package/src/modules/plugins/plugin-links.ts +184 -0
  331. package/src/modules/plugins/plugin-provision.service.ts +160 -54
  332. package/src/modules/plugins/plugin-rename.service.ts +298 -0
  333. package/src/modules/plugins/plugins.contract.ts +28 -1
  334. package/src/modules/plugins/plugins.routes.ts +128 -11
  335. package/src/modules/plugins/plugins.service.ts +106 -53
  336. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +57 -0
  337. package/src/modules/settings/__tests__/setup.routes.sync-status.test.ts +77 -0
  338. package/src/modules/settings/deployment-settings.service.ts +95 -2
  339. package/src/modules/settings/setup.routes.ts +24 -0
  340. package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +128 -0
  341. package/src/modules/skills/__tests__/skills.service.test.ts +85 -0
  342. package/src/modules/skills/index.ts +1 -0
  343. package/src/modules/skills/pending-skills.service.ts +7 -5
  344. package/src/modules/skills/skill-access-requests.routes.ts +199 -0
  345. package/src/modules/skills/skills.contract.ts +32 -0
  346. package/src/modules/skills/skills.routes.ts +59 -3
  347. package/src/modules/skills/skills.service.ts +84 -22
  348. package/src/modules/tool-auth/external-api-key.interface.ts +20 -1
  349. package/src/modules/tool-auth/external-api-key.service.ts +31 -13
  350. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +24 -1
  351. package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +2 -0
  352. package/src/modules/tool-manuals/mcp-json-discovery.ts +154 -88
  353. package/src/modules/tool-manuals/tool-manuals.service.ts +14 -23
  354. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
  355. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +1 -0
  356. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +2 -2
  357. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +119 -2
  358. package/src/modules/workflow/__tests__/workflow.service.sync.test.ts +437 -0
  359. package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +3 -3
  360. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +47 -4
  361. package/src/modules/workflow/git/__tests__/git.service.syncFromRemote.test.ts +253 -0
  362. package/src/modules/workflow/git/git.service.ts +239 -56
  363. package/src/modules/workflow/git/pull-request.service.ts +9 -0
  364. package/src/modules/workflow/workflow.service.ts +260 -9
  365. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +6 -7
  366. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +93 -0
  367. package/src/modules/workspace/__tests__/workspace.service.missing-branch.test.ts +28 -0
  368. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +42 -12
  369. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +219 -4
  370. package/src/modules/workspace/startup/steps/plugin-manifests.step.ts +129 -0
  371. package/src/modules/workspace/startup/steps/seed-tree.ts +44 -2
  372. package/src/modules/workspace/startup/steps/template-files.step.ts +131 -27
  373. package/src/modules/workspace/workspace.routes.ts +9 -17
  374. package/src/modules/workspace/workspace.service.ts +107 -16
  375. package/src/shared/__tests__/filename.test.ts +0 -0
  376. package/src/shared/__tests__/fs-walk.test.ts +22 -1
  377. package/src/shared/__tests__/kb-walk.test.ts +107 -0
  378. package/src/shared/__tests__/path-order.test.ts +24 -0
  379. package/src/shared/__tests__/ttl-cache.test.ts +55 -0
  380. package/src/shared/domain-errors.ts +29 -0
  381. package/src/shared/fs-errors.ts +10 -0
  382. package/src/shared/fs-walk.ts +38 -23
  383. package/src/shared/kb-walk.ts +95 -0
  384. package/src/shared/path-order.ts +33 -0
  385. package/src/shared/ttl-cache.ts +33 -1
@@ -4,6 +4,8 @@ import { execFile, spawn } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
  import { createHash } from 'node:crypto';
6
6
 
7
+ import { isAbsence } from '../../shared/fs-errors.js';
8
+ import { walkKb, type KbWalkListener } from '../../shared/kb-walk.js';
7
9
  import type { WorkspaceService } from '../workspace/workspace.service.js';
8
10
  import type {
9
11
  IAccessControl,
@@ -13,7 +15,11 @@ import type {
13
15
  GrantSources,
14
16
  ResolvedPrincipal,
15
17
  } from './access-control.interface.js';
18
+ import { PLUGINS_DIR, PLUGIN_MANIFEST_FILE, isPersonalPluginDir,
19
+ pluginIdentityOf,
20
+ } from '@bevel-software/platform-shared';
16
21
  import { AccessConfigError, AccessUnreadableError } from '../access-model/access-errors.js';
22
+ import { synthesizePluginPrincipals } from '../access-model/plugin-principals.js';
17
23
  import {
18
24
  GROUPS_YAML,
19
25
  SYNCED_GROUPS_YAML,
@@ -158,12 +164,6 @@ function roleKnown(roles: RolesIndex, canonicalRole: string): boolean {
158
164
  */
159
165
  export type GroupsHealth = { ok: true } | { ok: false; file: string; reason: string };
160
166
 
161
- /** True for the errno codes that mean "the file genuinely is not there". */
162
- function isAbsenceError(err: unknown): boolean {
163
- const code = (err as NodeJS.ErrnoException | null)?.code;
164
- return code === 'ENOENT' || code === 'ENOTDIR';
165
- }
166
-
167
167
  /**
168
168
  * Load the ACTIVE group source through `read` (working tree or at-ref — the
169
169
  * caller supplies the reader, so both model loaders share one mode rule):
@@ -201,7 +201,7 @@ export async function loadActiveGroups(
201
201
  // subprocess. roles.yaml and access.md already fail the build closed on
202
202
  // the same error; groups cannot be the one input that does not.
203
203
  if (err instanceof AccessUnreadableError) throw err;
204
- if (isAbsenceError(err)) {
204
+ if (isAbsence(err)) {
205
205
  syncedText = null;
206
206
  } else {
207
207
  // A non-absence read error on the SYNCED source must NOT fall back to
@@ -220,7 +220,7 @@ export async function loadActiveGroups(
220
220
  text = await read(GROUPS_YAML);
221
221
  } catch (err) {
222
222
  if (err instanceof AccessUnreadableError) throw err; // see above
223
- if (isAbsenceError(err)) text = null;
223
+ if (isAbsence(err)) text = null;
224
224
  else return broken(GROUPS_YAML, err instanceof Error ? err.message : String(err));
225
225
  }
226
226
  }
@@ -253,12 +253,22 @@ export type ScopeSource = { kind: 'own' } | { kind: 'access-md'; path: string };
253
253
  * already folded in (grant-only — see `buildScope`), so `byRole`/`byEmail`
254
254
  * hold the *effective* verdict each named principal gets at this one scope.
255
255
  *
256
+ * `byRole` and `byEmail` hold FILE-BACKED entries only — every key is a
257
+ * token some line in the file spells, which is what makes them the source
258
+ * of "what can be removed here" (`grantSources`). `everyone` is the one
259
+ * DERIVED verdict: what an unnamed signed-in caller gets at this scope. It
260
+ * is the literal `everyone` entry's state, promoted to a grant when the
261
+ * scope grants a PUBLIC plugin principal (one whose plugin admits everyone,
262
+ * so everyone holds it). Kept apart from `byRole` on purpose: a synthetic
263
+ * `everyone` key there would read as a line to revoke that no file has.
264
+ *
256
265
  * `source` identifies the file the scope's rules come from (see `ScopeSource`),
257
266
  * so a caller can map a per-scope verdict back to the editable file.
258
267
  */
259
268
  interface AccessScope {
260
269
  byRole: Map<string, GrantState>;
261
270
  byEmail: Map<string, GrantState>;
271
+ everyone: GrantState | undefined;
262
272
  source: ScopeSource;
263
273
  }
264
274
 
@@ -273,6 +283,67 @@ interface AccessScope {
273
283
  * `hasPermissionResolved`). `collapseScopes` flattens this into the
274
284
  * closest-wins-per-principal view the display helpers use.
275
285
  */
286
+ /**
287
+ * The rules listener on the one walk of the checkout: every `access.md` at
288
+ * any depth (the access tree is structure-agnostic — the root's included),
289
+ * plus every plugin manifest under the plugins root, for principal
290
+ * synthesis. A reader: a folder the walk could not list is simply not in
291
+ * the model (a writer that needs completeness asks the walk for its holes).
292
+ *
293
+ * Per-file failures (malformed YAML, bad role refs, unknown verbs) are
294
+ * logged and the offending file is dropped from the model — they do NOT
295
+ * throw. A typo in one nested access.md must not 500 the entire editor;
296
+ * admins can still write `access.md` / `roles.yaml` because
297
+ * `hasPermissionResolved` admin-rescues those paths, so the bad config
298
+ * remains fixable from inside the app.
299
+ */
300
+ function accessFileListener(
301
+ repoDir: string,
302
+ out: Map<string, AccessFile>,
303
+ pluginDirs: Map<string, string>,
304
+ ): KbWalkListener {
305
+ return {
306
+ async onFile(dir, name) {
307
+ const rel = dir ? `${dir}/${name}` : name;
308
+ if (name === PLUGIN_MANIFEST_FILE && dir.startsWith(`${PLUGINS_DIR}/`)) {
309
+ const text = await fs.readFile(path.join(repoDir, rel), 'utf-8').catch(() => null);
310
+ pluginDirs.set(dir, pluginIdentityOf(parseManifestText(text), path.posix.basename(dir)));
311
+ return;
312
+ }
313
+ if (name !== 'access.md') return;
314
+ const text = await fs.readFile(path.join(repoDir, rel), 'utf-8');
315
+ const parsed = parseAccessFile(text, rel);
316
+ if (!parsed.ok) {
317
+ // Treat as if the file didn't exist for resolution purposes.
318
+ // Admin-rescue on access.md paths still lets an admin fix it.
319
+ for (const e of parsed.errors) console.warn(`[access] ${e} — file ignored`);
320
+ return;
321
+ }
322
+ for (const w of parsed.warnings) console.warn(`[access] ${w}`);
323
+ if (out.has(parsed.file.dir)) {
324
+ console.warn(
325
+ `[access] ${rel}: duplicate access.md for directory '${parsed.file.dir}' — keeping the first one seen`,
326
+ );
327
+ return;
328
+ }
329
+ out.set(parsed.file.dir, parsed.file);
330
+ },
331
+ };
332
+ }
333
+
334
+ /** A manifest's parsed object, or null for absent, unparsable or not-an-object text. */
335
+ function parseManifestText(text: string | null): Record<string, unknown> | null {
336
+ if (text === null) return null;
337
+ try {
338
+ const parsed: unknown = JSON.parse(text);
339
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
340
+ ? (parsed as Record<string, unknown>)
341
+ : null;
342
+ } catch {
343
+ return null;
344
+ }
345
+ }
346
+
276
347
  function resolveScopes(
277
348
  model: AccessModel,
278
349
  verb: Verb,
@@ -311,7 +382,29 @@ function resolveScopes(
311
382
  set(entry, 'grant');
312
383
  }
313
384
  }
314
- return { byRole, byEmail, source };
385
+ // The anonymous caller's verdict at this scope — what someone who holds
386
+ // exactly the PUBLIC keys (a plugin principal whose plugin admits
387
+ // everyone, so everyone holds it) resolves to, by the same tiers a named
388
+ // caller gets: a grant via any public key wins, else a denial via one
389
+ // denies, else the built-in `everyone` entry decides. Derived HERE, where
390
+ // a scope's verdicts are built, so every reader of it — the permission
391
+ // check, the eligible lists, the `restricted` flag, the compiler's
392
+ // everyone audience — sees one truth; and derived into its own field,
393
+ // never into `byRole`, so the entries a person can remove stay exactly
394
+ // the lines the files hold.
395
+ let publicGrant = false;
396
+ let publicDeny = false;
397
+ for (const [key, state] of byRole) {
398
+ if (!model.roles.publicKeys?.has(key)) continue;
399
+ if (state === 'grant') publicGrant = true;
400
+ else if (state === 'denied') publicDeny = true;
401
+ }
402
+ const everyone: GrantState | undefined = publicGrant
403
+ ? 'grant'
404
+ : publicDeny
405
+ ? 'denied'
406
+ : byRole.get(EVERYONE_CANONICAL);
407
+ return { byRole, byEmail, everyone, source };
315
408
  };
316
409
 
317
410
  const scopes: AccessScope[] = [];
@@ -328,7 +421,7 @@ function resolveScopes(
328
421
 
329
422
  /** The collapsed closest-wins view: per-principal verdicts with no single
330
423
  * source (it's a flattening across scopes). */
331
- type CollapsedScope = Pick<AccessScope, 'byRole' | 'byEmail'>;
424
+ type CollapsedScope = Pick<AccessScope, 'byRole' | 'byEmail' | 'everyone'>;
332
425
 
333
426
  /**
334
427
  * Flatten ordered scopes (closest→farthest) into a single closest-wins
@@ -339,11 +432,13 @@ type CollapsedScope = Pick<AccessScope, 'byRole' | 'byEmail'>;
339
432
  function collapseScopes(scopes: AccessScope[]): CollapsedScope {
340
433
  const byRole = new Map<string, GrantState>();
341
434
  const byEmail = new Map<string, GrantState>();
435
+ let everyone: GrantState | undefined;
342
436
  for (let i = scopes.length - 1; i >= 0; i--) {
343
437
  for (const [k, v] of scopes[i].byRole) byRole.set(k, v);
344
438
  for (const [k, v] of scopes[i].byEmail) byEmail.set(k, v);
439
+ if (scopes[i].everyone !== undefined) everyone = scopes[i].everyone;
345
440
  }
346
- return { byRole, byEmail };
441
+ return { byRole, byEmail, everyone };
347
442
  }
348
443
 
349
444
  function resolveAtPath(
@@ -355,6 +450,19 @@ function resolveAtPath(
355
450
  return collapseScopes(resolveScopes(model, verb, relativePath, fileOwn));
356
451
  }
357
452
 
453
+ /**
454
+ * Every principal key a caller holds: the tokens their email is a member of,
455
+ * plus the PUBLIC keys every caller holds regardless (a plugin principal
456
+ * whose plugin grants `everyone`). Undefined when there is nothing at all,
457
+ * matching the plain `byEmail.get` the tier-2 check used to read.
458
+ */
459
+ function principalKeysOf(model: AccessModel, email: string): Set<string> | undefined {
460
+ const own = model.roles.byEmail.get(email);
461
+ const pub = model.roles.publicKeys;
462
+ if (!pub?.size) return own;
463
+ return new Set([...(own ?? []), ...pub]);
464
+ }
465
+
358
466
  function isAdminEmail(model: AccessModel, email: string): boolean {
359
467
  if (model.deploymentOwners.has(email)) return true;
360
468
  const roles = model.roles.byEmail.get(email);
@@ -411,7 +519,7 @@ function hasPermissionResolved(
411
519
  if (isAccessMdPath(relativePath) && isAdminEmail(model, email)) return true;
412
520
  }
413
521
 
414
- const userRoles = model.roles.byEmail.get(email);
522
+ const userRoles = principalKeysOf(model, email);
415
523
  const scopes = resolveScopes(model, verb, relativePath, fileOwn);
416
524
 
417
525
  for (const scope of scopes) {
@@ -434,9 +542,9 @@ function hasPermissionResolved(
434
542
  if (deny) return false;
435
543
  }
436
544
 
437
- // Tier 3 — the built-in `everyone` role.
438
- const everyone = scope.byRole.get(EVERYONE_CANONICAL);
439
- if (everyone) return everyone === 'grant';
545
+ // Tier 3 — the built-in `everyone` role (derived: a public plugin
546
+ // principal granted here counts, see `AccessScope.everyone`).
547
+ if (scope.everyone) return scope.everyone === 'grant';
440
548
 
441
549
  // No verdict at this scope — fall through to the next (farther) one.
442
550
  }
@@ -600,11 +708,11 @@ function canEveryoneReadResolved(
600
708
  relativePath: string,
601
709
  fileOwn?: OwnEntries | null,
602
710
  ): boolean {
603
- const { byRole, byEmail } = resolveAtPath(model, 'read', relativePath, fileOwn);
711
+ const { byEmail, everyone } = resolveAtPath(model, 'read', relativePath, fileOwn);
604
712
  // Baseline: an unnamed signed-in user (no email/role entries) reads only via
605
713
  // `everyone`. As a single principal, its collapsed verdict is its closest —
606
714
  // exactly what that user resolves to.
607
- if (byRole.get(EVERYONE_CANONICAL) !== 'grant') return false;
715
+ if (everyone !== 'grant') return false;
608
716
  // Every *named* principal must also still read. A collapsed deny may have
609
717
  // been shadowed by a closer-scope `everyone` grant, so re-resolve each
610
718
  // candidate through the closeness-first gate rather than trusting the
@@ -645,7 +753,7 @@ function eligibleHoldersResolved(
645
753
  relativePath: string,
646
754
  fileOwn?: OwnEntries | null,
647
755
  ): { principals: ResolvedPrincipal[]; roles: string[]; users: { name: string; email: string }[] } {
648
- const { byRole, byEmail } = resolveAtPath(model, verb, relativePath, fileOwn);
756
+ const { byRole, byEmail, everyone } = resolveAtPath(model, verb, relativePath, fileOwn);
649
757
 
650
758
  // (kind, display name) → one entry. The `byRole` keys are the merged
651
759
  // index's canonical tokens exactly as granted (bare, or the
@@ -659,7 +767,7 @@ function eligibleHoldersResolved(
659
767
  // principals, and collapsing them to one would hide the role's live
660
768
  // `role/<name>` grant from every consumer of the eligible list.
661
769
  const byIdentity = new Map<string, ResolvedPrincipal>();
662
- const addPrincipal = (name: string, kind: 'role' | 'group') => {
770
+ const addPrincipal = (name: string, kind: ResolvedPrincipal['kind']) => {
663
771
  const key = `${kind}\0${name.toLowerCase()}`;
664
772
  if (!byIdentity.has(key)) byIdentity.set(key, { name, kind });
665
773
  };
@@ -668,6 +776,11 @@ function eligibleHoldersResolved(
668
776
  const record = model.roles.byCanonical.get(canonical);
669
777
  addPrincipal(record ? record.displayName : canonical, record?.kind ?? 'role');
670
778
  }
779
+ // The derived everyone verdict: a public plugin principal granted here
780
+ // means anyone signed in holds the verb, and a list that counts holders (an
781
+ // approval gate, a "restricted to" banner) must say so — as `everyone`, the
782
+ // same row a literal grant produces.
783
+ if (everyone === 'grant') addPrincipal(EVERYONE_CANONICAL, 'role');
671
784
 
672
785
  // Mirror the admin overrides applied in `hasPermissionResolved`: write on
673
786
  // `roles.yaml` and on any `access.md` is granted to Admin even if the
@@ -871,8 +984,31 @@ export class AccessControlService implements IAccessControl {
871
984
  invalidate(workspaceId: string): void {
872
985
  this.cache.delete(workspaceId);
873
986
  this.ownEntriesCache.delete(workspaceId);
987
+ this.generation++;
874
988
  }
875
989
 
990
+ /**
991
+ * Bumped by every `invalidate()`, for any workspace. A model load reads the
992
+ * tree across many awaits, and an invalidation fires exactly when that tree
993
+ * changes underneath it: the load that started on the old tree would
994
+ * otherwise store its model AFTER the drop, and every read for the next TTL
995
+ * would resolve against a tree that is already gone. `loadModel` takes the
996
+ * generation before its first read and refuses to store a model fetched
997
+ * under an older one — the caller that started it still gets its answer,
998
+ * as old as the moment it started, and the next caller reloads.
999
+ *
1000
+ * ONE counter, not one per workspace: a per-workspace map would keep an
1001
+ * entry for every branch workspace the process ever loaded, with nothing to
1002
+ * tell it when a workspace is gone. The price of sharing is that a drop on
1003
+ * some other workspace, landing mid-load, costs this one a single reload —
1004
+ * a load is tens of milliseconds and a drop is a commit, so that is rare
1005
+ * and cheap, and it never stores a wrong model. (The own-entries memo needs
1006
+ * no token: it is keyed by the map object a reader took BEFORE its disk
1007
+ * read, and `invalidate()` replaces the map, so a late store lands in an
1008
+ * orphan nobody consults.)
1009
+ */
1010
+ private generation = 0;
1011
+
876
1012
  /** Memoized `readOwnEntries` for the batch paths; see `ownEntriesCache`. */
877
1013
  private async cachedOwnEntries(
878
1014
  workspaceId: string,
@@ -1031,17 +1167,30 @@ export class AccessControlService implements IAccessControl {
1031
1167
  principals: ResolvedPrincipal[];
1032
1168
  roles: string[];
1033
1169
  users: { name: string; email: string }[];
1170
+ publicVia: string[];
1034
1171
  }> {
1035
1172
  const model = await this.loadModel(workspaceId);
1036
1173
  const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
1037
- // When `read: everyone` applies cleanly, the node is readable by all users
1038
- // and the role/user lists are meaningless. Otherwise return the explicit
1039
- // reader set; it may be empty for a default-denied path with no grants.
1040
- if (canEveryoneReadResolved(model, relativePath, own)) {
1041
- return { restricted: false, principals: [], roles: [], users: [] };
1042
- }
1174
+ // `restricted` is the verdict — whether `read: everyone` applies cleanly —
1175
+ // and the lists are the GRANTS that exist regardless: on a public node
1176
+ // they name what makes it public (`everyone`, and a public plugin
1177
+ // principal whose grant is the one to remove), so the share dialog can
1178
+ // show that grant as a row. Consumers that only want to know whether the
1179
+ // node is public read the flag; the lists may be empty for a
1180
+ // default-denied path with no grants.
1043
1181
  const { principals, roles, users } = eligibleHoldersResolved(model, 'read', relativePath, own);
1044
- return { restricted: true, principals, roles, users };
1182
+ // WHY it is public, when it is: the public plugin principals granted read
1183
+ // here — the one thing the dialog cannot tell from the lists alone (a
1184
+ // plugin principal in them may or may not be public), so the resolver,
1185
+ // which knows, says it. A literal `everyone` line shows up as a source
1186
+ // of the `everyone` row instead; together the two answer "what remains
1187
+ // public after this grant is removed".
1188
+ const collapsed = resolveAtPath(model, 'read', relativePath, own);
1189
+ const publicVia = [...collapsed.byRole]
1190
+ .filter(([key, state]) => state === 'grant' && model.roles.publicKeys?.has(key))
1191
+ .map(([key]) => key)
1192
+ .sort();
1193
+ return { restricted: !canEveryoneReadResolved(model, relativePath, own), principals, roles, users, publicVia };
1045
1194
  }
1046
1195
 
1047
1196
  async eligibleDownloaders(
@@ -1106,14 +1255,17 @@ export class AccessControlService implements IAccessControl {
1106
1255
  return out;
1107
1256
  }
1108
1257
 
1109
- async kbPrincipals(
1110
- workspaceId: string,
1111
- ): Promise<{ roles: string[]; groups: string[]; people: { name: string; email: string }[] }> {
1258
+ async kbPrincipals(workspaceId: string): Promise<{
1259
+ roles: string[];
1260
+ groups: string[];
1261
+ plugins: { name: string; folder: string }[];
1262
+ people: { name: string; email: string }[];
1263
+ }> {
1112
1264
  let model: AccessModel;
1113
1265
  try {
1114
1266
  model = await this.loadModel(workspaceId);
1115
1267
  } catch {
1116
- return { roles: [], groups: [], people: [] };
1268
+ return { roles: [], groups: [], plugins: [], people: [] };
1117
1269
  }
1118
1270
  // Roles = the built-in `everyone` role plus every declared role's display
1119
1271
  // name — ROLE principals only, never groups. Each role is enumerated via
@@ -1125,9 +1277,22 @@ export class AccessControlService implements IAccessControl {
1125
1277
  // direct-access.md edit).
1126
1278
  const roles = [EVERYONE_DISPLAY];
1127
1279
  const groups: string[] = [];
1280
+ // Plugins = the identities behind the synthesised `plugin/<name>/<verb>`
1281
+ // principals, once each (three keys share a folder). Personal shelves are
1282
+ // plugins to the resolver but not to a person picking a grantee — told
1283
+ // apart by their FOLDER (the one structural rule), never by their name.
1284
+ const plugins = new Map<string, string>();
1128
1285
  for (const [key, principal] of model.roles.byCanonical) {
1129
1286
  if (key.startsWith(ROLE_TOKEN_PREFIX)) roles.push(principal.displayName);
1130
1287
  else if (principal.kind === 'group') groups.push(principal.displayName);
1288
+ else if (
1289
+ principal.kind === 'plugin' &&
1290
+ principal.pluginName &&
1291
+ principal.pluginDir &&
1292
+ !isPersonalPluginDir(principal.pluginDir)
1293
+ ) {
1294
+ plugins.set(principal.pluginName, principal.pluginDir);
1295
+ }
1131
1296
  }
1132
1297
  // People = roles.yaml member emails (name-less) ∪ access.md `Name <email>`
1133
1298
  // grants (named). The login-only users table is unioned in by the caller.
@@ -1148,7 +1313,14 @@ export class AccessControlService implements IAccessControl {
1148
1313
  name: name || email.split('@')[0],
1149
1314
  email,
1150
1315
  }));
1151
- return { roles, groups, people };
1316
+ return {
1317
+ roles,
1318
+ groups,
1319
+ plugins: [...plugins.entries()]
1320
+ .map(([name, folder]) => ({ name, folder }))
1321
+ .sort((a, b) => a.name.localeCompare(b.name)),
1322
+ people,
1323
+ };
1152
1324
  }
1153
1325
 
1154
1326
  async findEmailByHash(
@@ -1347,6 +1519,8 @@ export class AccessControlService implements IAccessControl {
1347
1519
  if (cached && Date.now() - cached.loadedAt < AccessControlService.CACHE_TTL_MS) {
1348
1520
  return cached.model;
1349
1521
  }
1522
+ // Taken before the first read; see `generation`.
1523
+ const generation = this.generation;
1350
1524
 
1351
1525
  const repoDir = await this.repoDir(workspaceId);
1352
1526
 
@@ -1404,7 +1578,15 @@ export class AccessControlService implements IAccessControl {
1404
1578
  // must not 500 the entire editor; admins can still write `access.md`
1405
1579
  // / `roles.yaml` because `hasPermissionResolved` admin-rescues those
1406
1580
  // paths, so the bad config remains fixable from inside the app.
1407
- await this.collectAccessFiles(repoDir, '', accessFiles);
1581
+ const pluginDirs = new Map<string, string>();
1582
+ await walkKb(repoDir, [accessFileListener(repoDir, accessFiles, pluginDirs)]);
1583
+
1584
+ // Plugin principals (`plugin/<Name>/<verb>`), derived from each plugin
1585
+ // folder's own access.md — a plugin being a folder with a manifest, at
1586
+ // any depth — after groups merged (so their rosters expand through the
1587
+ // finished index) and before the unknown-role sweep below (so a grant
1588
+ // naming one counts as known).
1589
+ synthesizePluginPrincipals(rolesParsed.index, accessFiles, pluginDirs);
1408
1590
 
1409
1591
  // Validate role refs against roles.yaml. `everyone` is a built-in role and
1410
1592
  // is valid without a roles.yaml entry. Unknown refs are dropped from the
@@ -1430,50 +1612,12 @@ export class AccessControlService implements IAccessControl {
1430
1612
  groupsHealth: activeGroups.health,
1431
1613
  deploymentOwners: this.deploymentOwners,
1432
1614
  };
1433
- this.cache.set(workspaceId, { model, loadedAt: Date.now() });
1434
- return model;
1435
- }
1436
-
1437
- private async collectAccessFiles(
1438
- absDir: string,
1439
- relDir: string,
1440
- out: Map<string, AccessFile>,
1441
- ): Promise<void> {
1442
- let entries;
1443
- try {
1444
- entries = await fs.readdir(absDir, { withFileTypes: true });
1445
- } catch {
1446
- return;
1447
- }
1448
- for (const entry of entries) {
1449
- // Skip VCS metadata + vendored deps. Hidden dirs (`.git`, `.vscode`,
1450
- // etc.) and `node_modules` can't host KB rules and are often huge —
1451
- // walking them would slow every cache miss without benefit.
1452
- if (entry.name.startsWith('.')) continue;
1453
- if (entry.name === 'node_modules') continue;
1454
- const abs = path.join(absDir, entry.name);
1455
- const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
1456
- if (entry.isDirectory()) {
1457
- await this.collectAccessFiles(abs, rel, out);
1458
- } else if (entry.isFile() && entry.name === 'access.md') {
1459
- const text = await fs.readFile(abs, 'utf-8');
1460
- const parsed = parseAccessFile(text, rel);
1461
- if (!parsed.ok) {
1462
- // Treat as if the file didn't exist for resolution purposes.
1463
- // Admin-rescue on access.md paths still lets an admin fix it.
1464
- for (const e of parsed.errors) console.warn(`[access] ${e} — file ignored`);
1465
- continue;
1466
- }
1467
- for (const w of parsed.warnings) console.warn(`[access] ${w}`);
1468
- if (out.has(parsed.file.dir)) {
1469
- console.warn(
1470
- `[access] ${rel}: duplicate access.md for directory '${parsed.file.dir}' — keeping the first one seen`,
1471
- );
1472
- continue;
1473
- }
1474
- out.set(parsed.file.dir, parsed.file);
1475
- }
1615
+ // An invalidation landed mid-load: this model describes a tree that is
1616
+ // already gone, so it is returned to this caller but never stored.
1617
+ if (this.generation === generation) {
1618
+ this.cache.set(workspaceId, { model, loadedAt: Date.now() });
1476
1619
  }
1620
+ return model;
1477
1621
  }
1478
1622
 
1479
1623
  private async repoDir(workspaceId: string): Promise<string> {
@@ -1614,7 +1758,10 @@ export class AccessControlService implements IAccessControl {
1614
1758
  * `'error'` when git failed: a model built without its access files is
1615
1759
  * not the model, and the caller must not cache it as one.
1616
1760
  */
1617
- private async listAccessFilesAtRef(repoDir: string, ref: string): Promise<string[] | 'error'> {
1761
+ private async listAccessFilesAtRef(
1762
+ repoDir: string,
1763
+ ref: string,
1764
+ ): Promise<{ accessFiles: string[]; pluginDirs: string[] } | 'error'> {
1618
1765
  try {
1619
1766
  const { stdout } = await execFileAsync(
1620
1767
  'git',
@@ -1622,12 +1769,16 @@ export class AccessControlService implements IAccessControl {
1622
1769
  { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 },
1623
1770
  );
1624
1771
  const out: string[] = [];
1772
+ const pluginDirs: string[] = [];
1625
1773
  for (const line of stdout.split('\n')) {
1626
1774
  const p = line.trim();
1627
1775
  if (!p) continue;
1628
1776
  if (p === 'access.md' || p.endsWith('/access.md')) out.push(p);
1777
+ else if (p.startsWith(`${PLUGINS_DIR}/`) && p.endsWith(`/${PLUGIN_MANIFEST_FILE}`)) {
1778
+ pluginDirs.push(p.slice(0, -(PLUGIN_MANIFEST_FILE.length + 1)));
1779
+ }
1629
1780
  }
1630
- return out;
1781
+ return { accessFiles: out, pluginDirs };
1631
1782
  } catch {
1632
1783
  return 'error';
1633
1784
  }
@@ -1781,7 +1932,7 @@ export class AccessControlService implements IAccessControl {
1781
1932
  console.warn(`[access@${tag}] listing access.md files failed; refusing to decide from a partial tree`);
1782
1933
  throw new AccessUnreadableError(label, 'access.md (ls-tree)');
1783
1934
  }
1784
- for (const p of listed) {
1935
+ for (const p of listed.accessFiles) {
1785
1936
  const text = await read(p);
1786
1937
  if (text === null) continue;
1787
1938
  const parsed = parseAccessFile(text, p);
@@ -1792,6 +1943,17 @@ export class AccessControlService implements IAccessControl {
1792
1943
  accessFiles.set(parsed.file.dir, parsed.file);
1793
1944
  }
1794
1945
 
1946
+ // Mirror `loadModel`: plugin principals from the plugins the ref carries,
1947
+ // each under the identity its manifest AT THAT REF declares, so a PR-time
1948
+ // verdict on a skill granted to `plugin/<name>/write` counts the plugin's
1949
+ // writers exactly as the working tree would.
1950
+ const pluginIdentities = new Map<string, string>();
1951
+ for (const dir of listed.pluginDirs) {
1952
+ const manifest = parseManifestText(await read(`${dir}/${PLUGIN_MANIFEST_FILE}`));
1953
+ pluginIdentities.set(dir, pluginIdentityOf(manifest, path.posix.basename(dir)));
1954
+ }
1955
+ synthesizePluginPrincipals(rolesParsed.index, accessFiles, pluginIdentities);
1956
+
1795
1957
  // Mirror `loadModel`: drop entries whose role ref is unknown to roles.yaml,
1796
1958
  // while preserving the built-in `everyone` role.
1797
1959
  for (const [, file] of accessFiles) {