@bevel-software/platform-core-backend 0.28.1 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-guide/access-control.md +15 -0
- package/agent-guide/skills.md +8 -0
- 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/catalog-revision.d.ts.map +1 -1
- package/dist/core/catalog-revision.js +4 -2
- package/dist/core/catalog-revision.js.map +1 -1
- package/dist/core/compose-embed.d.ts +35 -0
- package/dist/core/compose-embed.d.ts.map +1 -0
- package/dist/core/compose-embed.js +14 -0
- package/dist/core/compose-embed.js.map +1 -0
- package/dist/core/core-ports.d.ts +10 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +8 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +4 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +57 -18
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +19 -6
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +17 -6
- 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 +68 -15
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +282 -58
- 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/auth/index.d.ts +15 -0
- package/dist/modules/auth/index.d.ts.map +1 -0
- package/dist/modules/auth/index.js +20 -0
- package/dist/modules/auth/index.js.map +1 -0
- 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/embed/embed.interface.d.ts +22 -0
- package/dist/modules/embed/embed.interface.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.d.ts +28 -14
- package/dist/modules/embed/embed.service.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.js +38 -17
- package/dist/modules/embed/embed.service.js.map +1 -1
- package/dist/modules/embed/embed.tools.d.ts.map +1 -1
- package/dist/modules/embed/embed.tools.js +10 -2
- package/dist/modules/embed/embed.tools.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 +9 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -0
- package/dist/modules/kb-fs/index.js +3 -0
- package/dist/modules/kb-fs/index.js.map +1 -0
- 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/join-request-jobs.service.d.ts.map +1 -1
- package/dist/modules/plugins/join-request-jobs.service.js +43 -8
- package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
- 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/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.d.ts +8 -1
- package/dist/modules/skills/skills.tools.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.js +115 -11
- package/dist/modules/skills/skills.tools.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/default-branch-write.d.ts +69 -0
- package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
- package/dist/modules/tool-helpers/default-branch-write.js +63 -0
- package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
- package/dist/modules/tool-helpers/index.d.ts +1 -0
- package/dist/modules/tool-helpers/index.d.ts.map +1 -1
- package/dist/modules/tool-helpers/index.js +1 -0
- package/dist/modules/tool-helpers/index.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/file-lock.service.d.ts +27 -5
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +70 -18
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +42 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +201 -24
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/index.d.ts +8 -0
- package/dist/modules/workflow/index.d.ts.map +1 -0
- package/dist/modules/workflow/index.js +8 -0
- package/dist/modules/workflow/index.js.map +1 -0
- package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +4 -24
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +20 -1
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +5 -3
- package/dist/modules/workflow/workflow.service.js.map +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 +17 -0
- package/dist/modules/workspace/index.d.ts.map +1 -0
- package/dist/modules/workspace/index.js +16 -0
- package/dist/modules/workspace/index.js.map +1 -0
- 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 +268 -356
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/workspace-id.d.ts +17 -0
- package/dist/shared/workspace-id.d.ts.map +1 -1
- package/dist/shared/workspace-id.js +24 -0
- package/dist/shared/workspace-id.js.map +1 -1
- package/dist/test-setup.js +4 -0
- package/dist/test-setup.js.map +1 -1
- package/mcp-app/page.html +142 -8
- 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__/compose-embed.test.ts +65 -0
- package/src/core/__tests__/lifecycle.test.ts +45 -26
- package/src/core/catalog-revision.ts +4 -2
- package/src/core/compose-embed.ts +50 -0
- package/src/core/core-ports.ts +10 -0
- package/src/core/create-core-server.ts +8 -2
- package/src/core/create-core-services.ts +63 -24
- package/src/core/lifecycle.ts +34 -12
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +443 -25
- 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 +302 -58
- package/src/modules/access/access.routes.ts +22 -6
- package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
- package/src/modules/auth/index.ts +29 -0
- 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/embed/__tests__/embed.service.test.ts +76 -3
- package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
- package/src/modules/embed/embed.interface.ts +24 -0
- package/src/modules/embed/embed.service.ts +44 -19
- package/src/modules/embed/embed.tools.ts +10 -2
- 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 +8 -0
- package/src/modules/kb-fs/ref-files.ts +279 -0
- package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
- package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
- package/src/modules/plugins/join-request-jobs.service.ts +45 -8
- 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/skills/__tests__/branch-skills.tools.test.ts +1 -1
- package/src/modules/skills/skills.contract.ts +4 -0
- package/src/modules/skills/skills.tools.ts +120 -10
- 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/__tests__/can-write.tools.test.ts +581 -0
- package/src/modules/tool-helpers/default-branch-write.ts +90 -0
- package/src/modules/tool-helpers/index.ts +5 -0
- 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 +42 -3
- package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
- package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
- package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
- package/src/modules/workflow/file-lock.service.ts +79 -18
- package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
- package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
- package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
- package/src/modules/workflow/git/git.service.ts +207 -22
- package/src/modules/workflow/index.ts +7 -0
- package/src/modules/workflow/pending-commits.service.ts +4 -23
- package/src/modules/workflow/workflow-hooks.ts +1 -1
- package/src/modules/workflow/workflow.routes.ts +24 -1
- package/src/modules/workflow/workflow.service.ts +3 -2
- 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 +174 -309
- 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 +26 -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 +299 -385
- package/src/shared/workspace-id.ts +24 -0
- package/src/test-setup.ts +5 -0
|
@@ -6,21 +6,25 @@ import { isAbsence } from '../../shared/fs.contract.js';
|
|
|
6
6
|
import { assertOneSpecPerLine, isGitTimeout } from '../../shared/git.contract.js';
|
|
7
7
|
import { hashEmail } from '../../shared/email-identity.js';
|
|
8
8
|
import { NodeGitRunner } from '../workflow/git/node-git-runner.js';
|
|
9
|
+
import { readRevSha } from '../kb-fs/index.js';
|
|
9
10
|
import { DEFAULT_KB_LAYOUT, PLUGIN_MANIFEST_FILE, isPersonalPluginDir, pluginIdentityOf, platformRestoreDestination, } from '@bevel-software/platform-shared';
|
|
10
11
|
import { AccessConfigError, AccessUnreadableError } from '../access-model/access-errors.js';
|
|
11
12
|
import { synthesizePluginPrincipals } from '../access-model/plugin-principals.js';
|
|
12
13
|
import { GROUPS_YAML, SYNCED_GROUPS_YAML, parseGroupsFile, } from '../access-model/group-files.js';
|
|
13
14
|
import { ADMIN_CANONICAL, EVERYONE_CANONICAL, EVERYONE_DISPLAY, KNOWN_VERBS, ROLE_TOKEN_PREFIX, accessMdSelfEntries, canonicalEmail, canonicalRoleName, isAccessMdPath, mergeGroupsIntoRoles, parseAccessFile, parseOwnAccessEntries, parseRolesYaml, requiredVerbsFor, sourceVerbsFor, } from '../access-model/access-grammar.js';
|
|
14
15
|
import { DIRECTORY_SYNC_BOT_EMAIL, DIRECTORY_SYNC_BOT_NAME, } from './directory-sync-bot.js';
|
|
16
|
+
/** A full object id, which is what every at-ref gate resolves its ref to before reading. */
|
|
17
|
+
function isCommitSha(ref) {
|
|
18
|
+
return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(ref);
|
|
19
|
+
}
|
|
15
20
|
/**
|
|
16
21
|
* Read many objects from a git repo in ONE `git cat-file --batch` process.
|
|
17
22
|
* `specs` are `<ref>:<path>` lines; the result array is index-aligned with
|
|
18
|
-
* them
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* (the spec may itself contain spaces, hence the endsWith checks).
|
|
23
|
+
* them. Output protocol per request: `<oid> <type> <size>\n<size bytes>\n`,
|
|
24
|
+
* or `<spec> missing\n` (the spec may itself contain spaces, hence the
|
|
25
|
+
* endsWith checks).
|
|
22
26
|
*/
|
|
23
|
-
async function
|
|
27
|
+
async function catFileBatchOutcomes(runner, repoDir, specs) {
|
|
24
28
|
// Bytes, not text: every `<size>` in the header counts bytes, and the walk
|
|
25
29
|
// below has to step by that count through content that is not all ASCII.
|
|
26
30
|
// One spec per LINE is the protocol, and this walk depends on getting back
|
|
@@ -42,7 +46,7 @@ async function catFileBatch(runner, repoDir, specs) {
|
|
|
42
46
|
const header = out.subarray(off, nl).toString('utf-8');
|
|
43
47
|
off = nl + 1;
|
|
44
48
|
if (header.endsWith(' missing') || header.endsWith(' ambiguous')) {
|
|
45
|
-
results.push(
|
|
49
|
+
results.push({ kind: 'missing' });
|
|
46
50
|
continue;
|
|
47
51
|
}
|
|
48
52
|
const parts = header.split(' ');
|
|
@@ -50,13 +54,25 @@ async function catFileBatch(runner, repoDir, specs) {
|
|
|
50
54
|
if (parts.length !== 3 || !Number.isInteger(size) || size < 0) {
|
|
51
55
|
throw new Error(`unexpected git cat-file header: ${header}`);
|
|
52
56
|
}
|
|
53
|
-
//
|
|
54
|
-
// payload still has to be skipped to stay aligned.
|
|
55
|
-
results.push(parts[1] === 'blob'
|
|
57
|
+
// A non-blob (a directory path resolves to a tree) is reported as such,
|
|
58
|
+
// and its payload still has to be skipped to stay aligned.
|
|
59
|
+
results.push(parts[1] === 'blob'
|
|
60
|
+
? { kind: 'blob', text: out.subarray(off, off + size).toString('utf-8') }
|
|
61
|
+
: { kind: 'non-blob', type: parts[1] });
|
|
56
62
|
off += size + 1; // payload + trailing LF
|
|
57
63
|
}
|
|
58
64
|
return results;
|
|
59
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* {@link catFileBatchOutcomes} for callers that want text or nothing: the
|
|
68
|
+
* blob's text, or null when the spec is missing at the ref or resolves to a
|
|
69
|
+
* non-blob — the own-entries reads, where a directory where a file was
|
|
70
|
+
* named simply carries no entries.
|
|
71
|
+
*/
|
|
72
|
+
async function catFileBatch(runner, repoDir, specs) {
|
|
73
|
+
const outcomes = await catFileBatchOutcomes(runner, repoDir, specs);
|
|
74
|
+
return outcomes.map((o) => (o.kind === 'blob' ? o.text : null));
|
|
75
|
+
}
|
|
60
76
|
function isBuiltInRole(canonicalRole) {
|
|
61
77
|
return canonicalRole === EVERYONE_CANONICAL;
|
|
62
78
|
}
|
|
@@ -1107,22 +1123,49 @@ export class AccessControlService {
|
|
|
1107
1123
|
*
|
|
1108
1124
|
* This is what makes the change-request gates affordable. One detail read
|
|
1109
1125
|
* plus one approval click runs six at-ref gates, and before this cache
|
|
1110
|
-
* every one of them rebuilt the model from git
|
|
1111
|
-
* `
|
|
1112
|
-
*
|
|
1126
|
+
* every one of them rebuilt the model from git. A build is two git calls
|
|
1127
|
+
* (`buildModelAtCommit`: one `ls-tree`, one `cat-file --batch` for every
|
|
1128
|
+
* file the model reads), and each call forks the server process, which
|
|
1129
|
+
* costs tens of milliseconds on a server holding a large heap.
|
|
1113
1130
|
*
|
|
1114
|
-
* Bounded FIFO
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
1118
|
-
*
|
|
1119
|
-
*
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1131
|
+
* Bounded FIFO. The bound is sized for a listing that resolves access at
|
|
1132
|
+
* every open change request's two refs: one commit per open request plus
|
|
1133
|
+
* the targets, and a knowledge base with a hundred open requests was
|
|
1134
|
+
* evicting in a cycle under the old bound of 64, rebuilding every model on
|
|
1135
|
+
* every listing — thousands of forks, minutes of the main thread, and
|
|
1136
|
+
* every other request waiting behind them. A commit's tree never changes,
|
|
1137
|
+
* so a model is good for as long as it is held; the bound only caps memory.
|
|
1138
|
+
*
|
|
1139
|
+
* A commit with no roles.yaml is remembered too (`atRefNoRoles`): the batch
|
|
1140
|
+
* read answers "missing" for it from git itself, which is as final as the
|
|
1141
|
+
* commit. What is never cached is a build that saw a git read FAIL: it
|
|
1142
|
+
* throws AccessUnreadableError and the gate fails closed for that call, and
|
|
1143
|
+
* pinning that to a commit would turn one hiccup into a wrong verdict until
|
|
1144
|
+
* the branch moved. Concurrent misses on the same commit share one build
|
|
1145
|
+
* via `atRefInFlight`.
|
|
1122
1146
|
*/
|
|
1123
1147
|
atRefCache = new Map();
|
|
1148
|
+
atRefNoRoles = new Set();
|
|
1124
1149
|
atRefInFlight = new Map();
|
|
1125
|
-
static AT_REF_CACHE_MAX =
|
|
1150
|
+
static AT_REF_CACHE_MAX = 512;
|
|
1151
|
+
/**
|
|
1152
|
+
* A path's own access entries AT A COMMIT, keyed by commit + path. A blob
|
|
1153
|
+
* at a commit never changes, so neither does what it declares for itself:
|
|
1154
|
+
* no TTL, no invalidation, and no workspace in the key (every clone holds
|
|
1155
|
+
* the same objects). This is the read every at-ref gate makes after the
|
|
1156
|
+
* model: one `cat-file --batch` per call for the paths asked, which a
|
|
1157
|
+
* change-request listing made once per ref per listing for the same paths
|
|
1158
|
+
* at the same commits. Bounded FIFO; the bound only caps memory.
|
|
1159
|
+
*/
|
|
1160
|
+
ownEntriesAtCommit = new Map();
|
|
1161
|
+
static OWN_ENTRIES_AT_COMMIT_MAX = 16_384;
|
|
1162
|
+
/** Paths a cached model keeps of its commit's tree, at most; a larger tree is not kept. */
|
|
1163
|
+
static TREE_INDEX_MAX_PATHS = 20_000;
|
|
1164
|
+
/** Paths held across every cached model's tree, at most; past it the oldest models let their tree go. */
|
|
1165
|
+
static TREE_INDEX_BUDGET = 500_000;
|
|
1166
|
+
treeIndexMaxPaths;
|
|
1167
|
+
treeIndexBudget;
|
|
1168
|
+
treePathsHeld = 0;
|
|
1126
1169
|
/**
|
|
1127
1170
|
* Canonicalised deployment-owner emails (see `AccessModel.deploymentOwners`).
|
|
1128
1171
|
* Held on the service rather than read per-model because it comes from the
|
|
@@ -1151,13 +1194,22 @@ export class AccessControlService {
|
|
|
1151
1194
|
* `deploymentOwners`: the fixtures that construct this service directly
|
|
1152
1195
|
* run on the default layout.
|
|
1153
1196
|
*/
|
|
1154
|
-
kb = { layout: DEFAULT_KB_LAYOUT }
|
|
1197
|
+
kb = { layout: DEFAULT_KB_LAYOUT },
|
|
1198
|
+
/**
|
|
1199
|
+
* How much of the commits' trees the at-ref models may hold (see
|
|
1200
|
+
* `TREE_INDEX_MAX_PATHS`, `TREE_INDEX_BUDGET`). The defaults are the
|
|
1201
|
+
* deployment's; a suite lowers them to reach the bounds with a small
|
|
1202
|
+
* repository.
|
|
1203
|
+
*/
|
|
1204
|
+
limits = {}) {
|
|
1155
1205
|
this.workspaceService = workspaceService;
|
|
1156
1206
|
this.kbDirName = kbDirName;
|
|
1157
1207
|
this.disk = disk;
|
|
1158
1208
|
this.gitRunner = gitRunner;
|
|
1159
1209
|
this.kb = kb;
|
|
1160
1210
|
this.deploymentOwners = new Set(deploymentOwners.filter(Boolean).map((e) => canonicalEmail(e)));
|
|
1211
|
+
this.treeIndexMaxPaths = limits.treeIndexMaxPaths ?? AccessControlService.TREE_INDEX_MAX_PATHS;
|
|
1212
|
+
this.treeIndexBudget = limits.treeIndexBudget ?? AccessControlService.TREE_INDEX_BUDGET;
|
|
1161
1213
|
}
|
|
1162
1214
|
/**
|
|
1163
1215
|
* Drop a workspace's cached model + frontmatter memo. Call after operations
|
|
@@ -1272,6 +1324,24 @@ export class AccessControlService {
|
|
|
1272
1324
|
});
|
|
1273
1325
|
return result;
|
|
1274
1326
|
}
|
|
1327
|
+
async groupsOfUser(workspaceId, userEmail) {
|
|
1328
|
+
const model = await this.loadModel(workspaceId);
|
|
1329
|
+
const email = canonicalEmail(userEmail);
|
|
1330
|
+
const names = [];
|
|
1331
|
+
for (const principal of model.roles.byCanonical.values()) {
|
|
1332
|
+
if (principal.kind === 'group' && principal.emails.has(email))
|
|
1333
|
+
names.push(principal.displayName);
|
|
1334
|
+
}
|
|
1335
|
+
return names;
|
|
1336
|
+
}
|
|
1337
|
+
async visibleGroups(workspaceId, userEmail, groups) {
|
|
1338
|
+
if (groups.length === 0)
|
|
1339
|
+
return [];
|
|
1340
|
+
if (await this.holdsAdminRootWrite(workspaceId, userEmail))
|
|
1341
|
+
return [...groups];
|
|
1342
|
+
const mine = new Set(await this.groupsOfUser(workspaceId, userEmail));
|
|
1343
|
+
return groups.filter((g) => mine.has(g));
|
|
1344
|
+
}
|
|
1275
1345
|
async canWriteBatch(workspaceId, userEmail, relativePaths) {
|
|
1276
1346
|
const model = await this.loadModel(workspaceId);
|
|
1277
1347
|
const repoDir = await this.repoDir(workspaceId);
|
|
@@ -1650,7 +1720,7 @@ export class AccessControlService {
|
|
|
1650
1720
|
if (!loaded)
|
|
1651
1721
|
return null;
|
|
1652
1722
|
const repoDir = await this.repoDir(workspaceId);
|
|
1653
|
-
const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
|
|
1723
|
+
const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath, loaded.tree);
|
|
1654
1724
|
return hasPermissionResolved(loaded.model, 'write', userEmail, relativePath, own);
|
|
1655
1725
|
}
|
|
1656
1726
|
async canReadAtRef(workspaceId, ref, userEmail, relativePath) {
|
|
@@ -1658,7 +1728,7 @@ export class AccessControlService {
|
|
|
1658
1728
|
if (!loaded)
|
|
1659
1729
|
return null;
|
|
1660
1730
|
const repoDir = await this.repoDir(workspaceId);
|
|
1661
|
-
const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
|
|
1731
|
+
const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath, loaded.tree);
|
|
1662
1732
|
return canReadResolved(loaded.model, userEmail, relativePath, own);
|
|
1663
1733
|
}
|
|
1664
1734
|
async canWriteBatchAtRef(workspaceId, ref, userEmail, relativePaths) {
|
|
@@ -1678,7 +1748,7 @@ export class AccessControlService {
|
|
|
1678
1748
|
const repoDir = await this.repoDir(workspaceId);
|
|
1679
1749
|
// One `git cat-file --batch` for the whole path set — a per-path `git
|
|
1680
1750
|
// show` spawn made CR owner-routing take minutes on large change sets.
|
|
1681
|
-
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
|
|
1751
|
+
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths, loaded.tree);
|
|
1682
1752
|
const result = new Map();
|
|
1683
1753
|
for (const p of relativePaths) {
|
|
1684
1754
|
const machineOwned = machineAnswers.get(p);
|
|
@@ -1695,7 +1765,7 @@ export class AccessControlService {
|
|
|
1695
1765
|
return null;
|
|
1696
1766
|
const repoDir = await this.repoDir(workspaceId);
|
|
1697
1767
|
// One `git cat-file --batch` for the whole set — see canWriteBatchAtRef.
|
|
1698
|
-
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
|
|
1768
|
+
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths, loaded.tree);
|
|
1699
1769
|
const result = new Map();
|
|
1700
1770
|
for (const p of relativePaths) {
|
|
1701
1771
|
result.set(p, canReadResolved(loaded.model, userEmail, p, owns.get(p) ?? null));
|
|
@@ -1714,7 +1784,7 @@ export class AccessControlService {
|
|
|
1714
1784
|
if (!loaded)
|
|
1715
1785
|
return null;
|
|
1716
1786
|
const repoDir = await this.repoDir(workspaceId);
|
|
1717
|
-
const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
|
|
1787
|
+
const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath, loaded.tree);
|
|
1718
1788
|
return eligibleHoldersResolved(loaded.model, 'write', relativePath, own);
|
|
1719
1789
|
}
|
|
1720
1790
|
async holdsAdminRootWrite(workspaceId, userEmail) {
|
|
@@ -1806,7 +1876,7 @@ export class AccessControlService {
|
|
|
1806
1876
|
return null;
|
|
1807
1877
|
const repoDir = await this.repoDir(workspaceId);
|
|
1808
1878
|
// One `git cat-file --batch` for the whole path set — see canWriteBatchAtRef.
|
|
1809
|
-
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
|
|
1879
|
+
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths, loaded.tree);
|
|
1810
1880
|
for (const p of relativePaths) {
|
|
1811
1881
|
if (result.has(p))
|
|
1812
1882
|
continue;
|
|
@@ -2001,9 +2071,14 @@ export class AccessControlService {
|
|
|
2001
2071
|
* grant themselves rights by editing a file's frontmatter in a branch the
|
|
2002
2072
|
* gate isn't reading from.
|
|
2003
2073
|
*/
|
|
2004
|
-
async readOwnEntriesAtRef(repoDir, ref, relativePath) {
|
|
2074
|
+
async readOwnEntriesAtRef(repoDir, ref, relativePath, tree = null) {
|
|
2005
2075
|
if (relativePath === 'roles.yaml')
|
|
2006
2076
|
return null;
|
|
2077
|
+
// At a commit (what every gate passes: `resolvedRef`), the batched read
|
|
2078
|
+
// and its memo answer; a single `git show` stays for a symbolic ref.
|
|
2079
|
+
if (isCommitSha(ref)) {
|
|
2080
|
+
return (await this.readOwnEntriesAtRefBatch(repoDir, ref, [relativePath], tree)).get(relativePath) ?? null;
|
|
2081
|
+
}
|
|
2007
2082
|
const text = await this.showAtRef(repoDir, ref, relativePath);
|
|
2008
2083
|
if (text === null)
|
|
2009
2084
|
return null;
|
|
@@ -2019,13 +2094,31 @@ export class AccessControlService {
|
|
|
2019
2094
|
* `access.md`/`roles.yaml` (which carry no own-entries) resolve to null,
|
|
2020
2095
|
* mirroring the single-path helper.
|
|
2021
2096
|
*/
|
|
2022
|
-
async readOwnEntriesAtRefBatch(repoDir, ref, relativePaths) {
|
|
2097
|
+
async readOwnEntriesAtRefBatch(repoDir, ref, relativePaths, tree = null) {
|
|
2023
2098
|
const result = new Map();
|
|
2024
2099
|
const wanted = [];
|
|
2100
|
+
const memo = isCommitSha(ref) ? this.ownEntriesAtCommit : null;
|
|
2025
2101
|
for (const p of relativePaths) {
|
|
2026
|
-
if (p === 'roles.yaml')
|
|
2102
|
+
if (p === 'roles.yaml') {
|
|
2103
|
+
result.set(p, null);
|
|
2104
|
+
continue;
|
|
2105
|
+
}
|
|
2106
|
+
const key = `${ref}\0${p}`;
|
|
2107
|
+
if (memo?.has(key)) {
|
|
2108
|
+
result.set(p, memo.get(key) ?? null);
|
|
2109
|
+
continue;
|
|
2110
|
+
}
|
|
2111
|
+
// A path the commit's tree does not carry has no own entries, as a
|
|
2112
|
+
// fact about the commit: remembered, and not read. (A change request
|
|
2113
|
+
// that adds a file asks about it at the base, where it does not exist
|
|
2114
|
+
// yet; one that deletes a file asks at the head.)
|
|
2115
|
+
if (tree && !tree.has(p)) {
|
|
2027
2116
|
result.set(p, null);
|
|
2028
|
-
|
|
2117
|
+
if (memo)
|
|
2118
|
+
this.rememberOwnEntriesAtCommit(key, null);
|
|
2119
|
+
continue;
|
|
2120
|
+
}
|
|
2121
|
+
if (!wanted.includes(p))
|
|
2029
2122
|
wanted.push(p);
|
|
2030
2123
|
}
|
|
2031
2124
|
if (wanted.length === 0)
|
|
@@ -2033,13 +2126,30 @@ export class AccessControlService {
|
|
|
2033
2126
|
const texts = await catFileBatch(this.gitRunner, repoDir, wanted.map((p) => `${ref}:${p}`));
|
|
2034
2127
|
wanted.forEach((p, i) => {
|
|
2035
2128
|
const text = texts[i];
|
|
2036
|
-
if (text === null)
|
|
2129
|
+
if (text === null) {
|
|
2130
|
+
// Absent at the commit (when no tree was there to say so first), or
|
|
2131
|
+
// a blob git could not read: the batch says "missing" for both, and
|
|
2132
|
+
// only the first is a fact about the commit. Answered as no own
|
|
2133
|
+
// entries, as before, but never remembered.
|
|
2037
2134
|
result.set(p, null);
|
|
2038
|
-
|
|
2039
|
-
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
const own = isAccessMdPath(p) ? accessMdSelfEntries(text) : parseOwnAccessEntries(text);
|
|
2138
|
+
result.set(p, own);
|
|
2139
|
+
// What a blob declares for itself — including nothing — is a fact about the commit.
|
|
2140
|
+
if (memo)
|
|
2141
|
+
this.rememberOwnEntriesAtCommit(`${ref}\0${p}`, own);
|
|
2040
2142
|
});
|
|
2041
2143
|
return result;
|
|
2042
2144
|
}
|
|
2145
|
+
rememberOwnEntriesAtCommit(key, value) {
|
|
2146
|
+
this.ownEntriesAtCommit.set(key, value);
|
|
2147
|
+
if (this.ownEntriesAtCommit.size > AccessControlService.OWN_ENTRIES_AT_COMMIT_MAX) {
|
|
2148
|
+
const oldest = this.ownEntriesAtCommit.keys().next().value;
|
|
2149
|
+
if (oldest !== undefined)
|
|
2150
|
+
this.ownEntriesAtCommit.delete(oldest);
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2043
2153
|
/**
|
|
2044
2154
|
* Resolve a candidate ref to one git accepts. Falls back to `origin/<ref>`
|
|
2045
2155
|
* for short branch names, matching the heuristic in
|
|
@@ -2083,27 +2193,39 @@ export class AccessControlService {
|
|
|
2083
2193
|
/**
|
|
2084
2194
|
* List every `access.md` path that exists at any depth as of `ref`.
|
|
2085
2195
|
* The access tree is structure-agnostic: any `access.md` participates
|
|
2086
|
-
* regardless of where it sits. Uses `git ls-tree -r --name-only
|
|
2087
|
-
*
|
|
2088
|
-
*
|
|
2196
|
+
* regardless of where it sits. Uses `git ls-tree -r -z --name-only`: `-z`
|
|
2197
|
+
* puts a NUL between paths and prints them as they are, where the default
|
|
2198
|
+
* output C-quotes a path with a non-ASCII or special character, which would
|
|
2199
|
+
* then name nothing when read back. `'error'` when git failed: a model
|
|
2200
|
+
* built without its access files is not the model, and the caller must not
|
|
2201
|
+
* cache it as one.
|
|
2089
2202
|
*/
|
|
2090
2203
|
async listAccessFilesAtRef(repoDir, ref) {
|
|
2091
2204
|
try {
|
|
2092
|
-
const { stdout } = await this.gitRunner.run(repoDir, ['ls-tree', '-r', '--name-only', ref]);
|
|
2205
|
+
const { stdout } = await this.gitRunner.run(repoDir, ['ls-tree', '-r', '-z', '--name-only', ref]);
|
|
2093
2206
|
const out = [];
|
|
2094
2207
|
const pluginDirs = [];
|
|
2208
|
+
const all = [];
|
|
2209
|
+
// The fixed-name sources the tree carries (roles.yaml, the groups
|
|
2210
|
+
// files): a read of one of these that comes back empty is "absent"
|
|
2211
|
+
// only when the tree does not name it.
|
|
2212
|
+
const named = new Set();
|
|
2095
2213
|
const { pluginsDir } = this.kb.layout;
|
|
2096
|
-
for (const
|
|
2097
|
-
const p = line.trim();
|
|
2214
|
+
for (const p of stdout.split('\0')) {
|
|
2098
2215
|
if (!p)
|
|
2099
2216
|
continue;
|
|
2217
|
+
// One past the cap is enough to know the tree is not kept.
|
|
2218
|
+
if (all.length <= this.treeIndexMaxPaths)
|
|
2219
|
+
all.push(p);
|
|
2100
2220
|
if (p === 'access.md' || p.endsWith('/access.md'))
|
|
2101
2221
|
out.push(p);
|
|
2222
|
+
else if (p === 'roles.yaml' || p === SYNCED_GROUPS_YAML || p === GROUPS_YAML)
|
|
2223
|
+
named.add(p);
|
|
2102
2224
|
else if (p.startsWith(`${pluginsDir}/`) && p.endsWith(`/${PLUGIN_MANIFEST_FILE}`)) {
|
|
2103
2225
|
pluginDirs.push(p.slice(0, -(PLUGIN_MANIFEST_FILE.length + 1)));
|
|
2104
2226
|
}
|
|
2105
2227
|
}
|
|
2106
|
-
return { accessFiles: out, pluginDirs };
|
|
2228
|
+
return { accessFiles: out, pluginDirs, named, all };
|
|
2107
2229
|
}
|
|
2108
2230
|
catch {
|
|
2109
2231
|
return 'error';
|
|
@@ -2148,6 +2270,10 @@ export class AccessControlService {
|
|
|
2148
2270
|
const hit = this.atRefCache.get(key);
|
|
2149
2271
|
if (hit)
|
|
2150
2272
|
return hit;
|
|
2273
|
+
// A commit known to carry no roles.yaml: the next candidate, without a
|
|
2274
|
+
// build (see `atRefNoRoles`).
|
|
2275
|
+
if (this.atRefNoRoles.has(key))
|
|
2276
|
+
continue;
|
|
2151
2277
|
// Registered BEFORE the first await on the build path, so concurrent
|
|
2152
2278
|
// misses on one commit share a single build instead of all racing past
|
|
2153
2279
|
// an empty in-flight check together.
|
|
@@ -2157,6 +2283,8 @@ export class AccessControlService {
|
|
|
2157
2283
|
.then((built) => {
|
|
2158
2284
|
if (typeof built !== 'string')
|
|
2159
2285
|
this.rememberAtRef(key, built);
|
|
2286
|
+
else if (built === 'no-roles')
|
|
2287
|
+
this.rememberNoRolesAtRef(key);
|
|
2160
2288
|
return built;
|
|
2161
2289
|
})
|
|
2162
2290
|
.finally(() => {
|
|
@@ -2178,6 +2306,16 @@ export class AccessControlService {
|
|
|
2178
2306
|
async revParseCommit(repoDir, ref) {
|
|
2179
2307
|
if (!ref || ref.startsWith('-'))
|
|
2180
2308
|
return null;
|
|
2309
|
+
// The ref files first (`readRevSha`): the names git would try, in git's
|
|
2310
|
+
// order, read without a process. "Missing" from the files is what
|
|
2311
|
+
// rev-parse answers nothing for; "unreadable" — a spelling the reader
|
|
2312
|
+
// does not resolve, a tag it cannot peel, a file it could not read — is
|
|
2313
|
+
// git's to decide, as the authority.
|
|
2314
|
+
const fromFiles = await readRevSha(repoDir, ref);
|
|
2315
|
+
if (fromFiles === 'missing')
|
|
2316
|
+
return null;
|
|
2317
|
+
if (fromFiles !== 'unreadable')
|
|
2318
|
+
return fromFiles.sha;
|
|
2181
2319
|
try {
|
|
2182
2320
|
const { stdout } = await this.gitRunner.run(repoDir, [
|
|
2183
2321
|
'rev-parse',
|
|
@@ -2199,10 +2337,32 @@ export class AccessControlService {
|
|
|
2199
2337
|
}
|
|
2200
2338
|
rememberAtRef(key, value) {
|
|
2201
2339
|
this.atRefCache.set(key, value);
|
|
2340
|
+
this.treePathsHeld += value.tree?.size ?? 0;
|
|
2202
2341
|
if (this.atRefCache.size > AccessControlService.AT_REF_CACHE_MAX) {
|
|
2203
2342
|
const oldest = this.atRefCache.keys().next().value;
|
|
2204
|
-
if (oldest !== undefined)
|
|
2343
|
+
if (oldest !== undefined) {
|
|
2344
|
+
this.treePathsHeld -= this.atRefCache.get(oldest)?.tree?.size ?? 0;
|
|
2205
2345
|
this.atRefCache.delete(oldest);
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
// The trees are a budget of their own, across every held model: past
|
|
2349
|
+
// it, the oldest models keep their model and let their tree go — the
|
|
2350
|
+
// gate still answers, absence is just no longer remembered for them.
|
|
2351
|
+
for (const held of this.atRefCache.values()) {
|
|
2352
|
+
if (this.treePathsHeld <= this.treeIndexBudget)
|
|
2353
|
+
break;
|
|
2354
|
+
if (!held.tree)
|
|
2355
|
+
continue;
|
|
2356
|
+
this.treePathsHeld -= held.tree.size;
|
|
2357
|
+
held.tree = null;
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
rememberNoRolesAtRef(key) {
|
|
2361
|
+
this.atRefNoRoles.add(key);
|
|
2362
|
+
if (this.atRefNoRoles.size > AccessControlService.AT_REF_CACHE_MAX) {
|
|
2363
|
+
const oldest = this.atRefNoRoles.values().next().value;
|
|
2364
|
+
if (oldest !== undefined)
|
|
2365
|
+
this.atRefNoRoles.delete(oldest);
|
|
2206
2366
|
}
|
|
2207
2367
|
}
|
|
2208
2368
|
/**
|
|
@@ -2212,6 +2372,15 @@ export class AccessControlService {
|
|
|
2212
2372
|
* means the commit carries no roles.yaml (the caller tries its next
|
|
2213
2373
|
* candidate); `'malformed'` means it does but it doesn't parse (the caller
|
|
2214
2374
|
* answers null, as it always has).
|
|
2375
|
+
*
|
|
2376
|
+
* TWO git calls, whatever the tree holds: one `ls-tree` to learn which
|
|
2377
|
+
* files the model is made of, then one `cat-file --batch` that reads all of
|
|
2378
|
+
* them — roles.yaml, the groups sources, every access.md and every plugin
|
|
2379
|
+
* manifest. It used to be one `git show` per file, and each git call forks
|
|
2380
|
+
* the server process, which on a server holding a large heap costs tens of
|
|
2381
|
+
* milliseconds of the main thread per call: a knowledge base with a few
|
|
2382
|
+
* dozen access files paid a second or more per commit, times every ref a
|
|
2383
|
+
* listing resolves.
|
|
2215
2384
|
*/
|
|
2216
2385
|
async buildModelAtCommit(repoDir, commit, label) {
|
|
2217
2386
|
// Every read below tells "absent" apart from "git failed". A failure
|
|
@@ -2220,20 +2389,76 @@ export class AccessControlService {
|
|
|
2220
2389
|
// and the caller fails closed (AccessUnreadableError, a 503) until a
|
|
2221
2390
|
// retry reads the tree in full.
|
|
2222
2391
|
const tag = `${label}@${commit.slice(0, 7)}`;
|
|
2392
|
+
const listed = await this.listAccessFilesAtRef(repoDir, commit);
|
|
2393
|
+
if (listed === 'error') {
|
|
2394
|
+
logger(`access@${tag}`).warn('listing access.md files failed; refusing to decide from a partial tree');
|
|
2395
|
+
throw new AccessUnreadableError(label, 'access.md (ls-tree)');
|
|
2396
|
+
}
|
|
2397
|
+
const wanted = [
|
|
2398
|
+
'roles.yaml',
|
|
2399
|
+
SYNCED_GROUPS_YAML,
|
|
2400
|
+
GROUPS_YAML,
|
|
2401
|
+
...listed.accessFiles,
|
|
2402
|
+
...listed.pluginDirs.map((dir) => `${dir}/${PLUGIN_MANIFEST_FILE}`),
|
|
2403
|
+
];
|
|
2404
|
+
let outcomes;
|
|
2405
|
+
try {
|
|
2406
|
+
outcomes = await catFileBatchOutcomes(this.gitRunner, repoDir, wanted.map((p) => `${commit}:${p}`));
|
|
2407
|
+
}
|
|
2408
|
+
catch (err) {
|
|
2409
|
+
// The operational signal: one line per failed build, naming the commit,
|
|
2410
|
+
// so a run of these is visible in the logs.
|
|
2411
|
+
logger(`access@${tag}`).warn(`git read of the access tree failed; refusing to decide from a partial tree: ${err instanceof Error ? err.message : String(err)}`);
|
|
2412
|
+
throw new AccessUnreadableError(label, 'access tree (cat-file)');
|
|
2413
|
+
}
|
|
2414
|
+
const fetched = new Map();
|
|
2415
|
+
wanted.forEach((p, i) => fetched.set(p, outcomes[i] ?? { kind: 'missing' }));
|
|
2416
|
+
const refuse = (relativePath, why) => {
|
|
2417
|
+
logger(`access@${tag}`).warn(`${relativePath} ${why}; refusing to decide from a partial tree`);
|
|
2418
|
+
throw new AccessUnreadableError(label, relativePath);
|
|
2419
|
+
};
|
|
2420
|
+
// What the build reads. "Missing" from the batch is "absent" only for a
|
|
2421
|
+
// groups source the tree does not name. A path the tree names — an
|
|
2422
|
+
// access.md, a plugin manifest, or a groups file the listing carried —
|
|
2423
|
+
// that comes back missing has a blob git could not read (a lost object),
|
|
2424
|
+
// and one that is not a file is a tree the model cannot be read from:
|
|
2425
|
+
// either way the build fails closed rather than drop what that file
|
|
2426
|
+
// would deny. A path the build did not prefetch (none today) is read on
|
|
2427
|
+
// its own, told apart from a git failure as before.
|
|
2428
|
+
const inTree = (relativePath) => listed.named.has(relativePath) || !(relativePath === SYNCED_GROUPS_YAML || relativePath === GROUPS_YAML);
|
|
2223
2429
|
const read = async (relativePath) => {
|
|
2224
|
-
const
|
|
2225
|
-
if (
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2430
|
+
const got = fetched.get(relativePath);
|
|
2431
|
+
if (got === undefined) {
|
|
2432
|
+
const outcome = await this.showAtRefOutcome(repoDir, commit, relativePath);
|
|
2433
|
+
if (outcome.kind === 'error')
|
|
2434
|
+
refuse(relativePath, 'could not be read');
|
|
2435
|
+
return outcome.kind === 'text' ? outcome.text : null;
|
|
2230
2436
|
}
|
|
2231
|
-
|
|
2437
|
+
if (got.kind === 'blob')
|
|
2438
|
+
return got.text;
|
|
2439
|
+
if (got.kind === 'non-blob')
|
|
2440
|
+
return refuse(relativePath, `is a ${got.type}, not a file`);
|
|
2441
|
+
if (!inTree(relativePath))
|
|
2442
|
+
return null;
|
|
2443
|
+
return refuse(relativePath, 'is in the tree but its blob could not be read');
|
|
2232
2444
|
};
|
|
2233
|
-
|
|
2234
|
-
|
|
2445
|
+
// roles.yaml decides the build's outcome, so its states are read
|
|
2446
|
+
// directly. Absent from the tree is "no roles here" (the caller tries
|
|
2447
|
+
// its next candidate ref). In the tree but unreadable refuses the build:
|
|
2448
|
+
// answering "no roles" for it would be remembered for the commit and
|
|
2449
|
+
// would send a short ref through to origin, which may grant. A directory
|
|
2450
|
+
// of that name is as malformed as a file that does not parse (the caller
|
|
2451
|
+
// answers null, and never falls through to a ref that might grant what
|
|
2452
|
+
// this one does not).
|
|
2453
|
+
const roles = fetched.get('roles.yaml') ?? { kind: 'missing' };
|
|
2454
|
+
if (roles.kind === 'missing') {
|
|
2455
|
+
if (listed.named.has('roles.yaml'))
|
|
2456
|
+
refuse('roles.yaml', 'is in the tree but its blob could not be read');
|
|
2235
2457
|
return 'no-roles';
|
|
2236
|
-
|
|
2458
|
+
}
|
|
2459
|
+
if (roles.kind === 'non-blob')
|
|
2460
|
+
return 'malformed';
|
|
2461
|
+
const rolesParsed = parseRolesYaml(roles.text);
|
|
2237
2462
|
if (!rolesParsed.ok)
|
|
2238
2463
|
return 'malformed';
|
|
2239
2464
|
// Same group loading as the working-tree model, read AT THE COMMIT — the
|
|
@@ -2253,11 +2478,6 @@ export class AccessControlService {
|
|
|
2253
2478
|
for (const w of mergeWarnings)
|
|
2254
2479
|
logger(`access@${tag}`).warn(w);
|
|
2255
2480
|
const accessFiles = new Map();
|
|
2256
|
-
const listed = await this.listAccessFilesAtRef(repoDir, commit);
|
|
2257
|
-
if (listed === 'error') {
|
|
2258
|
-
logger(`access@${tag}`).warn('listing access.md files failed; refusing to decide from a partial tree');
|
|
2259
|
-
throw new AccessUnreadableError(label, 'access.md (ls-tree)');
|
|
2260
|
-
}
|
|
2261
2481
|
for (const p of listed.accessFiles) {
|
|
2262
2482
|
const text = await read(p);
|
|
2263
2483
|
if (text === null)
|
|
@@ -2299,6 +2519,10 @@ export class AccessControlService {
|
|
|
2299
2519
|
deploymentOwners: this.deploymentOwners,
|
|
2300
2520
|
},
|
|
2301
2521
|
resolvedRef: commit,
|
|
2522
|
+
// The listing already named every path; held so an own-entries read
|
|
2523
|
+
// can tell absence from a lost blob, unless the tree is too large to
|
|
2524
|
+
// hold for every cached commit (then absence is simply not remembered).
|
|
2525
|
+
tree: listed.all.length <= this.treeIndexMaxPaths ? new Set(listed.all) : null,
|
|
2302
2526
|
};
|
|
2303
2527
|
}
|
|
2304
2528
|
}
|