@bevel-software/platform-core-backend 0.29.0 → 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 (181) hide show
  1. package/dist/__tests__/test-repositories.d.ts +3 -0
  2. package/dist/__tests__/test-repositories.d.ts.map +1 -0
  3. package/dist/__tests__/test-repositories.js +18 -0
  4. package/dist/__tests__/test-repositories.js.map +1 -0
  5. package/dist/core/create-core-server.js +1 -1
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +3 -0
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +32 -4
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/core/lifecycle.d.ts +8 -0
  12. package/dist/core/lifecycle.d.ts.map +1 -1
  13. package/dist/core/lifecycle.js +4 -0
  14. package/dist/core/lifecycle.js.map +1 -1
  15. package/dist/modules/access/access-control.interface.d.ts +20 -0
  16. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  17. package/dist/modules/access/access-control.service.d.ts +32 -1
  18. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  19. package/dist/modules/access/access-control.service.js +138 -16
  20. package/dist/modules/access/access-control.service.js.map +1 -1
  21. package/dist/modules/access/access.routes.d.ts.map +1 -1
  22. package/dist/modules/access/access.routes.js +20 -6
  23. package/dist/modules/access/access.routes.js.map +1 -1
  24. package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
  25. package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
  26. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  27. package/dist/modules/diff/diff.service.js +7 -1
  28. package/dist/modules/diff/diff.service.js.map +1 -1
  29. package/dist/modules/kb-fs/git-dir.d.ts +21 -0
  30. package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
  31. package/dist/modules/kb-fs/git-dir.js +58 -0
  32. package/dist/modules/kb-fs/git-dir.js.map +1 -0
  33. package/dist/modules/kb-fs/index.d.ts +2 -0
  34. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  35. package/dist/modules/kb-fs/index.js +2 -1
  36. package/dist/modules/kb-fs/index.js.map +1 -1
  37. package/dist/modules/kb-fs/ref-files.d.ts +63 -0
  38. package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
  39. package/dist/modules/kb-fs/ref-files.js +249 -0
  40. package/dist/modules/kb-fs/ref-files.js.map +1 -0
  41. package/dist/modules/plugins/teams.routes.d.ts +4 -0
  42. package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
  43. package/dist/modules/plugins/teams.routes.js +9 -1
  44. package/dist/modules/plugins/teams.routes.js.map +1 -1
  45. package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
  46. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
  47. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  48. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
  49. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  50. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
  51. package/dist/modules/tool-helpers/tool.contract.d.ts +2 -2
  52. package/dist/modules/workflow/git/git.service.d.ts +29 -0
  53. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  54. package/dist/modules/workflow/git/git.service.js +177 -24
  55. package/dist/modules/workflow/git/git.service.js.map +1 -1
  56. package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
  57. package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
  58. package/dist/modules/workspace/agent-access.gate.js +2 -2
  59. package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
  60. package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
  61. package/dist/modules/workspace/bounded-matcher.js +261 -0
  62. package/dist/modules/workspace/bounded-matcher.js.map +1 -0
  63. package/dist/modules/workspace/fetch-instant.d.ts +9 -0
  64. package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
  65. package/dist/modules/workspace/fetch-instant.js +13 -0
  66. package/dist/modules/workspace/fetch-instant.js.map +1 -0
  67. package/dist/modules/workspace/index.d.ts +7 -0
  68. package/dist/modules/workspace/index.d.ts.map +1 -1
  69. package/dist/modules/workspace/index.js +6 -0
  70. package/dist/modules/workspace/index.js.map +1 -1
  71. package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
  72. package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
  73. package/dist/modules/workspace/kb-repository.contract.js +19 -0
  74. package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
  75. package/dist/modules/workspace/kb-repository.d.ts +236 -0
  76. package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
  77. package/dist/modules/workspace/kb-repository.js +811 -0
  78. package/dist/modules/workspace/kb-repository.js.map +1 -0
  79. package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
  80. package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
  81. package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
  82. package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
  83. package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
  84. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  85. package/dist/modules/workspace/routine-write-policy.js +0 -5
  86. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  87. package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
  88. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
  89. package/dist/modules/workspace/set-aside-clone.js +139 -0
  90. package/dist/modules/workspace/set-aside-clone.js.map +1 -1
  91. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
  92. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  93. package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
  94. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  95. package/dist/modules/workspace/workspace.service.d.ts +68 -32
  96. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  97. package/dist/modules/workspace/workspace.service.js +240 -161
  98. package/dist/modules/workspace/workspace.service.js.map +1 -1
  99. package/dist/modules/workspace/workspace.tools.d.ts +13 -4
  100. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  101. package/dist/modules/workspace/workspace.tools.js +162 -336
  102. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  103. package/dist/test-setup.js +4 -0
  104. package/dist/test-setup.js.map +1 -1
  105. package/package.json +3 -3
  106. package/src/__tests__/test-repositories.ts +22 -0
  107. package/src/__tests__/workspace-service.ts +69 -0
  108. package/src/core/__tests__/lifecycle.test.ts +16 -0
  109. package/src/core/create-core-server.ts +1 -1
  110. package/src/core/create-core-services.ts +36 -3
  111. package/src/core/lifecycle.ts +10 -0
  112. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +144 -4
  113. package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
  114. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
  115. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
  116. package/src/modules/access/access-control.interface.ts +22 -0
  117. package/src/modules/access/access-control.service.ts +142 -14
  118. package/src/modules/access/access.routes.ts +22 -6
  119. package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
  120. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
  121. package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
  122. package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
  123. package/src/modules/diff/diff.service.ts +6 -4
  124. package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
  125. package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
  126. package/src/modules/kb-fs/git-dir.ts +53 -0
  127. package/src/modules/kb-fs/index.ts +2 -0
  128. package/src/modules/kb-fs/ref-files.ts +279 -0
  129. package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
  130. package/src/modules/plugins/teams.routes.ts +14 -1
  131. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
  132. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
  133. package/src/modules/tool-auth/internal-token.service.ts +1 -1
  134. package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
  135. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
  136. package/src/modules/tool-helpers/tool-handler.ts +2 -2
  137. package/src/modules/tool-helpers/tool.contract.ts +2 -2
  138. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +6 -1
  139. package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
  140. package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
  141. package/src/modules/workflow/git/git.service.ts +184 -22
  142. package/src/modules/workflow/workflow-hooks.ts +1 -1
  143. package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
  144. package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
  145. package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
  146. package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
  147. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
  148. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
  149. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
  150. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
  151. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
  152. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
  153. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
  154. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
  155. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
  156. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
  157. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
  158. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
  159. package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
  160. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
  161. package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
  162. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
  163. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
  164. package/src/modules/workspace/__tests__/workspace.tools.test.ts +153 -298
  165. package/src/modules/workspace/agent-access.gate.ts +2 -2
  166. package/src/modules/workspace/bounded-matcher.ts +328 -0
  167. package/src/modules/workspace/fetch-instant.ts +13 -0
  168. package/src/modules/workspace/index.ts +12 -0
  169. package/src/modules/workspace/kb-repository.contract.ts +124 -0
  170. package/src/modules/workspace/kb-repository.ts +844 -0
  171. package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
  172. package/src/modules/workspace/routine-write-policy.ts +0 -15
  173. package/src/modules/workspace/set-aside-clone.ts +140 -0
  174. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
  175. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
  176. package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
  177. package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
  178. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
  179. package/src/modules/workspace/workspace.service.ts +247 -169
  180. package/src/modules/workspace/workspace.tools.ts +179 -365
  181. package/src/test-setup.ts +5 -0
@@ -0,0 +1,69 @@
1
+ import path from 'node:path';
2
+ import { NodeGitRunner } from '../modules/workflow/git/node-git-runner.js';
3
+ import type { IGitRunner } from '../shared/git.contract.js';
4
+ import type { ITreeWalker } from '../shared/fs.contract.js';
5
+ import type { KbContext } from '../shared/kb-context.js';
6
+ import { KbRepository, repositoryDirFor } from '../modules/workspace/kb-repository.js';
7
+ import type { IKbRepository } from '../modules/workspace/kb-repository.contract.js';
8
+ import { WorkspaceService } from '../modules/workspace/workspace.service.js';
9
+ import { KbStartupRunner, type KbStartupRunnerOptions } from '../modules/workspace/startup/kb-startup-runner.js';
10
+ import { recordTestRepositoryRoot } from './test-repositories.js';
11
+
12
+ /**
13
+ * The repository a suite's services share when it builds them by hand: one
14
+ * next to the workspaces root (see `repositoryDirFor`), as the composition
15
+ * root would place it there for a deployment with no backups root. Removed
16
+ * once the file's suites are done (see `test-repositories.ts`): a suite's
17
+ * teardown removes its fixture root, not what sits beside it.
18
+ */
19
+ export function testKbRepository(
20
+ workspacesRoot: string,
21
+ kbDirName: string,
22
+ kbRepoUrl: string | (() => string),
23
+ gitRunner?: IGitRunner,
24
+ ): IKbRepository {
25
+ const dir = repositoryDirFor(workspacesRoot, kbDirName);
26
+ recordTestRepositoryRoot(path.dirname(dir));
27
+ return new KbRepository({
28
+ dir,
29
+ kbRepoUrl: typeof kbRepoUrl === 'function' ? kbRepoUrl : () => kbRepoUrl,
30
+ gitRunner: gitRunner ?? new NodeGitRunner(),
31
+ });
32
+ }
33
+
34
+ /**
35
+ * A WorkspaceService as a suite builds it: the constructor's arguments, with
36
+ * the repository — which only the composition root builds in the product —
37
+ * made here when the suite passes none.
38
+ */
39
+ export function newWorkspaceService(
40
+ workspacesRoot: string,
41
+ kbRepoUrl: string | (() => string),
42
+ kb: Pick<KbContext, 'kbDirName' | 'defaultBranch'>,
43
+ disk: ITreeWalker,
44
+ gitRunner: IGitRunner = new NodeGitRunner(),
45
+ setAsideRoot?: string,
46
+ aroundSetAside?: (workspaceId: string, move: () => Promise<void>) => Promise<void>,
47
+ repository?: IKbRepository,
48
+ ): WorkspaceService {
49
+ return new WorkspaceService(
50
+ workspacesRoot,
51
+ kbRepoUrl,
52
+ kb,
53
+ disk,
54
+ gitRunner,
55
+ setAsideRoot,
56
+ aroundSetAside,
57
+ repository ?? testKbRepository(workspacesRoot, kb.kbDirName, kbRepoUrl, gitRunner),
58
+ );
59
+ }
60
+
61
+ /** A KbStartupRunner as a suite builds it — the repository made here when the suite passes none. */
62
+ export function newKbStartupRunner(
63
+ opts: Omit<KbStartupRunnerOptions, 'repository'> & { repository?: IKbRepository },
64
+ ): KbStartupRunner {
65
+ return new KbStartupRunner({
66
+ ...opts,
67
+ repository: opts.repository ?? testKbRepository(opts.workspacesRoot, opts.kbDirName, opts.kbRepoUrl, opts.gitRunner),
68
+ });
69
+ }
@@ -5,6 +5,7 @@ import { createShutdown, stopCore, withStartupTask, type LeasedWorker } from '..
5
5
  import { holdCommitWorkerLease, holdable } from '../lifecycle.js';
6
6
  import { BevelSecretsVariableLoader, registerBevelSecretsVariableLoader } from '../../modules/secrets-vault/secrets-variable-loader.js';
7
7
  import type { AdvisoryLease } from '../../modules/database/advisory-lock.js';
8
+ import { WorkerPatternMatcher } from '../../modules/workspace/bounded-matcher.js';
8
9
 
9
10
  /** A lease the suite scripts: what each `tryAcquire` answers, and a way to lose it. */
10
11
  function fakeLease(answers: boolean[]) {
@@ -537,6 +538,21 @@ describe('stopCore', () => {
537
538
  ]);
538
539
  });
539
540
 
541
+ it('ends the grep matcher\'s workers, so an evicted graph leaves no search running behind it', async () => {
542
+ const g = graph();
543
+ const patternMatcher = new WorkerPatternMatcher();
544
+ // A search holding a worker for its whole 5 s budget when the graph is stopped.
545
+ const running = patternMatcher.search('^(a+)+$', '').matchingLines([`${'a'.repeat(39)}!`], 1);
546
+ await new Promise((r) => setTimeout(r, 100));
547
+ const started = Date.now();
548
+ await stopCore({ ...g.core, patternMatcher } as never);
549
+ await expect(running).rejects.toMatchObject({ status: 503, details: { code: 'search_busy' } });
550
+ expect(Date.now() - started).toBeLessThan(1_000);
551
+ // Closed for good: nothing after the stop starts a worker.
552
+ await expect(patternMatcher.search('b', '').matchingLines(['b'], 1)).rejects.toMatchObject({ status: 503 });
553
+ expect(g.order.at(-1)).toBe('db.end');
554
+ });
555
+
540
556
  it('does not wait past the budget for a worker that never stops, and still ends the pool', async () => {
541
557
  const g = graph();
542
558
  const core = { ...g.core, commitWorker: { stop: () => new Promise<void>(() => undefined) } };
@@ -475,7 +475,7 @@ export async function createCoreServer(
475
475
  // because they are the only ones that gate their whole payload on the
476
476
  // caller's read access, so they take the access service and nothing else.
477
477
  registerChangeRequestReadTools(core.toolRegistry, toolsRouter, ta, th, core.accessControl, core.kb);
478
- const workspaceTools = registerWorkspaceTools(core.toolRegistry, toolsRouter, ta, th, core.spillStore, core.docExtractService, core.accessControl, core.kb, agentAccessGate, core.routineWritePolicy, core.sessionSink, allowedToolsChecker, core.changeGate, core.agentUploadStore, core.agentGuide, core.agentDownloadStore, core.starterPackService, core.disk);
478
+ const workspaceTools = registerWorkspaceTools(core.toolRegistry, toolsRouter, ta, th, core.spillStore, core.docExtractService, core.accessControl, core.kb, agentAccessGate, core.routineWritePolicy, core.sessionSink, allowedToolsChecker, core.changeGate, core.agentUploadStore, core.agentGuide, core.agentDownloadStore, core.starterPackService, core.disk, core.patternMatcher);
479
479
  // `open_page` — the knowledge-base page shown inside a chat. Registered
480
480
  // here, on the same router as the file tools, because it answers with
481
481
  // `read_file`'s own read: the read hook, the access gate and the
@@ -17,6 +17,8 @@ import { getDb, type Database } from '../modules/database/connection.js';
17
17
  import { runCoreMigrations } from '../modules/database/migrate.js';
18
18
  import { coreMigrationsDir } from '../assets.js';
19
19
  import { WorkspaceService } from '../modules/workspace/workspace.service.js';
20
+ import { KbRepository, repositoryDirFor } from '../modules/workspace/kb-repository.js';
21
+ import type { IKbRepository } from '../modules/workspace/kb-repository.contract.js';
20
22
  import { AccountLinkService } from '../modules/embed/account-link.service.js';
21
23
  import type { EmbedService } from '../modules/embed/embed.service.js';
22
24
  import { composeEmbedService } from './compose-embed.js';
@@ -62,6 +64,8 @@ import { SpillStore } from '../modules/workspace/spill-store.js';
62
64
  import { AgentUploadStore, assertUploadsRootOutsideWorkspaces } from '../modules/workspace/agent-upload.store.js';
63
65
  import { AgentDownloadStore, type IAgentDownloadStore } from '../modules/workspace/agent-download.store.js';
64
66
  import { createDownloadFetcherIdentifier } from '../modules/workspace/agent-download.routes.js';
67
+ import { WorkerPatternMatcher } from '../modules/workspace/bounded-matcher.js';
68
+ import type { IPatternMatcher } from '../modules/workspace/pattern-matcher.contract.js';
65
69
  import type { Request } from 'express';
66
70
  import { DocExtractService } from '../modules/workspace/file-readers/doc-extract.service.js';
67
71
  import { UuidSessionSink, type ISessionSink } from '../modules/workspace/session-sink.js';
@@ -243,6 +247,8 @@ export interface CoreServices {
243
247
  agentUploadStore: AgentUploadStore;
244
248
  /** The bytes `request_file_download` captured, held until their one-time link is fetched or expires. */
245
249
  agentDownloadStore: IAgentDownloadStore;
250
+ /** Runs a caller's `grep` pattern off the request thread under a budget; its workers end when the graph stops. */
251
+ patternMatcher: IPatternMatcher;
246
252
  /**
247
253
  * Every user a download fetch identifies itself as, by its bearer and its
248
254
  * session cookie — none when it carries none that verifies — so the
@@ -570,6 +576,30 @@ export async function createCoreServices(
570
576
  * Under the backups root: a persistent volume of its own, and one nothing
571
577
  * sweeps (see the runner's `reconcileClonesWithConfiguredRepository`).
572
578
  */
579
+ // Shared mutex so git and diff operations on the same workspace serialize
580
+ // against each other — a backup-reseed races a concurrent commit otherwise.
581
+ const workspaceMutex = new WorkspaceMutex();
582
+ /**
583
+ * The knowledge base's ONE repository: every branch workspace is a worktree
584
+ * of it (see `kb-repository.ts`). One instance, handed to the workspace
585
+ * service, the startup phase and (through the workspace service) the git
586
+ * layer, so one fetch serves every workspace and the writes to its one
587
+ * config file are serialized in one place. Git operations in the worktrees
588
+ * are not serialized against each other beyond the workspace mutex: git's
589
+ * own locks cover a repository shared by worktrees.
590
+ *
591
+ * Under the backups root, like the set-aside copies: a persistent volume in
592
+ * every shipped compose file, so no new mount is needed, and outside the
593
+ * workspaces root, where the orphan sweep never sees it. The folder name
594
+ * starts with a dot, which no branch — and so no backup ledger's workspace
595
+ * id — can. On hexis-cloud every tenant has its own backups root: one
596
+ * repository per tenant.
597
+ */
598
+ const kbRepository: IKbRepository = new KbRepository({
599
+ dir: repositoryDirFor(config.workspacesRoot, kbDirName, path.join(config.backupsRoot, '.kb-repository')),
600
+ kbRepoUrl: () => repositorySource.url(),
601
+ gitRunner,
602
+ });
573
603
  const replacedWorkingCopiesRoot = path.join(config.backupsRoot, 'replaced-working-copies');
574
604
  /**
575
605
  * What must leave with a working copy that is set aside, whoever sets it
@@ -635,6 +665,7 @@ export async function createCoreServices(
635
665
  );
636
666
  });
637
667
  }),
668
+ kbRepository,
638
669
  );
639
670
  // The KB startup phase: every seeding, scaffolding and migration concern,
640
671
  // run through one runner at the deployment's quiet moments (boot + setup
@@ -672,6 +703,9 @@ export async function createCoreServices(
672
703
  // The same folder the workspace service sets aside into — see
673
704
  // `replacedWorkingCopiesRoot` above for why the two must agree.
674
705
  setAsideRoot: replacedWorkingCopiesRoot,
706
+ // The one repository every branch workspace is a worktree of — the same
707
+ // instance the workspace service holds (see `kbRepository` above).
708
+ repository: kbRepository,
675
709
  kbDirName,
676
710
  templateDir: config.kbTemplateDir,
677
711
  defaultBranch: () => kb.defaultBranch,
@@ -741,6 +775,7 @@ export async function createCoreServices(
741
775
  'captured download bytes wait there for their one-time links, so a root inside a workspace would let the ' +
742
776
  'file tools read them. Point AGENT_UPLOADS_ROOT at a directory whose parent is outside WORKSPACES_ROOT.',
743
777
  });
778
+ const patternMatcher = new WorkerPatternMatcher();
744
779
  const agentDownloadStore = new AgentDownloadStore({
745
780
  root: agentDownloadsRoot,
746
781
  publicBaseUrl: config.publicBackendUrl,
@@ -827,9 +862,6 @@ export async function createCoreServices(
827
862
  // registers its listener here, after this returns (see `AccountHooks`).
828
863
  const accountHooks = new AccountHooks();
829
864
 
830
- // Shared mutex so git and diff operations on the same workspace serialize
831
- // against each other — a backup-reseed races a concurrent commit otherwise.
832
- const workspaceMutex = new WorkspaceMutex();
833
865
  // Workflow lifecycle hooks — the ONE registry this composition shares
834
866
  // between GitService (advisory commit validation), the agent-access gate
835
867
  // (the blocking agentRead / preWrite hooks), and the composition root,
@@ -1598,6 +1630,7 @@ export async function createCoreServices(
1598
1630
  spillStore,
1599
1631
  agentUploadStore,
1600
1632
  agentDownloadStore,
1633
+ patternMatcher,
1601
1634
  agentDownloadFetcher,
1602
1635
  docExtractService,
1603
1636
  accessControl,
@@ -448,6 +448,12 @@ export interface CoreStopDeps {
448
448
  agentUploadStore?: { stopSweeping(): void; drainSweep(): Promise<void> };
449
449
  /** The agent download store, whose sweep deletes captured bytes nobody fetched — stopped for the same reason. */
450
450
  agentDownloadStore?: { stopSweeping(): void; drainSweep(): Promise<void> };
451
+ /**
452
+ * The grep pattern matcher, whose worker threads are this graph's own: ended
453
+ * here so a graph a tenant host evicts leaves no worker behind. Optional: a
454
+ * caller that has not built one passes nothing.
455
+ */
456
+ patternMatcher?: { close(): Promise<void> };
451
457
  /**
452
458
  * The workflow service, for the tidy-up it runs after each sweep (closing
453
459
  * empty requests, removing leftover branches): detached from whatever
@@ -581,6 +587,10 @@ async function releaseCore(deps: CoreStopDeps, remaining: () => number, log: (m:
581
587
  if (deps.workflowService) {
582
588
  await bounded(deps.workflowService.drainTidy(), remaining(), 'finishing the branch tidy-up', log);
583
589
  }
590
+ // Its workers end with the graph; a search still running is answered busy.
591
+ if (deps.patternMatcher) {
592
+ await bounded(deps.patternMatcher.close(), remaining(), 'ending the search workers', log);
593
+ }
584
594
  await bounded(deps.commitWorker.stop(), remaining(), 'stopping the commit worker', log);
585
595
  if (deps.secretsScope !== undefined) unregisterBevelSecretsVariableLoader(deps.secretsScope);
586
596
  await bounded(closeDb(deps.db), remaining(), 'ending the database pool', log);
@@ -129,6 +129,11 @@ function lsTreeBuilds(): number {
129
129
  return spawnLog.filter((argv) => argv.includes('ls-tree')).length;
130
130
  }
131
131
 
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
+
132
137
  /** Batched reads of a commit's access tree: one per build. */
133
138
  function catFileBatches(): number {
134
139
  return spawnLog.filter((argv) => argv.includes('cat-file')).length;
@@ -190,6 +195,7 @@ describe('AccessControlService — at-ref model cache', () => {
190
195
  let repo: string;
191
196
  let other: string;
192
197
  let svc: AccessControlService;
198
+ let stub: WorkspaceService;
193
199
  const workspaceId = 'ws-atref-cache';
194
200
  const admin = 'razvan@bevel.software';
195
201
  const DOC = 'Team/Doc.md';
@@ -242,7 +248,7 @@ describe('AccessControlService — at-ref model cache', () => {
242
248
  await git(other, 'config', 'user.email', 'other@example.com');
243
249
  await git(other, 'config', 'user.name', 'Other');
244
250
 
245
- const stub = {
251
+ stub = {
246
252
  getWorkspacePath: async () => workspaceDir,
247
253
  // The real service TTLs this at 30s; the stub always fetches so a push
248
254
  // from `other` is visible on the next gate call.
@@ -386,9 +392,11 @@ describe('AccessControlService — at-ref model cache', () => {
386
392
  // The seed holds roles.yaml and two access.md files; a read per file was
387
393
  // what the build used to cost, and what a server forking a large heap
388
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).
389
397
  expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
390
398
  expect(lsTreeBuilds()).toBe(1);
391
- expect(catFileBatches()).toBe(1);
399
+ expect(catFileBatches()).toBe(2);
392
400
  expect(fileShows()).toBe(0);
393
401
  // Both access files were read through the batch: Team's denial is seen.
394
402
  await pushFromOther('deny Admin in Team', () =>
@@ -396,7 +404,7 @@ describe('AccessControlService — at-ref model cache', () => {
396
404
  );
397
405
  expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(false);
398
406
  expect(lsTreeBuilds()).toBe(2);
399
- expect(catFileBatches()).toBe(2);
407
+ expect(catFileBatches()).toBe(4);
400
408
  expect(fileShows()).toBe(0);
401
409
  });
402
410
 
@@ -534,7 +542,139 @@ describe('AccessControlService — at-ref model cache', () => {
534
542
  // ...and nothing was pinned: the next call, with git answering in full,
535
543
  // builds again and answers.
536
544
  expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
537
- expect(catFileBatches()).toBe(batchesBefore + 2);
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);
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);
538
678
  });
539
679
 
540
680
  it('holds the models of far more commits than one listing resolves', async () => {
@@ -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
+ });
@@ -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