@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.
- package/dist/__tests__/test-repositories.d.ts +3 -0
- package/dist/__tests__/test-repositories.d.ts.map +1 -0
- package/dist/__tests__/test-repositories.js +18 -0
- package/dist/__tests__/test-repositories.js.map +1 -0
- package/dist/core/create-core-server.js +1 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +3 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +32 -4
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +8 -0
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +4 -0
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +20 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +32 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +138 -16
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +20 -6
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
- package/dist/modules/diff/diff.service.d.ts.map +1 -1
- package/dist/modules/diff/diff.service.js +7 -1
- package/dist/modules/diff/diff.service.js.map +1 -1
- package/dist/modules/kb-fs/git-dir.d.ts +21 -0
- package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
- package/dist/modules/kb-fs/git-dir.js +58 -0
- package/dist/modules/kb-fs/git-dir.js.map +1 -0
- package/dist/modules/kb-fs/index.d.ts +2 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -1
- package/dist/modules/kb-fs/index.js +2 -1
- package/dist/modules/kb-fs/index.js.map +1 -1
- package/dist/modules/kb-fs/ref-files.d.ts +63 -0
- package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
- package/dist/modules/kb-fs/ref-files.js +249 -0
- package/dist/modules/kb-fs/ref-files.js.map +1 -0
- package/dist/modules/plugins/teams.routes.d.ts +4 -0
- package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
- package/dist/modules/plugins/teams.routes.js +9 -1
- package/dist/modules/plugins/teams.routes.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
- package/dist/modules/tool-helpers/tool.contract.d.ts +2 -2
- package/dist/modules/workflow/git/git.service.d.ts +29 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +177 -24
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
- package/dist/modules/workspace/agent-access.gate.js +2 -2
- package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
- package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
- package/dist/modules/workspace/bounded-matcher.js +261 -0
- package/dist/modules/workspace/bounded-matcher.js.map +1 -0
- package/dist/modules/workspace/fetch-instant.d.ts +9 -0
- package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
- package/dist/modules/workspace/fetch-instant.js +13 -0
- package/dist/modules/workspace/fetch-instant.js.map +1 -0
- package/dist/modules/workspace/index.d.ts +7 -0
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +6 -0
- package/dist/modules/workspace/index.js.map +1 -1
- package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
- package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
- package/dist/modules/workspace/kb-repository.contract.js +19 -0
- package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
- package/dist/modules/workspace/kb-repository.d.ts +236 -0
- package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
- package/dist/modules/workspace/kb-repository.js +811 -0
- package/dist/modules/workspace/kb-repository.js.map +1 -0
- package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
- package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
- package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
- package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
- package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
- package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
- package/dist/modules/workspace/routine-write-policy.js +0 -5
- package/dist/modules/workspace/routine-write-policy.js.map +1 -1
- package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
- package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
- package/dist/modules/workspace/set-aside-clone.js +139 -0
- package/dist/modules/workspace/set-aside-clone.js.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +68 -32
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +240 -161
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +13 -4
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +162 -336
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/test-setup.js +4 -0
- package/dist/test-setup.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/test-repositories.ts +22 -0
- package/src/__tests__/workspace-service.ts +69 -0
- package/src/core/__tests__/lifecycle.test.ts +16 -0
- package/src/core/create-core-server.ts +1 -1
- package/src/core/create-core-services.ts +36 -3
- package/src/core/lifecycle.ts +10 -0
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +144 -4
- package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
- package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
- package/src/modules/access/access-control.interface.ts +22 -0
- package/src/modules/access/access-control.service.ts +142 -14
- package/src/modules/access/access.routes.ts +22 -6
- package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
- package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
- package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
- package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
- package/src/modules/diff/diff.service.ts +6 -4
- package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
- package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
- package/src/modules/kb-fs/git-dir.ts +53 -0
- package/src/modules/kb-fs/index.ts +2 -0
- package/src/modules/kb-fs/ref-files.ts +279 -0
- package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
- package/src/modules/plugins/teams.routes.ts +14 -1
- package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
- package/src/modules/tool-auth/internal-token.service.ts +1 -1
- package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
- package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
- package/src/modules/tool-helpers/tool-handler.ts +2 -2
- package/src/modules/tool-helpers/tool.contract.ts +2 -2
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +6 -1
- package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
- package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
- package/src/modules/workflow/git/git.service.ts +184 -22
- package/src/modules/workflow/workflow-hooks.ts +1 -1
- package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
- package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
- package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
- package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
- package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
- package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
- package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
- package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
- package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
- package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +153 -298
- package/src/modules/workspace/agent-access.gate.ts +2 -2
- package/src/modules/workspace/bounded-matcher.ts +328 -0
- package/src/modules/workspace/fetch-instant.ts +13 -0
- package/src/modules/workspace/index.ts +12 -0
- package/src/modules/workspace/kb-repository.contract.ts +124 -0
- package/src/modules/workspace/kb-repository.ts +844 -0
- package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
- package/src/modules/workspace/routine-write-policy.ts +0 -15
- package/src/modules/workspace/set-aside-clone.ts +140 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
- package/src/modules/workspace/workspace.service.ts +247 -169
- package/src/modules/workspace/workspace.tools.ts +179 -365
- 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,
|
package/src/core/lifecycle.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|