@bevel-software/platform-core-backend 0.29.0 → 0.31.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 (307) hide show
  1. package/agent-guide/access-control.md +49 -4
  2. package/agent-guide/html-views.md +76 -2
  3. package/dist/__tests__/test-repositories.d.ts +3 -0
  4. package/dist/__tests__/test-repositories.d.ts.map +1 -0
  5. package/dist/__tests__/test-repositories.js +18 -0
  6. package/dist/__tests__/test-repositories.js.map +1 -0
  7. package/dist/core/catalog-revision.js +3 -3
  8. package/dist/core/create-core-server.d.ts.map +1 -1
  9. package/dist/core/create-core-server.js +3 -2
  10. package/dist/core/create-core-server.js.map +1 -1
  11. package/dist/core/create-core-services.d.ts +3 -0
  12. package/dist/core/create-core-services.d.ts.map +1 -1
  13. package/dist/core/create-core-services.js +35 -5
  14. package/dist/core/create-core-services.js.map +1 -1
  15. package/dist/core/lifecycle.d.ts +8 -0
  16. package/dist/core/lifecycle.d.ts.map +1 -1
  17. package/dist/core/lifecycle.js +4 -0
  18. package/dist/core/lifecycle.js.map +1 -1
  19. package/dist/modules/access/access-control.interface.d.ts +20 -0
  20. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  21. package/dist/modules/access/access-control.service.d.ts +32 -1
  22. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  23. package/dist/modules/access/access-control.service.js +135 -17
  24. package/dist/modules/access/access-control.service.js.map +1 -1
  25. package/dist/modules/access/access.routes.d.ts.map +1 -1
  26. package/dist/modules/access/access.routes.js +20 -6
  27. package/dist/modules/access/access.routes.js.map +1 -1
  28. package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
  29. package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
  30. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  31. package/dist/modules/audit/agent-audit.service.js +66 -6
  32. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  33. package/dist/modules/audit/audit.contract.d.ts +52 -8
  34. package/dist/modules/audit/audit.contract.d.ts.map +1 -1
  35. package/dist/modules/audit/audit.contract.js +37 -1
  36. package/dist/modules/audit/audit.contract.js.map +1 -1
  37. package/dist/modules/audit/audit.routes.d.ts +1 -1
  38. package/dist/modules/audit/audit.routes.d.ts.map +1 -1
  39. package/dist/modules/audit/audit.routes.js +15 -13
  40. package/dist/modules/audit/audit.routes.js.map +1 -1
  41. package/dist/modules/audit/request-audit.d.ts +21 -10
  42. package/dist/modules/audit/request-audit.d.ts.map +1 -1
  43. package/dist/modules/audit/request-audit.js +26 -10
  44. package/dist/modules/audit/request-audit.js.map +1 -1
  45. package/dist/modules/database/core-schema.d.ts +22 -3
  46. package/dist/modules/database/core-schema.d.ts.map +1 -1
  47. package/dist/modules/database/core-schema.js +15 -6
  48. package/dist/modules/database/core-schema.js.map +1 -1
  49. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  50. package/dist/modules/diff/diff.service.js +7 -1
  51. package/dist/modules/diff/diff.service.js.map +1 -1
  52. package/dist/modules/kb-fs/git-dir.d.ts +21 -0
  53. package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
  54. package/dist/modules/kb-fs/git-dir.js +58 -0
  55. package/dist/modules/kb-fs/git-dir.js.map +1 -0
  56. package/dist/modules/kb-fs/index.d.ts +3 -0
  57. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  58. package/dist/modules/kb-fs/index.js +3 -1
  59. package/dist/modules/kb-fs/index.js.map +1 -1
  60. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  61. package/dist/modules/kb-fs/locking-filesystem.js +3 -12
  62. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  63. package/dist/modules/kb-fs/mutex.d.ts +11 -0
  64. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  65. package/dist/modules/kb-fs/mutex.js +14 -0
  66. package/dist/modules/kb-fs/mutex.js.map +1 -1
  67. package/dist/modules/kb-fs/ref-files.d.ts +63 -0
  68. package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
  69. package/dist/modules/kb-fs/ref-files.js +249 -0
  70. package/dist/modules/kb-fs/ref-files.js.map +1 -0
  71. package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
  72. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  73. package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
  74. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  75. package/dist/modules/mcp/mcp.routes.d.ts +5 -0
  76. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  77. package/dist/modules/mcp/mcp.routes.js +67 -0
  78. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  79. package/dist/modules/mcp/mcp.service.d.ts +107 -27
  80. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  81. package/dist/modules/mcp/mcp.service.js +257 -50
  82. package/dist/modules/mcp/mcp.service.js.map +1 -1
  83. package/dist/modules/mcp/request-context.d.ts +25 -0
  84. package/dist/modules/mcp/request-context.d.ts.map +1 -0
  85. package/dist/modules/mcp/request-context.js +11 -0
  86. package/dist/modules/mcp/request-context.js.map +1 -0
  87. package/dist/modules/mcp/shared-definitions.d.ts +56 -0
  88. package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
  89. package/dist/modules/mcp/shared-definitions.js +117 -0
  90. package/dist/modules/mcp/shared-definitions.js.map +1 -0
  91. package/dist/modules/mcp/surface-cache.d.ts +70 -0
  92. package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
  93. package/dist/modules/mcp/surface-cache.js +104 -0
  94. package/dist/modules/mcp/surface-cache.js.map +1 -0
  95. package/dist/modules/plugins/teams.routes.d.ts +4 -0
  96. package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
  97. package/dist/modules/plugins/teams.routes.js +9 -1
  98. package/dist/modules/plugins/teams.routes.js.map +1 -1
  99. package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
  100. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
  101. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  102. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
  103. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  104. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  105. package/dist/modules/tool-helpers/tool-context.js +33 -12
  106. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  107. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
  108. package/dist/modules/tool-helpers/tool.contract.d.ts +10 -2
  109. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  110. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  111. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
  112. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
  113. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
  114. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
  115. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  116. package/dist/modules/workflow/agent-tools/workflow.tools.js +453 -2
  117. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  118. package/dist/modules/workflow/git/git.service.d.ts +142 -4
  119. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  120. package/dist/modules/workflow/git/git.service.js +645 -128
  121. package/dist/modules/workflow/git/git.service.js.map +1 -1
  122. package/dist/modules/workflow/read-on-branch.d.ts +35 -0
  123. package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
  124. package/dist/modules/workflow/read-on-branch.js +58 -0
  125. package/dist/modules/workflow/read-on-branch.js.map +1 -0
  126. package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
  127. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  128. package/dist/modules/workflow/workflow.routes.js +22 -41
  129. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  130. package/dist/modules/workflow/workflow.service.d.ts +48 -8
  131. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  132. package/dist/modules/workflow/workflow.service.js +163 -16
  133. package/dist/modules/workflow/workflow.service.js.map +1 -1
  134. package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
  135. package/dist/modules/workspace/agent-access.gate.js +2 -2
  136. package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
  137. package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
  138. package/dist/modules/workspace/bounded-matcher.js +261 -0
  139. package/dist/modules/workspace/bounded-matcher.js.map +1 -0
  140. package/dist/modules/workspace/fetch-instant.d.ts +9 -0
  141. package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
  142. package/dist/modules/workspace/fetch-instant.js +13 -0
  143. package/dist/modules/workspace/fetch-instant.js.map +1 -0
  144. package/dist/modules/workspace/index.d.ts +9 -0
  145. package/dist/modules/workspace/index.d.ts.map +1 -1
  146. package/dist/modules/workspace/index.js +10 -0
  147. package/dist/modules/workspace/index.js.map +1 -1
  148. package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
  149. package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
  150. package/dist/modules/workspace/kb-repository.contract.js +19 -0
  151. package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
  152. package/dist/modules/workspace/kb-repository.d.ts +236 -0
  153. package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
  154. package/dist/modules/workspace/kb-repository.js +811 -0
  155. package/dist/modules/workspace/kb-repository.js.map +1 -0
  156. package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
  157. package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
  158. package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
  159. package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
  160. package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
  161. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  162. package/dist/modules/workspace/routine-write-policy.js +0 -5
  163. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  164. package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
  165. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
  166. package/dist/modules/workspace/set-aside-clone.js +139 -0
  167. package/dist/modules/workspace/set-aside-clone.js.map +1 -1
  168. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
  169. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  170. package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
  171. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  172. package/dist/modules/workspace/workspace.service.d.ts +68 -32
  173. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  174. package/dist/modules/workspace/workspace.service.js +240 -161
  175. package/dist/modules/workspace/workspace.service.js.map +1 -1
  176. package/dist/modules/workspace/workspace.tools.d.ts +13 -4
  177. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  178. package/dist/modules/workspace/workspace.tools.js +162 -336
  179. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  180. package/dist/shared/git.contract.d.ts +7 -0
  181. package/dist/shared/git.contract.d.ts.map +1 -1
  182. package/dist/shared/git.contract.js +9 -0
  183. package/dist/shared/git.contract.js.map +1 -1
  184. package/dist/shared/no-link-below.d.ts +10 -0
  185. package/dist/shared/no-link-below.d.ts.map +1 -0
  186. package/dist/shared/no-link-below.js +30 -0
  187. package/dist/shared/no-link-below.js.map +1 -0
  188. package/dist/test-setup.js +4 -0
  189. package/dist/test-setup.js.map +1 -1
  190. package/migrations/0018_agent_events_page.sql +11 -0
  191. package/migrations/meta/0018_snapshot.json +2429 -0
  192. package/migrations/meta/_journal.json +8 -1
  193. package/package.json +3 -3
  194. package/src/__tests__/test-repositories.ts +22 -0
  195. package/src/__tests__/workspace-service.ts +69 -0
  196. package/src/core/__tests__/lifecycle.test.ts +16 -0
  197. package/src/core/catalog-revision.ts +3 -3
  198. package/src/core/create-core-server.ts +3 -2
  199. package/src/core/create-core-services.ts +39 -4
  200. package/src/core/lifecycle.ts +10 -0
  201. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +144 -4
  202. package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
  203. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
  204. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
  205. package/src/modules/access/access-control.interface.ts +22 -0
  206. package/src/modules/access/access-control.service.ts +138 -15
  207. package/src/modules/access/access.routes.ts +22 -6
  208. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
  209. package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
  210. package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
  211. package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
  212. package/src/modules/audit/agent-audit.service.ts +66 -6
  213. package/src/modules/audit/audit.contract.ts +62 -10
  214. package/src/modules/audit/audit.routes.ts +14 -12
  215. package/src/modules/audit/request-audit.ts +28 -13
  216. package/src/modules/database/core-schema.ts +15 -6
  217. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
  218. package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
  219. package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
  220. package/src/modules/diff/diff.service.ts +6 -4
  221. package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
  222. package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
  223. package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
  224. package/src/modules/kb-fs/git-dir.ts +53 -0
  225. package/src/modules/kb-fs/index.ts +3 -0
  226. package/src/modules/kb-fs/locking-filesystem.ts +3 -12
  227. package/src/modules/kb-fs/mutex.ts +16 -0
  228. package/src/modules/kb-fs/ref-files.ts +279 -0
  229. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
  230. package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
  231. package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
  232. package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
  233. package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
  234. package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
  235. package/src/modules/mcp/mcp.routes.ts +68 -0
  236. package/src/modules/mcp/mcp.service.ts +315 -51
  237. package/src/modules/mcp/request-context.ts +34 -0
  238. package/src/modules/mcp/shared-definitions.ts +121 -0
  239. package/src/modules/mcp/surface-cache.ts +154 -0
  240. package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
  241. package/src/modules/plugins/teams.routes.ts +14 -1
  242. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
  243. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
  244. package/src/modules/tool-auth/internal-token.service.ts +1 -1
  245. package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
  246. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
  247. package/src/modules/tool-helpers/tool-context.ts +37 -18
  248. package/src/modules/tool-helpers/tool-handler.ts +2 -2
  249. package/src/modules/tool-helpers/tool.contract.ts +10 -2
  250. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
  251. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +7 -2
  252. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
  253. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +250 -4
  254. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +464 -4
  255. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
  256. package/src/modules/workflow/agent-tools/workflow.tools.ts +462 -1
  257. package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
  258. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
  259. package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
  260. package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
  261. package/src/modules/workflow/git/git.service.ts +719 -126
  262. package/src/modules/workflow/read-on-branch.ts +79 -0
  263. package/src/modules/workflow/workflow-hooks.ts +1 -1
  264. package/src/modules/workflow/workflow.routes.ts +21 -39
  265. package/src/modules/workflow/workflow.service.ts +203 -16
  266. package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
  267. package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
  268. package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
  269. package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
  270. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
  271. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
  272. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
  273. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
  274. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
  275. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
  276. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
  277. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
  278. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
  279. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
  280. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
  281. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
  282. package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
  283. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
  284. package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
  285. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
  286. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
  287. package/src/modules/workspace/__tests__/workspace.tools.test.ts +153 -298
  288. package/src/modules/workspace/agent-access.gate.ts +2 -2
  289. package/src/modules/workspace/bounded-matcher.ts +328 -0
  290. package/src/modules/workspace/fetch-instant.ts +13 -0
  291. package/src/modules/workspace/index.ts +23 -0
  292. package/src/modules/workspace/kb-repository.contract.ts +124 -0
  293. package/src/modules/workspace/kb-repository.ts +844 -0
  294. package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
  295. package/src/modules/workspace/routine-write-policy.ts +0 -15
  296. package/src/modules/workspace/set-aside-clone.ts +140 -0
  297. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
  298. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
  299. package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
  300. package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
  301. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
  302. package/src/modules/workspace/workspace.service.ts +247 -169
  303. package/src/modules/workspace/workspace.tools.ts +179 -365
  304. package/src/shared/__tests__/no-link-below.test.ts +45 -0
  305. package/src/shared/git.contract.ts +10 -0
  306. package/src/shared/no-link-below.ts +29 -0
  307. package/src/test-setup.ts +5 -0
@@ -4,6 +4,7 @@ import express from 'express';
4
4
  import { describe, it, expect, afterEach, vi } from 'vitest';
5
5
 
6
6
  import type { IAccessControl } from '../access-control.interface.js';
7
+ import { AccessControlService } from '../access-control.service.js';
7
8
  import type { WorkspaceService } from '../../workspace/workspace.service.js';
8
9
  import type { AuthService } from '../../auth/auth.service.js';
9
10
  import type { WorkflowService } from '../../workflow/workflow.service.js';
@@ -31,6 +32,10 @@ async function makeHarness(opts: {
31
32
  files?: Record<string, string>;
32
33
  roles?: string[];
33
34
  groups?: string[];
35
+ /** Whether the caller is an admin, who is offered every group. Defaults to true. */
36
+ admin?: boolean;
37
+ /** The groups the caller is a member of — all a non-admin is offered, or may grant to. */
38
+ memberOf?: string[];
34
39
  }): Promise<{ server: Server; baseUrl: string; files: Map<string, string> }> {
35
40
  const files = new Map<string, string>(Object.entries(opts.files ?? {}));
36
41
 
@@ -46,6 +51,12 @@ async function makeHarness(opts: {
46
51
  groups: opts.groups ?? [],
47
52
  people: [],
48
53
  })),
54
+ holdsAdminRootWrite: vi.fn(async () => opts.admin ?? true),
55
+ groupsOfUser: vi.fn(async () => opts.memberOf ?? []),
56
+ // The real policy over the two stubs above: who sees which group is the
57
+ // resolver's one answer, not something a route (or this double) decides.
58
+ visibleGroups: (wsId: string, email: string, groups: readonly string[]) =>
59
+ AccessControlService.prototype.visibleGroups.call(accessControl, wsId, email, groups),
49
60
  eligibleWriters: vi.fn(async () => ({ roles: [], users: [] })),
50
61
  eligibleReaders: vi.fn(async () => ({ restricted: true, roles: [], users: [] })),
51
62
  eligibleOwners: vi.fn(async () => ({ roles: [], users: [] })),
@@ -213,6 +224,61 @@ describe('group principals on the share surface', () => {
213
224
  expect(body.plugins).toEqual(body.roles);
214
225
  });
215
226
 
227
+ it("suggest offers someone who is not an admin only the groups they are in, never another group's name", async () => {
228
+ h = await makeHarness({ roles: ['Everyone'], groups: ['GTM Team', 'Product'], admin: false, memberOf: ['Product'] });
229
+ const res = await fetch(`${h.baseUrl}/api/workspace/${encodeURIComponent(WS)}/access/suggest?q=`);
230
+ expect(res.status).toBe(200);
231
+ const body = (await res.json()) as { roles: string[]; groups: string[] };
232
+ expect(body.groups).toEqual(['Product']);
233
+ expect(body.roles).toEqual(['Everyone']); // roles are not withheld
234
+ });
235
+
236
+ it('suggest offers someone in no group, who is not an admin, no groups at all', async () => {
237
+ h = await makeHarness({ groups: ['GTM Team', 'Product'], admin: false });
238
+ const res = await fetch(`${h.baseUrl}/api/workspace/${encodeURIComponent(WS)}/access/suggest?q=`);
239
+ const body = (await res.json()) as { groups: string[] };
240
+ expect(body.groups).toEqual([]);
241
+ });
242
+
243
+ it('a non-admin grants to a group they are in', async () => {
244
+ h = await makeHarness({ groups: ['GTM Team', 'Product'], admin: false, memberOf: ['Product'] });
245
+ const res = await post('grant', {
246
+ path: `${KB}/Sales`,
247
+ kind: 'folder',
248
+ verb: 'read',
249
+ principal: { kind: 'group', group: 'Product' },
250
+ });
251
+ expect(res.status).toBe(200);
252
+ expect(h.files.get(`${KB}/Sales/access.md`)).toContain('- Product');
253
+ });
254
+
255
+ it('a non-admin granting to a group they are not in is refused exactly like an unknown group', async () => {
256
+ h = await makeHarness({ groups: ['GTM Team', 'Product'], admin: false, memberOf: ['Product'] });
257
+ const res = await post('grant', {
258
+ path: `${KB}/Sales`,
259
+ kind: 'folder',
260
+ verb: 'read',
261
+ principal: { kind: 'group', group: 'GTM Team' },
262
+ });
263
+ // Same status, same body as a group that does not exist — the WHOLE
264
+ // answer, message included, with only the name asked about differing —
265
+ // so the grant route confirms no group name the picker would not have
266
+ // offered.
267
+ const ghost = await post('grant', {
268
+ path: `${KB}/Sales`,
269
+ kind: 'folder',
270
+ verb: 'read',
271
+ principal: { kind: 'group', group: 'Ghost Team' },
272
+ });
273
+ expect(res.status).toBe(404);
274
+ expect(ghost.status).toBe(404);
275
+ const hiddenBody = (await res.json()) as Record<string, unknown>;
276
+ const ghostBody = (await ghost.json()) as Record<string, unknown>;
277
+ expect(hiddenBody).toMatchObject({ kind: 'unknown-group', group: 'GTM Team' });
278
+ expect(JSON.stringify(hiddenBody).replaceAll('GTM Team', 'Ghost Team')).toBe(JSON.stringify(ghostBody));
279
+ expect(h.files.has(`${KB}/Sales/access.md`)).toBe(false);
280
+ });
281
+
216
282
  it('revokes with a group principal (name-based, no existence check)', async () => {
217
283
  h = await makeHarness({
218
284
  groups: ['GTM Team'],
@@ -55,6 +55,8 @@ async function makeHarness(opts: {
55
55
  grantSources: vi.fn(async () => ({ read: [{ kind: 'direct' }] })),
56
56
  invalidate: vi.fn(),
57
57
  kbPrincipals: vi.fn(async () => ({ roles: [], groups: [], people: opts.kbPeople ?? [] })),
58
+ // No groups in this source, so the answer is the empty list the service gives for one.
59
+ visibleGroups: vi.fn(async (_w: string, _e: string, groups: readonly string[]) => [...groups]),
58
60
  eligibleWriters: vi.fn(async () => ({ roles: [], users: granted })),
59
61
  eligibleReaders: vi.fn(async () => ({ restricted: true, roles: [], users: granted })),
60
62
  eligibleOwners: vi.fn(async () => ({ roles: [], users: [] })),
@@ -234,6 +234,28 @@ export interface IAccessControl {
234
234
  */
235
235
  canReadAsEveryoneBatch(workspaceId: string, relativePaths: string[]): Promise<Map<string, boolean>>;
236
236
 
237
+ /**
238
+ * The display names of the groups `userEmail` is a member of, in the
239
+ * active group source (`groups.yaml`, or the IdP-synced file), spelled as
240
+ * `kbPrincipals` spells them. Direct membership only: a role that lists a
241
+ * group does not make its holders members of it. Empty when the person is
242
+ * in no group.
243
+ */
244
+ groupsOfUser(workspaceId: string, userEmail: string): Promise<string[]>;
245
+
246
+ /**
247
+ * Which of `groups` (names from the active group source) `userEmail` may
248
+ * SEE: every one for an admin, who manages them all, and only the groups
249
+ * they are a member of ({@link groupsOfUser}) for anyone else. A group's
250
+ * name is information — on a deployment that gives each client, author or
251
+ * customer a group of its own, the list of groups is the client list — so
252
+ * this ONE answer is what the Library's Groups tab lists, what the share
253
+ * dialog offers as a grantee, and what a grant may name; a group outside it
254
+ * is treated as one that does not exist. Order and spelling of `groups` are
255
+ * kept.
256
+ */
257
+ visibleGroups(workspaceId: string, userEmail: string, groups: readonly string[]): Promise<string[]>;
258
+
237
259
  /**
238
260
  * Batched canWrite for PR diffs and commit-time gating. Returns a map keyed
239
261
  * by the input paths, with `true` / `false` for each. Reuses one config
@@ -5,9 +5,10 @@ import { logger } from '../../shared/logging.js';
5
5
  const log = logger('access');
6
6
 
7
7
  import { isAbsence, type ITreeWalker, type WalkListener } from '../../shared/fs.contract.js';
8
- import { assertOneSpecPerLine, isGitTimeout, type IGitRunner } from '../../shared/git.contract.js';
8
+ import { assertOneSpecPerLine, isCommitSha, isGitTimeout, type IGitRunner } from '../../shared/git.contract.js';
9
9
  import { hashEmail } from '../../shared/email-identity.js';
10
10
  import { NodeGitRunner } from '../workflow/git/node-git-runner.js';
11
+ import { readRevSha } from '../kb-fs/index.js';
11
12
  import type { WorkspaceService } from '../workspace/workspace.service.js';
12
13
  import type {
13
14
  IAccessControl,
@@ -1370,7 +1371,13 @@ function ineligibleNamedEmailsResolved(
1370
1371
  * was built from (not the ref name that led there), so the per-file
1371
1372
  * own-entries reads that follow a gate resolve against exactly that tree.
1372
1373
  */
1373
- type AtRefModel = { model: AccessModel; resolvedRef: string };
1374
+ /**
1375
+ * A model built at a commit, with the commit it was built at and, when the
1376
+ * tree is not too large to hold, every path the commit carries: what the
1377
+ * own-entries reads need to tell a path that is absent at the commit (a
1378
+ * fact, remembered) from a blob the batch could not read (never remembered).
1379
+ */
1380
+ type AtRefModel = { model: AccessModel; resolvedRef: string; tree: ReadonlySet<string> | null };
1374
1381
  /**
1375
1382
  * One build's outcome: a model, no roles.yaml at the commit, or one that
1376
1383
  * doesn't parse. A git read failure is none of these: it throws
@@ -1440,6 +1447,25 @@ export class AccessControlService implements IAccessControl {
1440
1447
  private readonly atRefInFlight = new Map<string, Promise<AtRefBuild>>();
1441
1448
  private static readonly AT_REF_CACHE_MAX = 512;
1442
1449
 
1450
+ /**
1451
+ * A path's own access entries AT A COMMIT, keyed by commit + path. A blob
1452
+ * at a commit never changes, so neither does what it declares for itself:
1453
+ * no TTL, no invalidation, and no workspace in the key (every clone holds
1454
+ * the same objects). This is the read every at-ref gate makes after the
1455
+ * model: one `cat-file --batch` per call for the paths asked, which a
1456
+ * change-request listing made once per ref per listing for the same paths
1457
+ * at the same commits. Bounded FIFO; the bound only caps memory.
1458
+ */
1459
+ private readonly ownEntriesAtCommit = new Map<string, OwnEntries | null>();
1460
+ private static readonly OWN_ENTRIES_AT_COMMIT_MAX = 16_384;
1461
+ /** Paths a cached model keeps of its commit's tree, at most; a larger tree is not kept. */
1462
+ private static readonly TREE_INDEX_MAX_PATHS = 20_000;
1463
+ /** Paths held across every cached model's tree, at most; past it the oldest models let their tree go. */
1464
+ private static readonly TREE_INDEX_BUDGET = 500_000;
1465
+ private readonly treeIndexMaxPaths: number;
1466
+ private readonly treeIndexBudget: number;
1467
+ private treePathsHeld = 0;
1468
+
1443
1469
  /**
1444
1470
  * Canonicalised deployment-owner emails (see `AccessModel.deploymentOwners`).
1445
1471
  * Held on the service rather than read per-model because it comes from the
@@ -1473,10 +1499,19 @@ export class AccessControlService implements IAccessControl {
1473
1499
  * run on the default layout.
1474
1500
  */
1475
1501
  private readonly kb: Pick<KbContext, 'layout'> = { layout: DEFAULT_KB_LAYOUT },
1502
+ /**
1503
+ * How much of the commits' trees the at-ref models may hold (see
1504
+ * `TREE_INDEX_MAX_PATHS`, `TREE_INDEX_BUDGET`). The defaults are the
1505
+ * deployment's; a suite lowers them to reach the bounds with a small
1506
+ * repository.
1507
+ */
1508
+ limits: { treeIndexMaxPaths?: number; treeIndexBudget?: number } = {},
1476
1509
  ) {
1477
1510
  this.deploymentOwners = new Set(
1478
1511
  deploymentOwners.filter(Boolean).map((e) => canonicalEmail(e)),
1479
1512
  );
1513
+ this.treeIndexMaxPaths = limits.treeIndexMaxPaths ?? AccessControlService.TREE_INDEX_MAX_PATHS;
1514
+ this.treeIndexBudget = limits.treeIndexBudget ?? AccessControlService.TREE_INDEX_BUDGET;
1480
1515
  }
1481
1516
 
1482
1517
  /**
@@ -1618,6 +1653,23 @@ export class AccessControlService implements IAccessControl {
1618
1653
  return result;
1619
1654
  }
1620
1655
 
1656
+ async groupsOfUser(workspaceId: string, userEmail: string): Promise<string[]> {
1657
+ const model = await this.loadModel(workspaceId);
1658
+ const email = canonicalEmail(userEmail);
1659
+ const names: string[] = [];
1660
+ for (const principal of model.roles.byCanonical.values()) {
1661
+ if (principal.kind === 'group' && principal.emails.has(email)) names.push(principal.displayName);
1662
+ }
1663
+ return names;
1664
+ }
1665
+
1666
+ async visibleGroups(workspaceId: string, userEmail: string, groups: readonly string[]): Promise<string[]> {
1667
+ if (groups.length === 0) return [];
1668
+ if (await this.holdsAdminRootWrite(workspaceId, userEmail)) return [...groups];
1669
+ const mine = new Set(await this.groupsOfUser(workspaceId, userEmail));
1670
+ return groups.filter((g) => mine.has(g));
1671
+ }
1672
+
1621
1673
  async canWriteBatch(
1622
1674
  workspaceId: string,
1623
1675
  userEmail: string,
@@ -2135,7 +2187,7 @@ export class AccessControlService implements IAccessControl {
2135
2187
  const loaded = await this.loadModelAtRef(workspaceId, ref);
2136
2188
  if (!loaded) return null;
2137
2189
  const repoDir = await this.repoDir(workspaceId);
2138
- const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
2190
+ const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath, loaded.tree);
2139
2191
  return hasPermissionResolved(loaded.model, 'write', userEmail, relativePath, own);
2140
2192
  }
2141
2193
 
@@ -2148,7 +2200,7 @@ export class AccessControlService implements IAccessControl {
2148
2200
  const loaded = await this.loadModelAtRef(workspaceId, ref);
2149
2201
  if (!loaded) return null;
2150
2202
  const repoDir = await this.repoDir(workspaceId);
2151
- const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
2203
+ const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath, loaded.tree);
2152
2204
  return canReadResolved(loaded.model, userEmail, relativePath, own);
2153
2205
  }
2154
2206
 
@@ -2171,7 +2223,7 @@ export class AccessControlService implements IAccessControl {
2171
2223
  const repoDir = await this.repoDir(workspaceId);
2172
2224
  // One `git cat-file --batch` for the whole path set — a per-path `git
2173
2225
  // show` spawn made CR owner-routing take minutes on large change sets.
2174
- const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
2226
+ const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths, loaded.tree);
2175
2227
  const result = new Map<string, boolean>();
2176
2228
  for (const p of relativePaths) {
2177
2229
  const machineOwned = machineAnswers.get(p);
@@ -2196,7 +2248,7 @@ export class AccessControlService implements IAccessControl {
2196
2248
  if (!loaded) return null;
2197
2249
  const repoDir = await this.repoDir(workspaceId);
2198
2250
  // One `git cat-file --batch` for the whole set — see canWriteBatchAtRef.
2199
- const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
2251
+ const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths, loaded.tree);
2200
2252
  const result = new Map<string, boolean>();
2201
2253
  for (const p of relativePaths) {
2202
2254
  result.set(p, canReadResolved(loaded.model, userEmail, p, owns.get(p) ?? null));
@@ -2219,7 +2271,7 @@ export class AccessControlService implements IAccessControl {
2219
2271
  const loaded = await this.loadModelAtRef(workspaceId, ref);
2220
2272
  if (!loaded) return null;
2221
2273
  const repoDir = await this.repoDir(workspaceId);
2222
- const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
2274
+ const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath, loaded.tree);
2223
2275
  return eligibleHoldersResolved(loaded.model, 'write', relativePath, own);
2224
2276
  }
2225
2277
 
@@ -2333,7 +2385,7 @@ export class AccessControlService implements IAccessControl {
2333
2385
  if (!loaded) return null;
2334
2386
  const repoDir = await this.repoDir(workspaceId);
2335
2387
  // One `git cat-file --batch` for the whole path set — see canWriteBatchAtRef.
2336
- const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
2388
+ const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths, loaded.tree);
2337
2389
  for (const p of relativePaths) {
2338
2390
  if (result.has(p)) continue;
2339
2391
  const own = owns.get(p) ?? null;
@@ -2551,8 +2603,14 @@ export class AccessControlService implements IAccessControl {
2551
2603
  repoDir: string,
2552
2604
  ref: string,
2553
2605
  relativePath: string,
2606
+ tree: ReadonlySet<string> | null = null,
2554
2607
  ): Promise<OwnEntries | null> {
2555
2608
  if (relativePath === 'roles.yaml') return null;
2609
+ // At a commit (what every gate passes: `resolvedRef`), the batched read
2610
+ // and its memo answer; a single `git show` stays for a symbolic ref.
2611
+ if (isCommitSha(ref)) {
2612
+ return (await this.readOwnEntriesAtRefBatch(repoDir, ref, [relativePath], tree)).get(relativePath) ?? null;
2613
+ }
2556
2614
  const text = await this.showAtRef(repoDir, ref, relativePath);
2557
2615
  if (text === null) return null;
2558
2616
  if (isAccessMdPath(relativePath)) return accessMdSelfEntries(text);
@@ -2571,23 +2629,60 @@ export class AccessControlService implements IAccessControl {
2571
2629
  repoDir: string,
2572
2630
  ref: string,
2573
2631
  relativePaths: string[],
2632
+ tree: ReadonlySet<string> | null = null,
2574
2633
  ): Promise<Map<string, OwnEntries | null>> {
2575
2634
  const result = new Map<string, OwnEntries | null>();
2576
2635
  const wanted: string[] = [];
2636
+ const memo = isCommitSha(ref) ? this.ownEntriesAtCommit : null;
2577
2637
  for (const p of relativePaths) {
2578
- if (p === 'roles.yaml') result.set(p, null);
2579
- else if (!wanted.includes(p)) wanted.push(p);
2638
+ if (p === 'roles.yaml') {
2639
+ result.set(p, null);
2640
+ continue;
2641
+ }
2642
+ const key = `${ref}\0${p}`;
2643
+ if (memo?.has(key)) {
2644
+ result.set(p, memo.get(key) ?? null);
2645
+ continue;
2646
+ }
2647
+ // A path the commit's tree does not carry has no own entries, as a
2648
+ // fact about the commit: remembered, and not read. (A change request
2649
+ // that adds a file asks about it at the base, where it does not exist
2650
+ // yet; one that deletes a file asks at the head.)
2651
+ if (tree && !tree.has(p)) {
2652
+ result.set(p, null);
2653
+ if (memo) this.rememberOwnEntriesAtCommit(key, null);
2654
+ continue;
2655
+ }
2656
+ if (!wanted.includes(p)) wanted.push(p);
2580
2657
  }
2581
2658
  if (wanted.length === 0) return result;
2582
2659
  const texts = await catFileBatch(this.gitRunner, repoDir, wanted.map((p) => `${ref}:${p}`));
2583
2660
  wanted.forEach((p, i) => {
2584
2661
  const text = texts[i];
2585
- if (text === null) result.set(p, null);
2586
- else result.set(p, isAccessMdPath(p) ? accessMdSelfEntries(text) : parseOwnAccessEntries(text));
2662
+ if (text === null) {
2663
+ // Absent at the commit (when no tree was there to say so first), or
2664
+ // a blob git could not read: the batch says "missing" for both, and
2665
+ // only the first is a fact about the commit. Answered as no own
2666
+ // entries, as before, but never remembered.
2667
+ result.set(p, null);
2668
+ return;
2669
+ }
2670
+ const own = isAccessMdPath(p) ? accessMdSelfEntries(text) : parseOwnAccessEntries(text);
2671
+ result.set(p, own);
2672
+ // What a blob declares for itself — including nothing — is a fact about the commit.
2673
+ if (memo) this.rememberOwnEntriesAtCommit(`${ref}\0${p}`, own);
2587
2674
  });
2588
2675
  return result;
2589
2676
  }
2590
2677
 
2678
+ private rememberOwnEntriesAtCommit(key: string, value: OwnEntries | null): void {
2679
+ this.ownEntriesAtCommit.set(key, value);
2680
+ if (this.ownEntriesAtCommit.size > AccessControlService.OWN_ENTRIES_AT_COMMIT_MAX) {
2681
+ const oldest = this.ownEntriesAtCommit.keys().next().value;
2682
+ if (oldest !== undefined) this.ownEntriesAtCommit.delete(oldest);
2683
+ }
2684
+ }
2685
+
2591
2686
  /**
2592
2687
  * Resolve a candidate ref to one git accepts. Falls back to `origin/<ref>`
2593
2688
  * for short branch names, matching the heuristic in
@@ -2649,11 +2744,12 @@ export class AccessControlService implements IAccessControl {
2649
2744
  private async listAccessFilesAtRef(
2650
2745
  repoDir: string,
2651
2746
  ref: string,
2652
- ): Promise<{ accessFiles: string[]; pluginDirs: string[]; named: Set<string> } | 'error'> {
2747
+ ): Promise<{ accessFiles: string[]; pluginDirs: string[]; named: Set<string>; all: string[] } | 'error'> {
2653
2748
  try {
2654
2749
  const { stdout } = await this.gitRunner.run(repoDir, ['ls-tree', '-r', '-z', '--name-only', ref]);
2655
2750
  const out: string[] = [];
2656
2751
  const pluginDirs: string[] = [];
2752
+ const all: string[] = [];
2657
2753
  // The fixed-name sources the tree carries (roles.yaml, the groups
2658
2754
  // files): a read of one of these that comes back empty is "absent"
2659
2755
  // only when the tree does not name it.
@@ -2661,13 +2757,15 @@ export class AccessControlService implements IAccessControl {
2661
2757
  const { pluginsDir } = this.kb.layout;
2662
2758
  for (const p of stdout.split('\0')) {
2663
2759
  if (!p) continue;
2760
+ // One past the cap is enough to know the tree is not kept.
2761
+ if (all.length <= this.treeIndexMaxPaths) all.push(p);
2664
2762
  if (p === 'access.md' || p.endsWith('/access.md')) out.push(p);
2665
2763
  else if (p === 'roles.yaml' || p === SYNCED_GROUPS_YAML || p === GROUPS_YAML) named.add(p);
2666
2764
  else if (p.startsWith(`${pluginsDir}/`) && p.endsWith(`/${PLUGIN_MANIFEST_FILE}`)) {
2667
2765
  pluginDirs.push(p.slice(0, -(PLUGIN_MANIFEST_FILE.length + 1)));
2668
2766
  }
2669
2767
  }
2670
- return { accessFiles: out, pluginDirs, named };
2768
+ return { accessFiles: out, pluginDirs, named, all };
2671
2769
  } catch {
2672
2770
  return 'error';
2673
2771
  }
@@ -2742,6 +2840,14 @@ export class AccessControlService implements IAccessControl {
2742
2840
  /** The commit `ref` points at in this clone, or null when it doesn't resolve. */
2743
2841
  private async revParseCommit(repoDir: string, ref: string): Promise<string | null> {
2744
2842
  if (!ref || ref.startsWith('-')) return null;
2843
+ // The ref files first (`readRevSha`): the names git would try, in git's
2844
+ // order, read without a process. "Missing" from the files is what
2845
+ // rev-parse answers nothing for; "unreadable" — a spelling the reader
2846
+ // does not resolve, a tag it cannot peel, a file it could not read — is
2847
+ // git's to decide, as the authority.
2848
+ const fromFiles = await readRevSha(repoDir, ref);
2849
+ if (fromFiles === 'missing') return null;
2850
+ if (fromFiles !== 'unreadable') return fromFiles.sha;
2745
2851
  try {
2746
2852
  const { stdout } = await this.gitRunner.run(repoDir, [
2747
2853
  'rev-parse',
@@ -2762,9 +2868,22 @@ export class AccessControlService implements IAccessControl {
2762
2868
 
2763
2869
  private rememberAtRef(key: string, value: AtRefModel): void {
2764
2870
  this.atRefCache.set(key, value);
2871
+ this.treePathsHeld += value.tree?.size ?? 0;
2765
2872
  if (this.atRefCache.size > AccessControlService.AT_REF_CACHE_MAX) {
2766
2873
  const oldest = this.atRefCache.keys().next().value;
2767
- if (oldest !== undefined) this.atRefCache.delete(oldest);
2874
+ if (oldest !== undefined) {
2875
+ this.treePathsHeld -= this.atRefCache.get(oldest)?.tree?.size ?? 0;
2876
+ this.atRefCache.delete(oldest);
2877
+ }
2878
+ }
2879
+ // The trees are a budget of their own, across every held model: past
2880
+ // it, the oldest models keep their model and let their tree go — the
2881
+ // gate still answers, absence is just no longer remembered for them.
2882
+ for (const held of this.atRefCache.values()) {
2883
+ if (this.treePathsHeld <= this.treeIndexBudget) break;
2884
+ if (!held.tree) continue;
2885
+ this.treePathsHeld -= held.tree.size;
2886
+ held.tree = null;
2768
2887
  }
2769
2888
  }
2770
2889
 
@@ -2940,6 +3059,10 @@ export class AccessControlService implements IAccessControl {
2940
3059
  deploymentOwners: this.deploymentOwners,
2941
3060
  },
2942
3061
  resolvedRef: commit,
3062
+ // The listing already named every path; held so an own-entries read
3063
+ // can tell absence from a lost blob, unless the tree is too large to
3064
+ // hold for every cached commit (then absence is simply not remembered).
3065
+ tree: listed.all.length <= this.treeIndexMaxPaths ? new Set(listed.all) : null,
2943
3066
  };
2944
3067
  }
2945
3068
  }
@@ -128,6 +128,18 @@ export function createAccessRoutes(
128
128
  return { roles, groups, plugins: plugins ?? [] };
129
129
  };
130
130
 
131
+ /**
132
+ * The groups `email` may be offered as a grantee, or name in a grant: the
133
+ * resolver's ONE answer to who sees which group (`visibleGroups`: every
134
+ * group for an admin, only their own for anyone else), the same the
135
+ * Library's Groups tab lists. Read on the default branch, where the active
136
+ * group source lives. The picker never names a group the caller has no
137
+ * part in, and the grant route refuses one with the same words as a group
138
+ * that does not exist.
139
+ */
140
+ const visibleGroups = (email: string, groups: string[]): Promise<string[]> =>
141
+ accessControl.visibleGroups(kb.defaultWorkspaceId(), email, groups);
142
+
131
143
  /**
132
144
  * The plugins `email` may DISCOVER — read the folder's access.md, the same
133
145
  * verdict the plugin index lists on. Both the suggest list and the grant
@@ -491,8 +503,9 @@ export function createAccessRoutes(
491
503
  * names — resolved from the DEFAULT branch, the authoritative source),
492
504
  * groups (the ACTIVE group source, same authority), and people (branch-local
493
505
  * to `:id`). Roles/groups come from the resolver's CACHED model — no fresh
494
- * file reads per keystroke. Roles + groups are small + non-sensitive so they
495
- * always show; PEOPLE are withheld until `q` is ≥ 2 chars, so an empty query
506
+ * file reads per keystroke. Roles are small + non-sensitive so they always
507
+ * show; GROUPS are the caller's own unless they are an admin (see
508
+ * `visibleGroups`); PEOPLE are withheld until `q` is ≥ 2 chars, so an empty query
496
509
  * can't dump the whole directory (email-harvesting guard). People are the
497
510
  * union of the KB-canonical set (roles.yaml + access.md grants) and the
498
511
  * `users` table (logged-in users). Results are capped. Each person carries
@@ -527,7 +540,7 @@ export function createAccessRoutes(
527
540
  .filter((g) => !q || g.toLowerCase().includes(q))
528
541
  .slice(0, CAP);
529
542
 
530
- const matchedGroups = groups
543
+ const matchedGroups = (await visibleGroups(user.email, groups))
531
544
  .filter((g) => !q || g.toLowerCase().includes(q))
532
545
  .slice(0, CAP);
533
546
 
@@ -934,9 +947,12 @@ export function createAccessRoutes(
934
947
  } else if (principal.kind === 'group') {
935
948
  // A group grant must name a group in the ACTIVE source (IdP-synced or
936
949
  // manual) on the default branch — same authority the suggest list
937
- // uses. No role-collision refusal: bare tokens resolve GROUP-FIRST,
938
- // so the written bare name IS the group.
939
- const { groups } = await defaultBranchPrincipals();
950
+ // uses — and one the caller may see (`visibleGroups`): a group hidden
951
+ // from the picker is refused here with the same words as one that
952
+ // does not exist, so the grant route is no oracle for group names.
953
+ // No role-collision refusal: bare tokens resolve GROUP-FIRST, so the
954
+ // written bare name IS the group.
955
+ const groups = await visibleGroups(user.email, (await defaultBranchPrincipals()).groups);
940
956
  const canonical = canonicalRoleName(principal.group);
941
957
  if (!groups.some((g) => canonicalRoleName(g) === canonical)) {
942
958
  throw new AccessMutationError(
@@ -358,7 +358,7 @@ describe('the HTML views section', () => {
358
358
  expect(ids.indexOf('html-views')).toBe(ids.indexOf('tool-manuals') + 1);
359
359
  });
360
360
 
361
- it('describes the frame, the two members core puts on window.bevel, and a fragment that scrolls the page', async () => {
361
+ it('describes the frame, the members core puts on window.bevel, and a fragment that scrolls the page', async () => {
362
362
  const text = section(await composeAgentGuide(DEFAULT_KB_LAYOUT), '## HTML views').replace(/\s+/g, ' ');
363
363
  expect(text).toContain('**No network.**');
364
364
  expect(text).toContain('**Inline scripts and styles only.**');
@@ -374,6 +374,27 @@ describe('the HTML views section', () => {
374
374
  expect(docs).toContain('/workspace/main/Docs/Alice.md');
375
375
  });
376
376
 
377
+ it('documents window.bevel.tools: its four functions, the null check, the consent rule and requireTools with its card', async () => {
378
+ const text = section(await composeAgentGuide(DEFAULT_KB_LAYOUT), '## HTML views').replace(/\s+/g, ' ');
379
+ for (const member of [
380
+ '`window.bevel.tools.searchTools(query)`',
381
+ '`window.bevel.tools.toolsInfo(names)`',
382
+ '`window.bevel.tools.callTool(name, args)`',
383
+ '`window.bevel.tools.callToolChain(code)`',
384
+ '`window.bevel.requireTools()`',
385
+ ]) {
386
+ expect(text).toContain(member);
387
+ }
388
+ expect(text).toContain('`window.bevel.tools` is `null` wherever tools are not available');
389
+ expect(text).toContain('Check it before every use');
390
+ expect(text).toContain('**Reading runs at once; writing asks first.**');
391
+ expect(text).toContain('"<page name> wants to write to the knowledge base"');
392
+ expect(text).toContain('Allow while open and Deny');
393
+ expect(text).toContain('rejects the call with `denied`');
394
+ expect(text).toContain('"<page name> uses tools that are only available in the platform"');
395
+ expect(text).toContain('Continue without tools and Open in the platform');
396
+ });
397
+
377
398
  it('lets a distribution replace the section or drop it', async () => {
378
399
  const replaced = await composeAgentGuide(DEFAULT_KB_LAYOUT, (sections) =>
379
400
  sections.map((s) => (s.id === 'html-views' ? { id: s.id, body: '## HTML views\n\nOurs, graph-aware.\n' } : s)),
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * They used to be appended, in full, to every tool description that they
5
5
  * covered: the content rule rode on all twelve file tools, the agent-guide
6
- * reminder on all of them plus the shell, the write-mode and escape-sequence
6
+ * reminder on all of them, the write-mode and escape-sequence
7
7
  * paragraphs on two or three apiece. A description then ran to two or three
8
8
  * thousand characters, most of it text the agent had already read on the tool
9
9
  * above — and clients cut long descriptions from the END, which is where the
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
3
3
  import type { SQL } from 'drizzle-orm';
4
4
  import { PgDialect } from 'drizzle-orm/pg-core';
5
5
  import { AgentAuditService, retentionDaysFrom } from '../agent-audit.service.js';
6
- import { AuditPrincipalNotFoundError, InvalidCursorError } from '../audit.contract.js';
6
+ import { AuditPrincipalNotFoundError, InvalidCursorError, pagePrincipalId, parsePagePrincipalId } from '../audit.contract.js';
7
7
  import type { Database } from '../../database/connection.js';
8
8
  import { agentConnections, oauthTokens } from '../../database/schema.js';
9
9
  import type { IExternalApiKeyService } from '../../tool-auth/external-api-key.interface.js';
@@ -217,6 +217,8 @@ describe('AgentAuditService.listPrincipals', () => {
217
217
  { keyId: 'k-1', connectionId: null, n: 7 },
218
218
  { keyId: null, connectionId: 'c-1', n: 3 },
219
219
  ],
220
+ // pages
221
+ [],
220
222
  ],
221
223
  { listForUser: vi.fn(async () => [key({ lastUsedAt: NOW - 2 * DAY })]) },
222
224
  );
@@ -235,6 +237,7 @@ describe('AgentAuditService.listPrincipals', () => {
235
237
  [
236
238
  [{ connection: connection({ id: 'c-bob', userId: BOB.id }), user: BOB }],
237
239
  [],
240
+ [],
238
241
  ],
239
242
  { listForDeployment: vi.fn(async () => [{ ...key({}), user: ALICE }]) },
240
243
  );
@@ -247,6 +250,80 @@ describe('AgentAuditService.listPrincipals', () => {
247
250
  });
248
251
  });
249
252
 
253
+ describe('AgentAuditService — a page as a principal', () => {
254
+ const ALICE_ID = '11111111-1111-4111-8111-111111111111';
255
+ const PAGE = 'Dashboards/Roadmap.html';
256
+
257
+ it("records a page's call under its path, with neither a key nor a connection", async () => {
258
+ const { service, captured } = makeService([undefined]);
259
+ service.record({
260
+ userId: ALICE_ID,
261
+ principal: { kind: 'page', path: PAGE },
262
+ kind: 'capability',
263
+ manual: null,
264
+ name: 'edit_file',
265
+ outcome: 'refused',
266
+ durationMs: null,
267
+ });
268
+ await flush();
269
+ expect(captured.values[0]).toMatchObject({
270
+ userId: ALICE_ID,
271
+ keyId: null,
272
+ connectionId: null,
273
+ pagePath: PAGE,
274
+ name: 'edit_file',
275
+ outcome: 'refused',
276
+ });
277
+ });
278
+
279
+ it('lists each page a person opened as a row of its own, labelled by its path, never revocable', async () => {
280
+ const { service } = makeService([
281
+ [{ ...ALICE, id: ALICE_ID }],
282
+ [],
283
+ [],
284
+ [
285
+ {
286
+ userId: ALICE_ID,
287
+ pagePath: PAGE,
288
+ n: 4,
289
+ first: new Date(NOW - DAY),
290
+ last: new Date(NOW - 60_000),
291
+ user: { ...ALICE, id: ALICE_ID },
292
+ },
293
+ ],
294
+ ]);
295
+ const rows = await service.listPrincipals({ userId: ALICE_ID });
296
+ expect(rows).toHaveLength(1);
297
+ expect(rows[0]).toMatchObject({
298
+ kind: 'page',
299
+ id: pagePrincipalId(ALICE_ID, PAGE),
300
+ label: PAGE,
301
+ eventCount: 4,
302
+ createdAt: NOW - DAY,
303
+ lastUsedAt: NOW - 60_000,
304
+ revokedAt: null,
305
+ });
306
+ });
307
+
308
+ it("reads a page's events by its person and path, and owns it by that person", async () => {
309
+ const id = pagePrincipalId(ALICE_ID, PAGE);
310
+ expect(parsePagePrincipalId(id)).toEqual({ userId: ALICE_ID, path: PAGE });
311
+
312
+ const events = makeService([[], [{ total: 0 }]]);
313
+ await events.service.listEvents({ kind: 'page', id }, { limit: 10 });
314
+ expect(render(events.captured.where[0]).params).toEqual([ALICE_ID, PAGE]);
315
+
316
+ const owned = makeService([[{ id: 'e-1' }]]);
317
+ await expect(owned.service.ownerOf({ kind: 'page', id })).resolves.toBe(ALICE_ID);
318
+ const never = makeService([[]]);
319
+ await expect(never.service.ownerOf({ kind: 'page', id })).resolves.toBeNull();
320
+ // An id that names no person and path is no page, without asking the database.
321
+ const bogus = makeService([]);
322
+ await expect(bogus.service.ownerOf({ kind: 'page', id: 'not-a-page' })).resolves.toBeNull();
323
+ expect(bogus.counts.select).toBe(0);
324
+ });
325
+ });
326
+
250
327
  describe('AgentAuditService.listEvents', () => {
251
328
  const eventId = (i: number) => `00000000-0000-4000-8000-00000000000${i}`;
252
329
  const eventRow = (i: number) => ({