@bevel-software/platform-core-backend 0.28.1 → 0.30.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 (274) hide show
  1. package/agent-guide/access-control.md +15 -0
  2. package/agent-guide/skills.md +8 -0
  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.d.ts.map +1 -1
  8. package/dist/core/catalog-revision.js +4 -2
  9. package/dist/core/catalog-revision.js.map +1 -1
  10. package/dist/core/compose-embed.d.ts +35 -0
  11. package/dist/core/compose-embed.d.ts.map +1 -0
  12. package/dist/core/compose-embed.js +14 -0
  13. package/dist/core/compose-embed.js.map +1 -0
  14. package/dist/core/core-ports.d.ts +10 -0
  15. package/dist/core/core-ports.d.ts.map +1 -1
  16. package/dist/core/core-ports.js.map +1 -1
  17. package/dist/core/create-core-server.d.ts.map +1 -1
  18. package/dist/core/create-core-server.js +8 -2
  19. package/dist/core/create-core-server.js.map +1 -1
  20. package/dist/core/create-core-services.d.ts +4 -1
  21. package/dist/core/create-core-services.d.ts.map +1 -1
  22. package/dist/core/create-core-services.js +57 -18
  23. package/dist/core/create-core-services.js.map +1 -1
  24. package/dist/core/lifecycle.d.ts +19 -6
  25. package/dist/core/lifecycle.d.ts.map +1 -1
  26. package/dist/core/lifecycle.js +17 -6
  27. package/dist/core/lifecycle.js.map +1 -1
  28. package/dist/modules/access/access-control.interface.d.ts +20 -0
  29. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  30. package/dist/modules/access/access-control.service.d.ts +68 -15
  31. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  32. package/dist/modules/access/access-control.service.js +282 -58
  33. package/dist/modules/access/access-control.service.js.map +1 -1
  34. package/dist/modules/access/access.routes.d.ts.map +1 -1
  35. package/dist/modules/access/access.routes.js +20 -6
  36. package/dist/modules/access/access.routes.js.map +1 -1
  37. package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
  38. package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
  39. package/dist/modules/auth/index.d.ts +15 -0
  40. package/dist/modules/auth/index.d.ts.map +1 -0
  41. package/dist/modules/auth/index.js +20 -0
  42. package/dist/modules/auth/index.js.map +1 -0
  43. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  44. package/dist/modules/diff/diff.service.js +7 -1
  45. package/dist/modules/diff/diff.service.js.map +1 -1
  46. package/dist/modules/embed/embed.interface.d.ts +22 -0
  47. package/dist/modules/embed/embed.interface.d.ts.map +1 -1
  48. package/dist/modules/embed/embed.service.d.ts +28 -14
  49. package/dist/modules/embed/embed.service.d.ts.map +1 -1
  50. package/dist/modules/embed/embed.service.js +38 -17
  51. package/dist/modules/embed/embed.service.js.map +1 -1
  52. package/dist/modules/embed/embed.tools.d.ts.map +1 -1
  53. package/dist/modules/embed/embed.tools.js +10 -2
  54. package/dist/modules/embed/embed.tools.js.map +1 -1
  55. package/dist/modules/kb-fs/git-dir.d.ts +21 -0
  56. package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
  57. package/dist/modules/kb-fs/git-dir.js +58 -0
  58. package/dist/modules/kb-fs/git-dir.js.map +1 -0
  59. package/dist/modules/kb-fs/index.d.ts +9 -0
  60. package/dist/modules/kb-fs/index.d.ts.map +1 -0
  61. package/dist/modules/kb-fs/index.js +3 -0
  62. package/dist/modules/kb-fs/index.js.map +1 -0
  63. package/dist/modules/kb-fs/ref-files.d.ts +63 -0
  64. package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
  65. package/dist/modules/kb-fs/ref-files.js +249 -0
  66. package/dist/modules/kb-fs/ref-files.js.map +1 -0
  67. package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
  68. package/dist/modules/plugins/join-request-jobs.service.js +43 -8
  69. package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
  70. package/dist/modules/plugins/teams.routes.d.ts +4 -0
  71. package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
  72. package/dist/modules/plugins/teams.routes.js +9 -1
  73. package/dist/modules/plugins/teams.routes.js.map +1 -1
  74. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  75. package/dist/modules/skills/skills.tools.d.ts +8 -1
  76. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  77. package/dist/modules/skills/skills.tools.js +115 -11
  78. package/dist/modules/skills/skills.tools.js.map +1 -1
  79. package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
  80. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
  81. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  82. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
  83. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  84. package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
  85. package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
  86. package/dist/modules/tool-helpers/default-branch-write.js +63 -0
  87. package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
  88. package/dist/modules/tool-helpers/index.d.ts +1 -0
  89. package/dist/modules/tool-helpers/index.d.ts.map +1 -1
  90. package/dist/modules/tool-helpers/index.js +1 -0
  91. package/dist/modules/tool-helpers/index.js.map +1 -1
  92. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
  93. package/dist/modules/tool-helpers/tool.contract.d.ts +2 -2
  94. package/dist/modules/workflow/file-lock.service.d.ts +27 -5
  95. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  96. package/dist/modules/workflow/file-lock.service.js +70 -18
  97. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  98. package/dist/modules/workflow/git/git.service.d.ts +42 -0
  99. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  100. package/dist/modules/workflow/git/git.service.js +201 -24
  101. package/dist/modules/workflow/git/git.service.js.map +1 -1
  102. package/dist/modules/workflow/index.d.ts +8 -0
  103. package/dist/modules/workflow/index.d.ts.map +1 -0
  104. package/dist/modules/workflow/index.js +8 -0
  105. package/dist/modules/workflow/index.js.map +1 -0
  106. package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
  107. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  108. package/dist/modules/workflow/pending-commits.service.js +4 -24
  109. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  110. package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
  111. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  112. package/dist/modules/workflow/workflow.routes.js +20 -1
  113. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  114. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  115. package/dist/modules/workflow/workflow.service.js +5 -3
  116. package/dist/modules/workflow/workflow.service.js.map +1 -1
  117. package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
  118. package/dist/modules/workspace/agent-access.gate.js +2 -2
  119. package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
  120. package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
  121. package/dist/modules/workspace/bounded-matcher.js +261 -0
  122. package/dist/modules/workspace/bounded-matcher.js.map +1 -0
  123. package/dist/modules/workspace/fetch-instant.d.ts +9 -0
  124. package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
  125. package/dist/modules/workspace/fetch-instant.js +13 -0
  126. package/dist/modules/workspace/fetch-instant.js.map +1 -0
  127. package/dist/modules/workspace/index.d.ts +17 -0
  128. package/dist/modules/workspace/index.d.ts.map +1 -0
  129. package/dist/modules/workspace/index.js +16 -0
  130. package/dist/modules/workspace/index.js.map +1 -0
  131. package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
  132. package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
  133. package/dist/modules/workspace/kb-repository.contract.js +19 -0
  134. package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
  135. package/dist/modules/workspace/kb-repository.d.ts +236 -0
  136. package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
  137. package/dist/modules/workspace/kb-repository.js +811 -0
  138. package/dist/modules/workspace/kb-repository.js.map +1 -0
  139. package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
  140. package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
  141. package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
  142. package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
  143. package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
  144. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  145. package/dist/modules/workspace/routine-write-policy.js +0 -5
  146. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  147. package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
  148. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
  149. package/dist/modules/workspace/set-aside-clone.js +139 -0
  150. package/dist/modules/workspace/set-aside-clone.js.map +1 -1
  151. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
  152. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  153. package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
  154. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  155. package/dist/modules/workspace/workspace.service.d.ts +68 -32
  156. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  157. package/dist/modules/workspace/workspace.service.js +240 -161
  158. package/dist/modules/workspace/workspace.service.js.map +1 -1
  159. package/dist/modules/workspace/workspace.tools.d.ts +13 -4
  160. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  161. package/dist/modules/workspace/workspace.tools.js +268 -356
  162. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  163. package/dist/shared/workspace-id.d.ts +17 -0
  164. package/dist/shared/workspace-id.d.ts.map +1 -1
  165. package/dist/shared/workspace-id.js +24 -0
  166. package/dist/shared/workspace-id.js.map +1 -1
  167. package/dist/test-setup.js +4 -0
  168. package/dist/test-setup.js.map +1 -1
  169. package/mcp-app/page.html +142 -8
  170. package/package.json +3 -3
  171. package/src/__tests__/test-repositories.ts +22 -0
  172. package/src/__tests__/workspace-service.ts +69 -0
  173. package/src/core/__tests__/compose-embed.test.ts +65 -0
  174. package/src/core/__tests__/lifecycle.test.ts +45 -26
  175. package/src/core/catalog-revision.ts +4 -2
  176. package/src/core/compose-embed.ts +50 -0
  177. package/src/core/core-ports.ts +10 -0
  178. package/src/core/create-core-server.ts +8 -2
  179. package/src/core/create-core-services.ts +63 -24
  180. package/src/core/lifecycle.ts +34 -12
  181. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +443 -25
  182. package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
  183. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
  184. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
  185. package/src/modules/access/access-control.interface.ts +22 -0
  186. package/src/modules/access/access-control.service.ts +302 -58
  187. package/src/modules/access/access.routes.ts +22 -6
  188. package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
  189. package/src/modules/auth/index.ts +29 -0
  190. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
  191. package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
  192. package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
  193. package/src/modules/diff/diff.service.ts +6 -4
  194. package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
  195. package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
  196. package/src/modules/embed/embed.interface.ts +24 -0
  197. package/src/modules/embed/embed.service.ts +44 -19
  198. package/src/modules/embed/embed.tools.ts +10 -2
  199. package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
  200. package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
  201. package/src/modules/kb-fs/git-dir.ts +53 -0
  202. package/src/modules/kb-fs/index.ts +8 -0
  203. package/src/modules/kb-fs/ref-files.ts +279 -0
  204. package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
  205. package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
  206. package/src/modules/plugins/join-request-jobs.service.ts +45 -8
  207. package/src/modules/plugins/teams.routes.ts +14 -1
  208. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
  209. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
  210. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
  211. package/src/modules/skills/skills.contract.ts +4 -0
  212. package/src/modules/skills/skills.tools.ts +120 -10
  213. package/src/modules/tool-auth/internal-token.service.ts +1 -1
  214. package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
  215. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
  216. package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
  217. package/src/modules/tool-helpers/default-branch-write.ts +90 -0
  218. package/src/modules/tool-helpers/index.ts +5 -0
  219. package/src/modules/tool-helpers/tool-handler.ts +2 -2
  220. package/src/modules/tool-helpers/tool.contract.ts +2 -2
  221. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +42 -3
  222. package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
  223. package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
  224. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
  225. package/src/modules/workflow/file-lock.service.ts +79 -18
  226. package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
  227. package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
  228. package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
  229. package/src/modules/workflow/git/git.service.ts +207 -22
  230. package/src/modules/workflow/index.ts +7 -0
  231. package/src/modules/workflow/pending-commits.service.ts +4 -23
  232. package/src/modules/workflow/workflow-hooks.ts +1 -1
  233. package/src/modules/workflow/workflow.routes.ts +24 -1
  234. package/src/modules/workflow/workflow.service.ts +3 -2
  235. package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
  236. package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
  237. package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
  238. package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
  239. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
  240. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
  241. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
  242. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
  243. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
  244. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
  245. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
  246. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
  247. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
  248. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
  249. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
  250. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
  251. package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
  252. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
  253. package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
  254. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
  255. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
  256. package/src/modules/workspace/__tests__/workspace.tools.test.ts +174 -309
  257. package/src/modules/workspace/agent-access.gate.ts +2 -2
  258. package/src/modules/workspace/bounded-matcher.ts +328 -0
  259. package/src/modules/workspace/fetch-instant.ts +13 -0
  260. package/src/modules/workspace/index.ts +26 -0
  261. package/src/modules/workspace/kb-repository.contract.ts +124 -0
  262. package/src/modules/workspace/kb-repository.ts +844 -0
  263. package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
  264. package/src/modules/workspace/routine-write-policy.ts +0 -15
  265. package/src/modules/workspace/set-aside-clone.ts +140 -0
  266. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
  267. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
  268. package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
  269. package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
  270. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
  271. package/src/modules/workspace/workspace.service.ts +247 -169
  272. package/src/modules/workspace/workspace.tools.ts +299 -385
  273. package/src/shared/workspace-id.ts +24 -0
  274. package/src/test-setup.ts +5 -0
@@ -18,11 +18,16 @@ import { AccessUnreadableError } from '../../access-model/access-errors.js';
18
18
  * the process boundary, which is the cost this cache exists to remove.
19
19
  * `injected.failNext` makes ONE matching spawn fail the way a transient git
20
20
  * error does, so the cache's behaviour under a failed read can be pinned
21
- * without a flaky fixture.
21
+ * without a flaky fixture. `injected.rewriteNext` lets ONE matching spawn
22
+ * run for real and rewrites what it printed before the service reads it,
23
+ * for replies a healthy git cannot be made to give.
22
24
  */
23
25
  const { spawnLog, injected } = vi.hoisted(() => ({
24
26
  spawnLog: [] as string[][],
25
- injected: { failNext: null as null | ((argv: string[]) => boolean) },
27
+ injected: {
28
+ failNext: null as null | ((argv: string[]) => boolean),
29
+ rewriteNext: null as null | ((argv: string[]) => null | ((stdout: Buffer, stdin: string) => Buffer)),
30
+ },
26
31
  }));
27
32
 
28
33
  vi.mock('node:child_process', async (importOriginal) => {
@@ -64,6 +69,41 @@ vi.mock('node:child_process', async (importOriginal) => {
64
69
  });
65
70
  return child as unknown as ReturnType<typeof actual.spawn>;
66
71
  };
72
+ type Child = ReturnType<typeof actual.spawn>;
73
+ const realSpawn = actual.spawn as unknown as (...a: unknown[]) => Child;
74
+ /**
75
+ * The real child, with what it was fed on stdin recorded and its stdout
76
+ * handed to `fn` whole, once it has exited, before the service sees it.
77
+ */
78
+ const rewriting = (file: string, args: string[], options: unknown, fn: (stdout: Buffer, stdin: string) => Buffer) => {
79
+ const real = realSpawn(file, args, options);
80
+ const fed: Buffer[] = [];
81
+ const out: Buffer[] = [];
82
+ const child = Object.assign(new EventEmitter(), {
83
+ pid: real.pid,
84
+ exitCode: null as number | null,
85
+ signalCode: null,
86
+ stdin: Object.assign(new EventEmitter(), {
87
+ write: (chunk: string | Buffer) => {
88
+ fed.push(Buffer.from(chunk));
89
+ return real.stdin!.write(chunk);
90
+ },
91
+ end: () => real.stdin!.end(),
92
+ }),
93
+ stdout: new PassThrough(),
94
+ stderr: new PassThrough(),
95
+ kill: (signal?: NodeJS.Signals | number) => real.kill(signal),
96
+ });
97
+ real.stdout!.on('data', (chunk: Buffer) => out.push(chunk));
98
+ real.stderr!.pipe(child.stderr);
99
+ real.on('error', (err) => child.emit('error', err));
100
+ real.on('close', (code, signal) => {
101
+ child.stdout.end(fn(Buffer.concat(out), Buffer.concat(fed).toString('utf-8')));
102
+ child.exitCode = code;
103
+ child.emit('close', code, signal);
104
+ });
105
+ return child as unknown as Child;
106
+ };
67
107
  const spawn = ((file: string, args: string[], options: unknown) => {
68
108
  if (file === 'git') {
69
109
  spawnLog.push(args ?? []);
@@ -71,8 +111,13 @@ vi.mock('node:child_process', async (importOriginal) => {
71
111
  injected.failNext = null;
72
112
  return failing();
73
113
  }
114
+ const rewrite = injected.rewriteNext?.(args);
115
+ if (rewrite) {
116
+ injected.rewriteNext = null;
117
+ return rewriting(file, args, options, rewrite);
118
+ }
74
119
  }
75
- return (actual.spawn as unknown as (...a: unknown[]) => unknown)(file, args, options);
120
+ return realSpawn(file, args, options);
76
121
  }) as unknown as typeof actual.spawn;
77
122
  return { ...actual, execFile, spawn };
78
123
  });
@@ -84,20 +129,63 @@ function lsTreeBuilds(): number {
84
129
  return spawnLog.filter((argv) => argv.includes('ls-tree')).length;
85
130
  }
86
131
 
87
- function rolesReads(): number {
88
- return spawnLog.filter((argv) => argv.includes('show') && argv.some((a) => a.endsWith(':roles.yaml')))
89
- .length;
132
+ /** Ref resolutions through git: none, since the ref files answer. */
133
+ function revParses(): number {
134
+ return spawnLog.filter((argv) => argv.includes('rev-parse')).length;
135
+ }
136
+
137
+ /** Batched reads of a commit's access tree: one per build. */
138
+ function catFileBatches(): number {
139
+ return spawnLog.filter((argv) => argv.includes('cat-file')).length;
140
+ }
141
+
142
+ /**
143
+ * Single-file reads of the MODEL's files at a ref (roles, groups, any
144
+ * access.md, any plugin manifest): none, since the build reads them in one
145
+ * batch. The one `show` a single-path gate makes afterwards, for the asked
146
+ * file's own frontmatter, is not the model's and is not counted.
147
+ */
148
+ function fileShows(): number {
149
+ return spawnLog.filter(
150
+ (argv) =>
151
+ argv.includes('show') &&
152
+ argv.some((a) => /:(roles\.yaml|groups\.yaml|synced-groups\.yaml|(.*\/)?access\.md|.*\/plugin\.json)$/.test(a)),
153
+ ).length;
154
+ }
155
+
156
+ /**
157
+ * Rewrite ONE record of a `git cat-file --batch` reply — the one answering
158
+ * the stdin spec `which` matches — as `<spec> missing`, or as the same
159
+ * object reported with type `tree`. The reply is index-aligned with the
160
+ * specs fed on stdin, each record `<oid> <type> <size>\n<size bytes>\n` or
161
+ * `<spec> missing\n`, so the records are walked by the specs.
162
+ */
163
+ function rewriteBatchRecord(which: RegExp, as: 'missing' | 'tree') {
164
+ return (stdout: Buffer, stdin: string): Buffer => {
165
+ const parts: Buffer[] = [];
166
+ let off = 0;
167
+ for (const spec of stdin.split('\n').filter(Boolean)) {
168
+ const nl = stdout.indexOf(0x0a, off);
169
+ const header = stdout.subarray(off, nl).toString('utf-8');
170
+ const end = header.endsWith(' missing') ? nl + 1 : nl + 1 + Number(header.split(' ')[2]) + 1;
171
+ if (!which.test(spec)) parts.push(stdout.subarray(off, end));
172
+ else if (as === 'missing') parts.push(Buffer.from(`${spec} missing\n`));
173
+ else parts.push(Buffer.from(header.replace(' blob ', ' tree ')), stdout.subarray(nl, end));
174
+ off = end;
175
+ }
176
+ return Buffer.concat(parts);
177
+ };
90
178
  }
91
179
 
92
180
  /**
93
181
  * The at-ref access model — `canWriteAtRef` / `canWriteBatchAtRef` /
94
182
  * `eligibleWritersForPathsAtRef`, the gates every change-request read and
95
183
  * every approval click run — is a pure function of the tree at the commit
96
- * the ref resolves to. Building it costs one `ls-tree` plus one `git show`
97
- * per `access.md`, so it must be built ONCE per commit and shared by every
98
- * gate call that lands on that commit, while a push that moves the ref is
99
- * still seen on the very next call, and a build that saw a git read fail
100
- * is never pinned.
184
+ * the ref resolves to. Building it costs two git calls, one `ls-tree` plus
185
+ * one `cat-file --batch` for every file the model reads, so it must be built
186
+ * ONCE per commit and shared by every gate call that lands on that commit,
187
+ * while a push that moves the ref is still seen on the very next call, and a
188
+ * build that saw a git read fail is never pinned.
101
189
  *
102
190
  * Every test seeds its own bare origin, workspace clone, and second clone
103
191
  * (`other`, standing in for another user's push), so each one holds alone.
@@ -107,9 +195,12 @@ describe('AccessControlService — at-ref model cache', () => {
107
195
  let repo: string;
108
196
  let other: string;
109
197
  let svc: AccessControlService;
198
+ let stub: WorkspaceService;
110
199
  const workspaceId = 'ws-atref-cache';
111
200
  const admin = 'razvan@bevel.software';
112
201
  const DOC = 'Team/Doc.md';
202
+ const QUOTED_DIR = 'Équipe';
203
+ const QUOTED_DOC = `${QUOTED_DIR}/Doc.md`;
113
204
 
114
205
  async function git(cwd: string, ...args: string[]): Promise<string> {
115
206
  const { stdout } = await execFileAsync('git', ['-C', cwd, ...args], { encoding: 'utf-8' });
@@ -137,6 +228,14 @@ describe('AccessControlService — at-ref model cache', () => {
137
228
  await fs.writeFile(path.join(repo, 'access.md'), '---\nwrite:\n - Admin\n---\n');
138
229
  await fs.writeFile(path.join(repo, 'Team/access.md'), '---\nwrite:\n - Admin\n---\n');
139
230
  await fs.writeFile(path.join(repo, DOC), '# doc\n');
231
+ // A folder git would C-quote in its default listing, with an access.md
232
+ // that DENIES the admin: read as it is, or the denial is missed.
233
+ await fs.mkdir(path.join(repo, QUOTED_DIR), { recursive: true });
234
+ await fs.writeFile(path.join(repo, QUOTED_DIR, 'access.md'), '---\nwrite:\n - deny Admin\n---\n');
235
+ await fs.writeFile(path.join(repo, QUOTED_DOC), '# doc\n');
236
+ // A plugin, whose manifest the model reads too.
237
+ await fs.mkdir(path.join(repo, 'Plugins/Demo'), { recursive: true });
238
+ await fs.writeFile(path.join(repo, 'Plugins/Demo/plugin.json'), '{ "name": "Demo" }\n');
140
239
  await execFileAsync('git', ['init', '-b', 'main', repo]);
141
240
  await git(repo, 'config', 'user.email', 'test@example.com');
142
241
  await git(repo, 'config', 'user.name', 'Test');
@@ -149,7 +248,7 @@ describe('AccessControlService — at-ref model cache', () => {
149
248
  await git(other, 'config', 'user.email', 'other@example.com');
150
249
  await git(other, 'config', 'user.name', 'Other');
151
250
 
152
- const stub = {
251
+ stub = {
153
252
  getWorkspacePath: async () => workspaceDir,
154
253
  // The real service TTLs this at 30s; the stub always fetches so a push
155
254
  // from `other` is visible on the next gate call.
@@ -160,10 +259,12 @@ describe('AccessControlService — at-ref model cache', () => {
160
259
  svc = new AccessControlService(stub, PROCESS_MAP_DIR, new NodeFs());
161
260
  spawnLog.length = 0;
162
261
  injected.failNext = null;
262
+ injected.rewriteNext = null;
163
263
  });
164
264
 
165
265
  afterEach(async () => {
166
266
  injected.failNext = null;
267
+ injected.rewriteNext = null;
167
268
  // Spies are restored here, not at the end of the test that made them:
168
269
  // a failing assertion would otherwise leave a console.warn mock in place
169
270
  // for every test after it (this config sets no `restoreMocks`).
@@ -233,10 +334,13 @@ describe('AccessControlService — at-ref model cache', () => {
233
334
  const map = await svc.eligibleWritersForPathsAtRef(workspaceId, 'feature', [DOC]);
234
335
  expect(map).not.toBeNull();
235
336
  expect(map!.get(DOC)!.roles).toContain('Admin');
236
- expect(lsTreeBuilds()).toBe(1);
237
- // ...and the fallthrough result is cached under origin/feature's commit.
337
+ // Two commits were read: the local tip, found to carry no roles.yaml,
338
+ // then origin/feature's.
339
+ expect(lsTreeBuilds()).toBe(2);
340
+ // ...and both answers are remembered: the fallthrough result under
341
+ // origin/feature's commit, the local tip as a commit with no roles.
238
342
  expect(await svc.canWriteAtRef(workspaceId, 'feature', admin, DOC)).toBe(true);
239
- expect(lsTreeBuilds()).toBe(1);
343
+ expect(lsTreeBuilds()).toBe(2);
240
344
  });
241
345
 
242
346
  it('refuses to decide from a build that lost a git read, and never caches it', async () => {
@@ -246,18 +350,19 @@ describe('AccessControlService — at-ref model cache', () => {
246
350
  fs.writeFile(path.join(other, 'Team/access.md'), '---\nwrite:\n - deny Admin\n---\n'),
247
351
  );
248
352
 
249
- // Lose exactly that read, once, to a simulated transient failure. The
250
- // gate must not answer from the partial tree (which would grant what the
251
- // lost file denies): it fails closed with an AccessUnreadableError...
252
- injected.failNext = (argv) => argv.includes('show') && argv.some((a) => a.endsWith(':Team/access.md'));
353
+ // Lose the batched read of the tree, once, to a simulated transient
354
+ // failure. The gate must not answer from a partial tree (which would
355
+ // grant what the lost file denies): it fails closed with an
356
+ // AccessUnreadableError...
357
+ injected.failNext = (argv) => argv.includes('cat-file');
253
358
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
254
359
  await expect(svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).rejects.toBeInstanceOf(
255
360
  AccessUnreadableError,
256
361
  );
257
362
  expect(injected.failNext).toBeNull();
258
363
  expect(lsTreeBuilds()).toBe(1);
259
- // ...and says so in the logs, naming the read it lost.
260
- expect(warn).toHaveBeenCalledWith(expect.stringMatching(/Team\/access\.md failed; refusing to decide/));
364
+ // ...and says so in the logs.
365
+ expect(warn).toHaveBeenCalledWith(expect.stringMatching(/access tree failed; refusing to decide/));
261
366
 
262
367
  // Nothing was pinned: the next call rebuilds, answers correctly, and
263
368
  // that healthy build is the one that gets cached.
@@ -267,7 +372,7 @@ describe('AccessControlService — at-ref model cache', () => {
267
372
  expect(lsTreeBuilds()).toBe(2);
268
373
  });
269
374
 
270
- it('does not cache a commit with no roles.yaml, and keeps answering null for it', async () => {
375
+ it('remembers a commit with no roles.yaml, and keeps answering null for it without another read', async () => {
271
376
  await git(other, 'checkout', '-qb', 'bare');
272
377
  await git(other, 'rm', '-q', 'roles.yaml');
273
378
  await git(other, 'commit', '-qm', 'no roles here');
@@ -276,9 +381,322 @@ describe('AccessControlService — at-ref model cache', () => {
276
381
  spawnLog.length = 0;
277
382
  expect(await svc.canWriteAtRef(workspaceId, 'origin/bare', admin, DOC)).toBeNull();
278
383
  expect(await svc.canWriteAtRef(workspaceId, 'origin/bare', admin, DOC)).toBeNull();
279
- // Each call went back to git for roles.yaml (nothing was remembered),
280
- // and neither ever got as far as walking the tree.
281
- expect(rolesReads()).toBe(2);
384
+ // Git said "missing" for roles.yaml at that commit once, which is as
385
+ // final as the commit; the second call read nothing.
386
+ expect(lsTreeBuilds()).toBe(1);
387
+ expect(catFileBatches()).toBe(1);
388
+ expect(fileShows()).toBe(0);
389
+ });
390
+
391
+ it('reads the whole access tree at a commit in two git calls, never one per file', async () => {
392
+ // The seed holds roles.yaml and two access.md files; a read per file was
393
+ // what the build used to cost, and what a server forking a large heap
394
+ // cannot afford.
395
+ // One listing and one batch build the model; a second batch reads the
396
+ // asked path's own entries, once per commit and path (see the memo test).
397
+ expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
398
+ expect(lsTreeBuilds()).toBe(1);
399
+ expect(catFileBatches()).toBe(2);
400
+ expect(fileShows()).toBe(0);
401
+ // Both access files were read through the batch: Team's denial is seen.
402
+ await pushFromOther('deny Admin in Team', () =>
403
+ fs.writeFile(path.join(other, 'Team/access.md'), '---\nwrite:\n - deny Admin\n---\n'),
404
+ );
405
+ expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(false);
406
+ expect(lsTreeBuilds()).toBe(2);
407
+ expect(catFileBatches()).toBe(4);
408
+ expect(fileShows()).toBe(0);
409
+ });
410
+
411
+ it('honours an access.md under a folder git would quote in its listing', async () => {
412
+ // `git ls-tree` prints "\303\211quipe/access.md" unless told `-z`; read
413
+ // under that spelling nothing comes back, and a denial would be missed.
414
+ expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, QUOTED_DOC)).toBe(false);
415
+ expect(await svc.canWriteBatchAtRef(workspaceId, 'origin/main', admin, [QUOTED_DOC, DOC])).toEqual(
416
+ new Map([
417
+ [QUOTED_DOC, false],
418
+ [DOC, true],
419
+ ]),
420
+ );
421
+ expect(lsTreeBuilds()).toBe(1);
422
+ expect(fileShows()).toBe(0);
423
+ });
424
+
425
+ it('answers null for a ref whose roles.yaml is a directory, and never falls through to origin for it', async () => {
426
+ // `feature` on origin grants the admin; the LOCAL `feature` replaced
427
+ // roles.yaml with a directory of that name. That is malformed config, as
428
+ // a roles file that does not parse is: the gate answers null for the
429
+ // ref, rather than reading the next candidate and granting from it.
430
+ await git(repo, 'checkout', '-q', '-b', 'feature');
431
+ await git(repo, 'push', '-q', '-u', 'origin', 'feature');
432
+ await git(repo, 'rm', '-q', 'roles.yaml');
433
+ await fs.mkdir(path.join(repo, 'roles.yaml'));
434
+ await fs.writeFile(path.join(repo, 'roles.yaml/README.md'), 'not roles\n');
435
+ await git(repo, 'add', '-A');
436
+ await git(repo, 'commit', '-qm', 'roles.yaml is a folder now');
437
+
438
+ spawnLog.length = 0;
439
+ expect(await svc.canWriteAtRef(workspaceId, 'feature', admin, DOC)).toBeNull();
440
+ expect(lsTreeBuilds()).toBe(1);
441
+ });
442
+
443
+ it('refuses to decide, twice, for a ref whose roles.yaml blob is lost, and never falls through to a granting origin', async () => {
444
+ // `feature` on origin carries a roles.yaml that grants the admin. The
445
+ // LOCAL `feature` is one commit ahead, with a roles.yaml of its own
446
+ // whose blob is then lost from the object store (real git: the loose
447
+ // object file removed). The tree still names roles.yaml, so the batch's
448
+ // "missing" is an unreadable file, not an absent one: the build must
449
+ // fail closed on both attempts, remember nothing for the commit, and
450
+ // never read the next candidate, origin/feature, which would grant.
451
+ await git(repo, 'checkout', '-q', '-b', 'feature');
452
+ await fs.writeFile(path.join(repo, 'roles.yaml'), 'roles:\n Admin:\n - razvan@bevel.software\n# on origin\n');
453
+ await git(repo, 'commit', '-qam', 'roles on origin/feature');
454
+ await git(repo, 'push', '-q', '-u', 'origin', 'feature');
455
+ await fs.writeFile(path.join(repo, 'roles.yaml'), 'roles:\n Admin:\n - razvan@bevel.software\n# local only\n');
456
+ await git(repo, 'commit', '-qam', 'roles on the local feature tip');
457
+ const lost = await git(repo, 'rev-parse', 'HEAD:roles.yaml');
458
+ await fs.rm(path.join(repo, '.git/objects', lost.slice(0, 2), lost.slice(2)));
459
+
460
+ spawnLog.length = 0;
461
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
462
+ for (const attempt of [1, 2]) {
463
+ await expect(svc.canWriteAtRef(workspaceId, 'feature', admin, DOC)).rejects.toBeInstanceOf(
464
+ AccessUnreadableError,
465
+ );
466
+ // One listing per attempt: the local tip, and never origin/feature.
467
+ expect(lsTreeBuilds()).toBe(attempt);
468
+ expect(catFileBatches()).toBe(attempt);
469
+ }
470
+ expect(warn).toHaveBeenCalledWith(expect.stringMatching(/roles\.yaml is in the tree but its blob could not be read/));
471
+ });
472
+
473
+ it.each(['synced-groups.yaml', 'groups.yaml'])(
474
+ 'refuses to decide, twice, for a ref whose %s blob is lost, and never reads it as absent',
475
+ async (file) => {
476
+ // The groups source the model reads at origin/main — the synced file,
477
+ // which supersedes a manual one still naming a retired team, or the
478
+ // manual file alone — has its blob lost from the workspace's object
479
+ // store (real git: the loose object file removed after the fetch).
480
+ // The tree still names it, so the batch's "missing" is an unreadable
481
+ // file, not an absent one: the build fails closed on both attempts and
482
+ // remembers nothing. Reading the synced file as absent would revive
483
+ // the manual groups; reading the manual one as absent would drop what
484
+ // it grants.
485
+ await pushFromOther(`${file} on main`, async () => {
486
+ if (file === 'synced-groups.yaml') {
487
+ await fs.writeFile(path.join(other, 'synced-groups.yaml'), 'groups:\n Platform:\n - zoe@x.io\n');
488
+ }
489
+ await fs.writeFile(path.join(other, 'groups.yaml'), 'groups:\n Retired Team:\n - ghost@x.io\n');
490
+ });
491
+ await git(repo, 'fetch', '-q', 'origin');
492
+ const lost = await git(repo, 'rev-parse', `origin/main:${file}`);
493
+ await fs.rm(path.join(repo, '.git/objects', lost.slice(0, 2), lost.slice(2)));
494
+
495
+ spawnLog.length = 0;
496
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
497
+ for (const attempt of [1, 2]) {
498
+ await expect(svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).rejects.toBeInstanceOf(
499
+ AccessUnreadableError,
500
+ );
501
+ expect(lsTreeBuilds()).toBe(attempt);
502
+ expect(catFileBatches()).toBe(attempt);
503
+ }
504
+ expect(warn).toHaveBeenCalledWith(
505
+ expect.stringMatching(new RegExp(`${file.replaceAll('.', '\\.')} is in the tree but its blob could not be read`)),
506
+ );
507
+ },
508
+ );
509
+
510
+ it('refuses to decide when a groups source is a directory rather than a file', async () => {
511
+ await pushFromOther('groups.yaml as a folder', async () => {
512
+ await fs.mkdir(path.join(other, 'groups.yaml'));
513
+ await fs.writeFile(path.join(other, 'groups.yaml/README.md'), 'not groups\n');
514
+ });
515
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
516
+ await expect(svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).rejects.toBeInstanceOf(
517
+ AccessUnreadableError,
518
+ );
519
+ expect(warn).toHaveBeenCalledWith(expect.stringMatching(/groups\.yaml is a tree, not a file; refusing to decide/));
520
+ });
521
+
522
+ it.each([
523
+ ['access.md', 'missing', /:Team\/access\.md$/, /Team\/access\.md is in the tree but its blob could not be read/],
524
+ ['access.md', 'a tree', /:Team\/access\.md$/, /Team\/access\.md is a tree, not a file/],
525
+ ['plugin manifest', 'missing', /:Plugins\/Demo\/plugin\.json$/, /Plugins\/Demo\/plugin\.json is in the tree but its blob could not be read/],
526
+ ['plugin manifest', 'a tree', /:Plugins\/Demo\/plugin\.json$/, /Plugins\/Demo\/plugin\.json is a tree, not a file/],
527
+ ])('refuses to decide when a listed %s comes back %s from the batch, and never caches it', async (_what, how, spec, warned) => {
528
+ // A healthy git cannot be made to answer this: the listing names blobs
529
+ // only, and a commit's objects do not change between two reads of it.
530
+ // The batch's reply is rewritten at the process boundary instead, so the
531
+ // build's refusal of a listed path it could not read is pinned all the
532
+ // same: a model missing that file could grant what the file denies.
533
+ injected.rewriteNext = (argv) =>
534
+ argv.includes('cat-file') ? rewriteBatchRecord(spec, how === 'missing' ? 'missing' : 'tree') : null;
535
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
536
+ const batchesBefore = catFileBatches();
537
+ await expect(svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).rejects.toBeInstanceOf(
538
+ AccessUnreadableError,
539
+ );
540
+ expect(injected.rewriteNext).toBeNull();
541
+ expect(warn).toHaveBeenCalledWith(expect.stringMatching(warned));
542
+ // ...and nothing was pinned: the next call, with git answering in full,
543
+ // builds again and answers.
544
+ expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
545
+ // Three batches: the refused build, then the build that answered and the
546
+ // asked path's own entries.
547
+ expect(catFileBatches()).toBe(batchesBefore + 3);
548
+ });
549
+
550
+ it('resolves refs from the ref files and remembers own entries at a commit: a repeated gate call starts no git', async () => {
551
+ // First call at this commit: the model is built (two git calls) and the
552
+ // asked path's own entries are read (one batch). The refs themselves —
553
+ // `origin/main`, and the candidates a short name tries — come from the
554
+ // ref files, so no `rev-parse` runs at all.
555
+ expect(await svc.canWriteBatchAtRef(workspaceId, 'origin/main', admin, [DOC])).toEqual(new Map([[DOC, true]]));
556
+ expect(revParses()).toBe(0);
557
+ expect(lsTreeBuilds()).toBe(1);
558
+ expect(catFileBatches()).toBe(2);
559
+
560
+ // The same gate again, and the single-path gate for the same path: the
561
+ // model and the own entries are both remembered, so apart from the
562
+ // fixture's own fetch nothing is started.
563
+ spawnLog.length = 0;
564
+ expect(await svc.canWriteBatchAtRef(workspaceId, 'origin/main', admin, [DOC])).toEqual(new Map([[DOC, true]]));
565
+ expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
566
+ expect(await svc.canWriteAtRef(workspaceId, 'main', admin, DOC)).toBe(true);
567
+ expect(spawnLog.filter((argv) => !argv.includes('fetch'))).toEqual([]);
568
+
569
+ // A path not asked before at this commit is read, and only that path.
570
+ spawnLog.length = 0;
571
+ expect(await svc.canWriteBatchAtRef(workspaceId, 'origin/main', admin, [DOC, QUOTED_DOC])).toEqual(
572
+ new Map([
573
+ [DOC, true],
574
+ [QUOTED_DOC, false],
575
+ ]),
576
+ );
577
+ expect(catFileBatches()).toBe(1);
578
+ expect(revParses()).toBe(0);
579
+ });
580
+
581
+ it('asks git for a ref the files cannot decide, instead of reading it as missing', async () => {
582
+ // An annotated tag names a tag object, not a commit; the reader steps
583
+ // aside and the gate resolves it through `rev-parse <tag>^{commit}` —
584
+ // one process, the answer git gives, never "no such ref".
585
+ await git(repo, 'tag', '-a', 'v1', '-m', 'v1', 'origin/main');
586
+ expect(await svc.canWriteAtRef(workspaceId, 'v1', admin, DOC)).toBe(true);
587
+ expect(revParses()).toBe(1);
588
+ expect(lsTreeBuilds()).toBe(1);
589
+ // The same commit under its branch name: resolved from the files, the
590
+ // model already held.
591
+ spawnLog.length = 0;
592
+ expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
593
+ expect(revParses()).toBe(0);
594
+ expect(lsTreeBuilds()).toBe(0);
595
+ });
596
+
597
+ it('knows from the tree that a path is absent at a commit, and reads nothing for it', async () => {
598
+ // A change request that adds a file asks about it at the base, where it
599
+ // does not exist yet. The model's listing already named every path the
600
+ // commit carries, so the absence is a fact about the commit: remembered,
601
+ // and not read — on the first call or any later one.
602
+ const NEW = 'Team/New.md';
603
+ expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
604
+ spawnLog.length = 0;
605
+ expect(await svc.canWriteBatchAtRef(workspaceId, 'origin/main', admin, [NEW])).toEqual(new Map([[NEW, true]]));
606
+ expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, NEW)).toBe(true);
607
+ expect(catFileBatches()).toBe(0);
608
+ expect(spawnLog.filter((argv) => !argv.includes('fetch'))).toEqual([]);
609
+ });
610
+
611
+ it("keeps a commit's tree up to the cap and not past it, as an absent path's cost shows", async () => {
612
+ // The seed carries seven paths. A service that may hold seven keeps the
613
+ // tree, so an absent path is answered without a read; one that may hold
614
+ // six does not keep it, so the same path is read on every call.
615
+ const NEW = 'Team/New.md';
616
+ const limited = (treeIndexMaxPaths: number) =>
617
+ new AccessControlService(stub, PROCESS_MAP_DIR, new NodeFs(), [], undefined, undefined, { treeIndexMaxPaths });
618
+ const keeps = limited(7);
619
+ expect(await keeps.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
620
+ spawnLog.length = 0;
621
+ expect(await keeps.canWriteAtRef(workspaceId, 'origin/main', admin, NEW)).toBe(true);
622
+ expect(await keeps.canWriteAtRef(workspaceId, 'origin/main', admin, NEW)).toBe(true);
623
+ expect(catFileBatches()).toBe(0);
624
+ const drops = limited(6);
625
+ expect(await drops.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
626
+ spawnLog.length = 0;
627
+ expect(await drops.canWriteAtRef(workspaceId, 'origin/main', admin, NEW)).toBe(true);
628
+ expect(await drops.canWriteAtRef(workspaceId, 'origin/main', admin, NEW)).toBe(true);
629
+ expect(catFileBatches()).toBe(2);
630
+ });
631
+
632
+ it('lets the oldest models give their tree up past the budget, and they still answer', async () => {
633
+ // Seven paths at the first commit, eight at the second, a budget of ten:
634
+ // remembering the second puts the first past the budget. The first
635
+ // model keeps answering gates without a rebuild; only an absent path at
636
+ // it is read again, while at the second it costs nothing.
637
+ const NEW = 'Team/New.md';
638
+ const budgeted = new AccessControlService(stub, PROCESS_MAP_DIR, new NodeFs(), [], undefined, undefined, {
639
+ treeIndexBudget: 10,
640
+ });
641
+ const first = await git(repo, 'rev-parse', 'origin/main');
642
+ expect(await budgeted.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
643
+ await pushFromOther('a later page', () => fs.writeFile(path.join(other, 'Team/Later.md'), '# later\n'));
644
+ expect(await budgeted.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
645
+ const second = await git(repo, 'rev-parse', 'origin/main');
646
+ expect(second).not.toBe(first);
647
+ spawnLog.length = 0;
648
+ expect(await budgeted.canWriteAtRef(workspaceId, second, admin, NEW)).toBe(true);
649
+ expect(lsTreeBuilds()).toBe(0);
650
+ expect(catFileBatches()).toBe(0);
651
+ spawnLog.length = 0;
652
+ expect(await budgeted.canWriteAtRef(workspaceId, first, admin, NEW)).toBe(true);
282
653
  expect(lsTreeBuilds()).toBe(0);
654
+ expect(catFileBatches()).toBe(1);
655
+ });
656
+
657
+ it('remembers what a blob declares for itself, never a blob the batch could not read', async () => {
658
+ const SECRET = 'Team/Secret.md';
659
+ await pushFromOther('a page that denies the admin for itself', () =>
660
+ fs.writeFile(path.join(other, SECRET), '---\nwrite:\n - deny Admin\n---\n# secret\n'),
661
+ );
662
+ // Build the model first, so the next batch is the own-entries read.
663
+ expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
664
+ // The own-entries batch loses the blob (a lost object reads as missing,
665
+ // exactly like an absent path): answered as no own entries, as before —
666
+ // the inherited write stands — but NOT remembered for the commit.
667
+ injected.rewriteNext = (argv) => (argv.includes('cat-file') ? rewriteBatchRecord(/:Team\/Secret\.md$/, 'missing') : null);
668
+ expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, SECRET)).toBe(true);
669
+ expect(injected.rewriteNext).toBeNull();
670
+ // The next call reads the blob again, and its own denial now holds...
671
+ spawnLog.length = 0;
672
+ expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, SECRET)).toBe(false);
673
+ expect(catFileBatches()).toBe(1);
674
+ // ...and that answer, a fact about the commit, is remembered.
675
+ spawnLog.length = 0;
676
+ expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, SECRET)).toBe(false);
677
+ expect(catFileBatches()).toBe(0);
283
678
  });
679
+
680
+ it('holds the models of far more commits than one listing resolves', async () => {
681
+ // A listing resolves access at every open change request's two refs: a
682
+ // knowledge base with a hundred open requests spans a hundred commits.
683
+ // Under the old bound of 64 that evicted in a cycle, so every listing
684
+ // rebuilt every model. Each commit here is a distinct tip; after seventy
685
+ // of them, past that old bound, the first is still held.
686
+ const shas: string[] = [];
687
+ for (let i = 0; i < 70; i++) {
688
+ await fs.writeFile(path.join(other, 'Team/Doc.md'), `# doc ${i}\n`);
689
+ await git(other, 'add', '-A');
690
+ await git(other, 'commit', '-qm', `edit ${i}`);
691
+ shas.push(await git(other, 'rev-parse', 'HEAD'));
692
+ }
693
+ await git(other, 'push', '-q', 'origin', 'HEAD');
694
+ await git(repo, 'fetch', '-q', 'origin');
695
+ spawnLog.length = 0;
696
+ for (const sha of shas) expect(await svc.canWriteAtRef(workspaceId, sha, admin, DOC)).toBe(true);
697
+ expect(lsTreeBuilds()).toBe(70);
698
+ expect(await svc.canWriteAtRef(workspaceId, shas[0]!, admin, DOC)).toBe(true);
699
+ expect(await svc.canWriteAtRef(workspaceId, shas[35]!, admin, DOC)).toBe(true);
700
+ expect(lsTreeBuilds()).toBe(70);
701
+ }, 120_000);
284
702
  });
@@ -161,3 +161,76 @@ describe('canReadAsGroupBatch', () => {
161
161
  expect(verdict?.get('Plugins/GTM')).toBe(false);
162
162
  });
163
163
  });
164
+
165
+ describe('groupsOfUser', () => {
166
+ let root: string;
167
+ const workspaceId = 'ws-groups-of-user-1';
168
+
169
+ beforeEach(async () => {
170
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-groups-of-user-'));
171
+ });
172
+ afterEach(async () => {
173
+ await fs.rm(root, { recursive: true, force: true });
174
+ });
175
+
176
+ async function makeService() {
177
+ const workspaceDir = path.join(root, workspaceId);
178
+ const repo = path.join(workspaceDir, KB_DIR);
179
+ await fs.mkdir(repo, { recursive: true });
180
+ // `lee@x.io` holds Leads, the role that lists `group:Sales Team`, without
181
+ // being in the group: the role reaches the group, not the other way round.
182
+ await fs.writeFile(path.join(repo, 'roles.yaml'), ROLES_YAML.replace(' - group:Sales Team\n', ' - group:Sales Team\n - lee@x.io\n'));
183
+ await fs.writeFile(
184
+ path.join(repo, 'groups.yaml'),
185
+ `groups:\n Sales Team:\n - sam@x.io\n - sue@x.io\n Design:\n - dee@x.io\n - sue@x.io\n`,
186
+ );
187
+ await fs.writeFile(path.join(repo, 'access.md'), rules('write:\n - Admin\n'));
188
+ return new AccessControlService(stubWorkspaceService(workspaceId, workspaceDir), KB_DIR, new NodeFs());
189
+ }
190
+
191
+ it('names the groups a person is in, as groups.yaml spells them', async () => {
192
+ const svc = await makeService();
193
+ expect(await svc.groupsOfUser(workspaceId, 'sam@x.io')).toEqual(['Sales Team']);
194
+ expect((await svc.groupsOfUser(workspaceId, 'sue@x.io')).sort()).toEqual(['Design', 'Sales Team']);
195
+ });
196
+
197
+ it('matches the email in any case', async () => {
198
+ const svc = await makeService();
199
+ expect(await svc.groupsOfUser(workspaceId, 'Sam@X.io')).toEqual(['Sales Team']);
200
+ });
201
+
202
+ it('is empty for someone in no group, however many roles they hold', async () => {
203
+ const svc = await makeService();
204
+ expect(await svc.groupsOfUser(workspaceId, 'eng@x.io')).toEqual([]);
205
+ expect(await svc.groupsOfUser(workspaceId, 'admin@x.io')).toEqual([]);
206
+ });
207
+
208
+ it('does not count holding a role that names a group as being in it', async () => {
209
+ const svc = await makeService();
210
+ expect(await svc.groupsOfUser(workspaceId, 'lee@x.io')).toEqual([]);
211
+ });
212
+
213
+ describe('visibleGroups: the one answer the Groups tab, the share dialog and a grant use', () => {
214
+ // The active source's groups, in the order a caller happens to hold them.
215
+ const listed = ['Sales Team', 'Design'];
216
+
217
+ it('is every group, as given, for an admin', async () => {
218
+ const svc = await makeService();
219
+ expect(await svc.visibleGroups(workspaceId, 'admin@x.io', listed)).toEqual(listed);
220
+ });
221
+
222
+ it('is only the groups a person is in, in the order given, for anyone else', async () => {
223
+ const svc = await makeService();
224
+ expect(await svc.visibleGroups(workspaceId, 'sue@x.io', listed)).toEqual(['Sales Team', 'Design']);
225
+ expect(await svc.visibleGroups(workspaceId, 'sam@x.io', listed)).toEqual(['Sales Team']);
226
+ expect(await svc.visibleGroups(workspaceId, 'dee@x.io', listed)).toEqual(['Design']);
227
+ });
228
+
229
+ it('is empty for someone in no group, a role that names a group included', async () => {
230
+ const svc = await makeService();
231
+ expect(await svc.visibleGroups(workspaceId, 'eng@x.io', listed)).toEqual([]);
232
+ expect(await svc.visibleGroups(workspaceId, 'lee@x.io', listed)).toEqual([]);
233
+ expect(await svc.visibleGroups(workspaceId, 'admin@x.io', [])).toEqual([]);
234
+ });
235
+ });
236
+ });