@bevel-software/platform-core-backend 0.4.1 → 0.5.1
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/THIRD-PARTY-NOTICES.md +6118 -0
- package/dist/core/core-ports.d.ts +15 -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 +40 -6
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +17 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +153 -29
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts +70 -21
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +134 -58
- package/dist/core-config.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +6 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +74 -5
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +116 -15
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts +93 -0
- package/dist/modules/access/access-declarations.d.ts.map +1 -0
- package/dist/modules/access/access-declarations.js +168 -0
- package/dist/modules/access/access-declarations.js.map +1 -0
- package/dist/modules/access/access-mutation.service.js +4 -4
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.d.ts +16 -1
- package/dist/modules/access/access-splice.d.ts.map +1 -1
- package/dist/modules/access/access-splice.js +40 -21
- package/dist/modules/access/access-splice.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +51 -0
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/creator-access.d.ts +8 -0
- package/dist/modules/access/creator-access.d.ts.map +1 -1
- package/dist/modules/access/creator-access.js +21 -2
- package/dist/modules/access/creator-access.js.map +1 -1
- package/dist/modules/admin/admin-access.service.d.ts +1 -1
- package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
- package/dist/modules/admin/admin-access.service.js +3 -2
- package/dist/modules/admin/admin-access.service.js.map +1 -1
- package/dist/modules/auth/auth.routes.d.ts.map +1 -1
- package/dist/modules/auth/auth.routes.js +50 -1
- package/dist/modules/auth/auth.routes.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +39 -10
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +38 -16
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +124 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +45 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/groups/group-provision.service.d.ts +130 -0
- package/dist/modules/groups/group-provision.service.d.ts.map +1 -0
- package/dist/modules/groups/group-provision.service.js +288 -0
- package/dist/modules/groups/group-provision.service.js.map +1 -0
- package/dist/modules/groups/groups.contract.d.ts +106 -0
- package/dist/modules/groups/groups.contract.d.ts.map +1 -0
- package/dist/modules/groups/groups.contract.js +36 -0
- package/dist/modules/groups/groups.contract.js.map +1 -0
- package/dist/modules/groups/groups.routes.d.ts +42 -0
- package/dist/modules/groups/groups.routes.d.ts.map +1 -0
- package/dist/modules/groups/groups.routes.js +379 -0
- package/dist/modules/groups/groups.routes.js.map +1 -0
- package/dist/modules/groups/groups.service.d.ts +60 -0
- package/dist/modules/groups/groups.service.d.ts.map +1 -0
- package/dist/modules/groups/groups.service.js +172 -0
- package/dist/modules/groups/groups.service.js.map +1 -0
- package/dist/modules/groups/index.d.ts +7 -0
- package/dist/modules/groups/index.d.ts.map +1 -0
- package/dist/modules/groups/index.js +6 -0
- package/dist/modules/groups/index.js.map +1 -0
- package/dist/modules/groups/join-proposals.d.ts +53 -0
- package/dist/modules/groups/join-proposals.d.ts.map +1 -0
- package/dist/modules/groups/join-proposals.js +67 -0
- package/dist/modules/groups/join-proposals.js.map +1 -0
- package/dist/modules/groups/join-requests.service.d.ts +81 -0
- package/dist/modules/groups/join-requests.service.d.ts.map +1 -0
- package/dist/modules/groups/join-requests.service.js +135 -0
- package/dist/modules/groups/join-requests.service.js.map +1 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +20 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +59 -11
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +129 -0
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -0
- package/dist/modules/settings/deployment-settings.service.js +368 -0
- package/dist/modules/settings/deployment-settings.service.js.map +1 -0
- package/dist/modules/settings/setup.routes.d.ts +49 -0
- package/dist/modules/settings/setup.routes.d.ts.map +1 -0
- package/dist/modules/settings/setup.routes.js +292 -0
- package/dist/modules/settings/setup.routes.js.map +1 -0
- package/dist/modules/skills/index.d.ts +2 -1
- package/dist/modules/skills/index.d.ts.map +1 -1
- package/dist/modules/skills/index.js +1 -0
- package/dist/modules/skills/index.js.map +1 -1
- package/dist/modules/skills/pending-skills.service.d.ts +40 -0
- package/dist/modules/skills/pending-skills.service.d.ts.map +1 -0
- package/dist/modules/skills/pending-skills.service.js +147 -0
- package/dist/modules/skills/pending-skills.service.js.map +1 -0
- package/dist/modules/skills/skills.contract.d.ts +32 -3
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.contract.js +1 -1
- package/dist/modules/skills/skills.routes.d.ts +3 -2
- package/dist/modules/skills/skills.routes.d.ts.map +1 -1
- package/dist/modules/skills/skills.routes.js +18 -1
- package/dist/modules/skills/skills.routes.js.map +1 -1
- package/dist/modules/skills/skills.service.d.ts +22 -0
- package/dist/modules/skills/skills.service.d.ts.map +1 -1
- package/dist/modules/skills/skills.service.js +24 -12
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/tool-manuals/index.d.ts +1 -1
- package/dist/modules/tool-manuals/index.d.ts.map +1 -1
- package/dist/modules/tool-manuals/index.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +47 -2
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +4 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.js +23 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +3 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +84 -17
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -2
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +4 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/event-bus.d.ts +11 -0
- package/dist/modules/workflow/event-bus.d.ts.map +1 -1
- package/dist/modules/workflow/event-bus.js +22 -0
- package/dist/modules/workflow/event-bus.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +9 -0
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +16 -0
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/branch-name.d.ts +1 -1
- package/dist/modules/workflow/git/branch-name.d.ts.map +1 -1
- package/dist/modules/workflow/git/branch-name.js +1 -1
- package/dist/modules/workflow/git/branch-name.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +31 -2
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +144 -52
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +12 -6
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +19 -9
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +95 -14
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +92 -3
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +381 -108
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/bevel-ignore.d.ts +5 -0
- package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -1
- package/dist/modules/workspace/bevel-ignore.js +5 -1
- package/dist/modules/workspace/bevel-ignore.js.map +1 -1
- package/dist/modules/workspace/kb-seed.interface.d.ts +1 -1
- package/dist/modules/workspace/kb-seed.service.d.ts +60 -5
- package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
- package/dist/modules/workspace/kb-seed.service.js +228 -51
- package/dist/modules/workspace/kb-seed.service.js.map +1 -1
- package/dist/modules/workspace/workspace.routes.d.ts +2 -1
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +51 -18
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +16 -4
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +36 -9
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +8 -3
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/kb-layout.test.js +2 -2
- package/dist/test-setup.d.ts +2 -0
- package/dist/test-setup.d.ts.map +1 -0
- package/dist/test-setup.js +18 -0
- package/dist/test-setup.js.map +1 -0
- package/kb-template/.bevelignore +6 -2
- package/kb-template/AGENTS.md +272 -0
- package/kb-template/KnowledgeBase/How to get started.md +65 -0
- package/kb-template/access.md +36 -36
- package/migrations/0002_user_onboarding_done.sql +1 -0
- package/migrations/0003_deployment_settings.sql +9 -0
- package/migrations/meta/0001_snapshot.json +1486 -0
- package/migrations/meta/0002_snapshot.json +1493 -0
- package/migrations/meta/0003_snapshot.json +1552 -0
- package/migrations/meta/_journal.json +15 -1
- package/package.json +5 -4
- package/src/__tests__/branch-model.test.ts +79 -0
- package/src/__tests__/core-config.admin.test.ts +114 -0
- package/src/__tests__/database-url.test.ts +70 -0
- package/src/core/core-ports.ts +15 -0
- package/src/core/create-core-server.ts +55 -6
- package/src/core/create-core-services.ts +201 -36
- package/src/core-config.ts +139 -59
- package/src/modules/access/__tests__/access-declarations.test.ts +195 -0
- package/src/modules/access/__tests__/access-md-format.test.ts +191 -0
- package/src/modules/access/__tests__/access-splice.test.ts +21 -0
- package/src/modules/access/__tests__/access.routes.overrides.test.ts +214 -0
- package/src/modules/access/access-control.interface.ts +11 -0
- package/src/modules/access/access-control.service.ts +113 -15
- package/src/modules/access/access-declarations.ts +251 -0
- package/src/modules/access/access-mutation.service.ts +4 -4
- package/src/modules/access/access-splice.ts +64 -21
- package/src/modules/access/access.routes.ts +61 -0
- package/src/modules/access/creator-access.ts +22 -2
- package/src/modules/admin/admin-access.service.ts +4 -3
- package/src/modules/auth/__tests__/auth.service.test.ts +57 -0
- package/src/modules/auth/__tests__/onboarding-done.route.test.ts +147 -0
- package/src/modules/auth/auth.routes.ts +50 -1
- package/src/modules/auth/auth.service.ts +55 -17
- package/src/modules/database/core-schema.ts +46 -0
- package/src/modules/groups/__tests__/group-index.service.test.ts +225 -0
- package/src/modules/groups/__tests__/group-provision.service.test.ts +247 -0
- package/src/modules/groups/__tests__/groups.routes.test.ts +493 -0
- package/src/modules/groups/__tests__/join-proposals.test.ts +101 -0
- package/src/modules/groups/__tests__/join-requests.service.test.ts +175 -0
- package/src/modules/groups/group-provision.service.ts +328 -0
- package/src/modules/groups/groups.contract.ts +107 -0
- package/src/modules/groups/groups.routes.ts +433 -0
- package/src/modules/groups/groups.service.ts +193 -0
- package/src/modules/groups/index.ts +17 -0
- package/src/modules/groups/join-proposals.ts +114 -0
- package/src/modules/groups/join-requests.service.ts +185 -0
- package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +283 -0
- package/src/modules/secrets-vault/secrets-vault.routes.ts +68 -12
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +256 -0
- package/src/modules/settings/__tests__/setup.routes.test.ts +201 -0
- package/src/modules/settings/deployment-settings.service.ts +419 -0
- package/src/modules/settings/setup.routes.ts +317 -0
- package/src/modules/skills/__tests__/pending-skills.service.test.ts +240 -0
- package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
- package/src/modules/skills/index.ts +3 -0
- package/src/modules/skills/pending-skills.service.ts +168 -0
- package/src/modules/skills/skills.contract.ts +34 -3
- package/src/modules/skills/skills.routes.ts +23 -2
- package/src/modules/skills/skills.service.ts +24 -16
- package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +262 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +61 -23
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -156
- package/src/modules/tool-manuals/index.ts +2 -0
- package/src/modules/tool-manuals/tool-manuals.contract.ts +50 -2
- package/src/modules/tool-manuals/tool-manuals.routes.ts +21 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +86 -17
- package/src/modules/tool-manuals/tool-manuals.tools.ts +206 -202
- package/src/modules/workflow/__tests__/event-bus.test.ts +35 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +251 -5
- package/src/modules/workflow/agent-tools/workflow.tools.ts +4 -2
- package/src/modules/workflow/event-bus.ts +25 -0
- package/src/modules/workflow/file-lock.service.ts +17 -0
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +9 -49
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +101 -0
- package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +219 -211
- package/src/modules/workflow/git/__tests__/git.service.fileContentsAtCommit.test.ts +132 -0
- package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +4 -2
- package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +275 -272
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +5 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +44 -0
- package/src/modules/workflow/git/branch-name.ts +5 -1
- package/src/modules/workflow/git/git.service.ts +159 -62
- package/src/modules/workflow/git/pull-request.service.ts +22 -11
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +4 -1
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
- package/src/modules/workflow/workflow.routes.ts +91 -13
- package/src/modules/workflow/workflow.service.ts +353 -13
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +255 -24
- package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -233
- package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -176
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -303
- package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -276
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +14 -6
- package/src/modules/workspace/bevel-ignore.ts +66 -62
- package/src/modules/workspace/kb-seed.interface.ts +1 -1
- package/src/modules/workspace/kb-seed.service.ts +247 -53
- package/src/modules/workspace/workspace.routes.ts +54 -18
- package/src/modules/workspace/workspace.service.ts +45 -7
- package/src/modules/workspace/workspace.tools.ts +922 -917
- package/src/shared/__tests__/frontmatter-id.test.ts +53 -0
- package/src/shared/__tests__/join-request.test.ts +96 -0
- package/src/shared/__tests__/kb-layout.group.test.ts +45 -0
- package/src/shared/kb-layout.test.ts +2 -2
- package/src/test-setup.ts +18 -0
- package/kb-template/Agents/README.md +0 -38
- package/kb-template/CLAUDE.md +0 -419
- package/kb-template/Data/.gitkeep +0 -0
- package/kb-template/Data/README.md +0 -39
- package/kb-template/KnowledgeBase/README.md +0 -37
- package/kb-template/Pipelines/.gitkeep +0 -0
- package/kb-template/Pipelines/README.md +0 -38
- package/kb-template/Skills/.gitkeep +0 -0
- package/kb-template/Skills/README.md +0 -32
- package/kb-template/Tools/.gitkeep +0 -0
- package/kb-template/Tools/README.md +0 -42
- /package/kb-template/{Agents → Groups}/.gitkeep +0 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
|
|
6
|
+
import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
7
|
+
import {
|
|
8
|
+
AccessControlService,
|
|
9
|
+
accessMdDeclaresBodyRules,
|
|
10
|
+
accessMdSelfEntries,
|
|
11
|
+
parseAccessFile,
|
|
12
|
+
} from '../access-control.service.js';
|
|
13
|
+
import { spliceGrant, spliceRevoke } from '../access-splice.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The two-format `access.md` story:
|
|
17
|
+
*
|
|
18
|
+
* - LEGACY: the frontmatter carries the FOLDER's rules; the file cannot
|
|
19
|
+
* govern itself (its readability follows the folder chain).
|
|
20
|
+
* - NEW: the body carries the folder's rules; the frontmatter — like every
|
|
21
|
+
* other file's — is about the FILE itself. `read: everyone` there makes
|
|
22
|
+
* the group discoverable (anyone may open the access.md and see who runs
|
|
23
|
+
* the folder) while the folder's contents stay locked.
|
|
24
|
+
*
|
|
25
|
+
* Compat rule under test: a file whose body is NOT parsable as rules resolves
|
|
26
|
+
* its frontmatter for the folder, exactly as before.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const KB = 'knowledge-base';
|
|
30
|
+
|
|
31
|
+
const ROLES_YAML = `roles:
|
|
32
|
+
Admin:
|
|
33
|
+
- razvan@bevel.software
|
|
34
|
+
GTM Team:
|
|
35
|
+
- felix@example.com
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
/** New-format group access.md: discoverable by everyone, readable by GTM Team. */
|
|
39
|
+
const NEW_FORMAT = `---
|
|
40
|
+
read:
|
|
41
|
+
- everyone
|
|
42
|
+
---
|
|
43
|
+
read:
|
|
44
|
+
- GTM Team
|
|
45
|
+
write:
|
|
46
|
+
- GTM Team
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
const LEGACY_FORMAT = `---
|
|
50
|
+
read:
|
|
51
|
+
- GTM Team
|
|
52
|
+
write:
|
|
53
|
+
- GTM Team
|
|
54
|
+
---
|
|
55
|
+
Some prose explaining this folder.
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
describe('access.md format detection + parsing', () => {
|
|
59
|
+
it('detects body rules (new format) and parses the BODY for the folder', () => {
|
|
60
|
+
expect(accessMdDeclaresBodyRules(NEW_FORMAT)).toBe(true);
|
|
61
|
+
const parsed = parseAccessFile(NEW_FORMAT, 'Groups/GTM/access.md');
|
|
62
|
+
expect(parsed.ok).toBe(true);
|
|
63
|
+
if (!parsed.ok) return;
|
|
64
|
+
expect(parsed.file.entries.read).toEqual([
|
|
65
|
+
{ kind: 'role', role: 'gtm team', displayRole: 'GTM Team', deny: false },
|
|
66
|
+
]);
|
|
67
|
+
// The frontmatter's `everyone` grant is NOT a folder rule.
|
|
68
|
+
expect(parsed.file.entries.read.some((e) => e.kind === 'role' && e.role === 'everyone')).toBe(
|
|
69
|
+
false,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('a prose body stays legacy: the frontmatter governs the folder', () => {
|
|
74
|
+
expect(accessMdDeclaresBodyRules(LEGACY_FORMAT)).toBe(false);
|
|
75
|
+
const parsed = parseAccessFile(LEGACY_FORMAT, 'access.md');
|
|
76
|
+
expect(parsed.ok).toBe(true);
|
|
77
|
+
if (!parsed.ok) return;
|
|
78
|
+
expect(parsed.file.entries.read).toEqual([
|
|
79
|
+
{ kind: 'role', role: 'gtm team', displayRole: 'GTM Team', deny: false },
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('an empty body stays legacy', () => {
|
|
84
|
+
expect(accessMdDeclaresBodyRules('---\nread:\n - GTM Team\n---\n')).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('a body that names a verb but is malformed is a hard ERROR, never a silent fallback', () => {
|
|
88
|
+
// Falling back to the frontmatter here would hand the folder to the
|
|
89
|
+
// (possibly `read: everyone`) self-rules — a typo must fail loudly.
|
|
90
|
+
const typo = '---\nread:\n - everyone\n---\nread: GTM Team\n';
|
|
91
|
+
expect(accessMdDeclaresBodyRules(typo)).toBe(true);
|
|
92
|
+
const parsed = parseAccessFile(typo, 'Groups/GTM/access.md');
|
|
93
|
+
expect(parsed.ok).toBe(false);
|
|
94
|
+
if (parsed.ok) return;
|
|
95
|
+
expect(parsed.errors.join(' ')).toContain("'read:' must be a list");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('self entries: frontmatter in the new format, null for legacy', () => {
|
|
99
|
+
const self = accessMdSelfEntries(NEW_FORMAT);
|
|
100
|
+
expect(self?.read).toEqual([
|
|
101
|
+
{ kind: 'role', role: 'everyone', displayRole: 'everyone', deny: false },
|
|
102
|
+
]);
|
|
103
|
+
expect(accessMdSelfEntries(LEGACY_FORMAT)).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('access.md splice targeting', () => {
|
|
108
|
+
const FELIX = { kind: 'user' as const, email: 'ali@x.eu', displayName: 'Ali Baba' };
|
|
109
|
+
|
|
110
|
+
it("target 'folder' on a new-format file lands the grant in the BODY", () => {
|
|
111
|
+
const r = spliceGrant(NEW_FORMAT, 'read', FELIX, { target: 'folder' });
|
|
112
|
+
expect(r.changed).toBe(true);
|
|
113
|
+
const parsed = parseAccessFile(r.text, 'Groups/GTM/access.md');
|
|
114
|
+
expect(parsed.ok && parsed.file.entries.read.some((e) => e.kind === 'user' && e.email === 'ali@x.eu')).toBe(true);
|
|
115
|
+
// The self-frontmatter is untouched.
|
|
116
|
+
expect(accessMdSelfEntries(r.text)?.read).toEqual([
|
|
117
|
+
{ kind: 'role', role: 'everyone', displayRole: 'everyone', deny: false },
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("target 'folder' on a legacy file lands the grant in the frontmatter (as before)", () => {
|
|
122
|
+
const r = spliceGrant(LEGACY_FORMAT, 'read', FELIX, { target: 'folder' });
|
|
123
|
+
expect(r.changed).toBe(true);
|
|
124
|
+
const parsed = parseAccessFile(r.text, 'access.md');
|
|
125
|
+
expect(parsed.ok && parsed.file.entries.read.some((e) => e.kind === 'user' && e.email === 'ali@x.eu')).toBe(true);
|
|
126
|
+
// Still legacy — the prose body did not become rules.
|
|
127
|
+
expect(accessMdDeclaresBodyRules(r.text)).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("default target ('node') on a new-format file edits the SELF frontmatter", () => {
|
|
131
|
+
const r = spliceGrant(NEW_FORMAT, 'read', FELIX);
|
|
132
|
+
expect(r.changed).toBe(true);
|
|
133
|
+
expect(accessMdSelfEntries(r.text)?.read.some((e) => e.kind === 'user' && e.email === 'ali@x.eu')).toBe(true);
|
|
134
|
+
// Folder rules untouched.
|
|
135
|
+
const parsed = parseAccessFile(r.text, 'Groups/GTM/access.md');
|
|
136
|
+
expect(parsed.ok && parsed.file.entries.read.some((e) => e.kind === 'user')).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("revoke with target 'folder' removes from the body and round-trips", () => {
|
|
140
|
+
const granted = spliceGrant(NEW_FORMAT, 'read', FELIX, { target: 'folder' }).text;
|
|
141
|
+
const revoked = spliceRevoke(granted, 'read', FELIX, { target: 'folder' });
|
|
142
|
+
expect(revoked.changed).toBe(true);
|
|
143
|
+
const parsed = parseAccessFile(revoked.text, 'Groups/GTM/access.md');
|
|
144
|
+
expect(parsed.ok && parsed.file.entries.read.some((e) => e.kind === 'user')).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('end-to-end resolution over a new-format group', () => {
|
|
149
|
+
let root: string;
|
|
150
|
+
const workspaceId = 'ws-groups-fmt';
|
|
151
|
+
|
|
152
|
+
beforeEach(async () => {
|
|
153
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-accessfmt-'));
|
|
154
|
+
});
|
|
155
|
+
afterEach(async () => {
|
|
156
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
async function seed(): Promise<AccessControlService> {
|
|
160
|
+
const workspaceDir = path.join(root, workspaceId);
|
|
161
|
+
const repo = path.join(workspaceDir, KB);
|
|
162
|
+
await fs.mkdir(path.join(repo, 'Groups', 'GTM'), { recursive: true });
|
|
163
|
+
await fs.writeFile(path.join(repo, 'roles.yaml'), ROLES_YAML);
|
|
164
|
+
await fs.writeFile(path.join(repo, 'access.md'), '---\nwrite:\n - Admin\n---\n');
|
|
165
|
+
await fs.writeFile(path.join(repo, 'Groups', 'GTM', 'access.md'), NEW_FORMAT);
|
|
166
|
+
await fs.mkdir(path.join(repo, 'Groups', 'GTM', 'outreach'), { recursive: true });
|
|
167
|
+
await fs.writeFile(path.join(repo, 'Groups', 'GTM', 'outreach', 'SKILL.md'), '# outreach\n');
|
|
168
|
+
const stub = {
|
|
169
|
+
getWorkspacePath: async (id: string) => {
|
|
170
|
+
if (id !== workspaceId) throw new Error(`unexpected workspace ${id}`);
|
|
171
|
+
return workspaceDir;
|
|
172
|
+
},
|
|
173
|
+
} as unknown as WorkspaceService;
|
|
174
|
+
return new AccessControlService(stub, KB);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
it('a NON-member can read the access.md itself (discovery) but nothing inside the folder', async () => {
|
|
178
|
+
const svc = await seed();
|
|
179
|
+
const outsider = 'sara@example.com';
|
|
180
|
+
expect(await svc.canRead(workspaceId, outsider, 'Groups/GTM/access.md')).toBe(true);
|
|
181
|
+
expect(await svc.canRead(workspaceId, outsider, 'Groups/GTM/outreach/SKILL.md')).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('a member reads both; body rules (not frontmatter) decide the folder', async () => {
|
|
185
|
+
const svc = await seed();
|
|
186
|
+
const member = 'felix@example.com';
|
|
187
|
+
expect(await svc.canRead(workspaceId, member, 'Groups/GTM/access.md')).toBe(true);
|
|
188
|
+
expect(await svc.canRead(workspaceId, member, 'Groups/GTM/outreach/SKILL.md')).toBe(true);
|
|
189
|
+
expect(await svc.canWrite(workspaceId, member, 'Groups/GTM/outreach/SKILL.md')).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -199,6 +199,27 @@ describe('validatePrincipal — injection guard', () => {
|
|
|
199
199
|
).toThrow(AccessSpliceError);
|
|
200
200
|
});
|
|
201
201
|
|
|
202
|
+
it.each([['\n'], ['\r'], ['\r\n'], [''], ['']])(
|
|
203
|
+
'rejects a display name carrying the control char %j (frontmatter injection)',
|
|
204
|
+
(ch) => {
|
|
205
|
+
expect(() =>
|
|
206
|
+
validatePrincipal({
|
|
207
|
+
kind: 'user',
|
|
208
|
+
email: 'a@b.com',
|
|
209
|
+
// Interior, so `trim()` cannot quietly rescue it on the way through.
|
|
210
|
+
displayName: `Ali${ch}read:${ch} - everyone`,
|
|
211
|
+
}),
|
|
212
|
+
).toThrow(AccessSpliceError);
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
it('trims a SURROUNDING newline rather than rejecting, and renders the trimmed name', () => {
|
|
217
|
+
// The guard runs on the trimmed name and the trimmed name is what gets
|
|
218
|
+
// returned — so a stray edge newline can never reach a rendered line.
|
|
219
|
+
const p = validatePrincipal({ kind: 'user', email: 'a@b.com', displayName: '\n Ali \n' });
|
|
220
|
+
expect(p.kind === 'user' && p.displayName).toBe('Ali');
|
|
221
|
+
});
|
|
222
|
+
|
|
202
223
|
it('rejects a group name with a colon (would forge a key)', () => {
|
|
203
224
|
expect(() => validatePrincipal({ kind: 'role', role: 'write:\n - everyone' })).toThrow(
|
|
204
225
|
AccessSpliceError,
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { Server } from 'node:http';
|
|
2
|
+
import type { AddressInfo } from 'node:net';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
5
|
+
import type { FileTreeEntry } from '@bevel-software/platform-shared';
|
|
6
|
+
|
|
7
|
+
import type { IAccessControl } from '../access-control.interface.js';
|
|
8
|
+
import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
9
|
+
import type { AuthService } from '../../auth/auth.service.js';
|
|
10
|
+
import type { WorkflowService } from '../../workflow/workflow.service.js';
|
|
11
|
+
import type { WorkflowEventBus } from '../../workflow/event-bus.js';
|
|
12
|
+
import type { Database } from '../../database/connection.js';
|
|
13
|
+
import { createAccessRoutes } from '../access.routes.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* HTTP contract for `GET /access/overrides`. The scan itself is covered in
|
|
17
|
+
* `access-declarations.test.ts`; what is under test here is the DISCLOSURE
|
|
18
|
+
* boundary — the folder-read gate, the per-row read filter, and the path
|
|
19
|
+
* validation that stops a caller pointing the scan outside the KB.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const USER = { id: 'u-1', email: 'alice@bevel.software', name: 'Alice' };
|
|
23
|
+
const WS = 'main';
|
|
24
|
+
const KB = 'knowledge-base';
|
|
25
|
+
|
|
26
|
+
function buildTree(paths: string[]): FileTreeEntry {
|
|
27
|
+
const root: FileTreeEntry = { name: '', relativePath: '', type: 'directory', children: [] };
|
|
28
|
+
for (const wsPath of paths) {
|
|
29
|
+
const segments = wsPath.split('/');
|
|
30
|
+
let node = root;
|
|
31
|
+
segments.forEach((segment, i) => {
|
|
32
|
+
const isLeaf = i === segments.length - 1;
|
|
33
|
+
let child = node.children?.find((c) => c.name === segment);
|
|
34
|
+
if (!child) {
|
|
35
|
+
child = {
|
|
36
|
+
name: segment,
|
|
37
|
+
relativePath: segments.slice(0, i + 1).join('/'),
|
|
38
|
+
type: isLeaf ? 'file' : 'directory',
|
|
39
|
+
...(isLeaf ? {} : { children: [] }),
|
|
40
|
+
};
|
|
41
|
+
node.children?.push(child);
|
|
42
|
+
}
|
|
43
|
+
node = child;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return root;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface Harness {
|
|
50
|
+
server: Server;
|
|
51
|
+
baseUrl: string;
|
|
52
|
+
canRead: ReturnType<typeof vi.fn>;
|
|
53
|
+
canReadBatch: ReturnType<typeof vi.fn>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function makeHarness(opts: {
|
|
57
|
+
files?: Record<string, string>;
|
|
58
|
+
/** Folder-level read verdict. Defaults to readable. */
|
|
59
|
+
canRead?: (repoRelPath: string) => boolean;
|
|
60
|
+
/** Per-row read verdict on `governs`. Defaults to readable. */
|
|
61
|
+
rowReadable?: (repoRelPath: string) => boolean;
|
|
62
|
+
}): Promise<Harness> {
|
|
63
|
+
const files = opts.files ?? {};
|
|
64
|
+
|
|
65
|
+
const canRead = vi.fn(async (_w: string, _e: string, p: string) =>
|
|
66
|
+
opts.canRead ? opts.canRead(p) : true,
|
|
67
|
+
);
|
|
68
|
+
const canReadBatch = vi.fn(async (_w: string, _e: string, paths: string[]) => {
|
|
69
|
+
const m = new Map<string, boolean>();
|
|
70
|
+
for (const p of paths) m.set(p, opts.rowReadable ? opts.rowReadable(p) : true);
|
|
71
|
+
return m;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const accessControl = { canRead, canReadBatch } as unknown as IAccessControl;
|
|
75
|
+
|
|
76
|
+
const workspaceService = {
|
|
77
|
+
listFiles: vi.fn(async () => buildTree(Object.keys(files))),
|
|
78
|
+
readFile: vi.fn(async (_id: string, wsRel: string) => {
|
|
79
|
+
const text = files[wsRel];
|
|
80
|
+
if (text === undefined) throw new Error(`ENOENT ${wsRel}`);
|
|
81
|
+
return text;
|
|
82
|
+
}),
|
|
83
|
+
getOrCreateForBranch: vi.fn(async () => ({ id: WS, name: WS, kbDirName: KB })),
|
|
84
|
+
} as unknown as WorkspaceService;
|
|
85
|
+
|
|
86
|
+
const authService = { getUserById: vi.fn(async () => USER) } as unknown as AuthService;
|
|
87
|
+
const workflowService = {} as unknown as WorkflowService;
|
|
88
|
+
const eventBus = { emit: vi.fn() } as unknown as WorkflowEventBus;
|
|
89
|
+
const db = {} as unknown as Database;
|
|
90
|
+
|
|
91
|
+
const app = express();
|
|
92
|
+
app.use(express.json());
|
|
93
|
+
app.use('/api', (req, _res, next) => {
|
|
94
|
+
(req as unknown as { userId: string }).userId = USER.id;
|
|
95
|
+
next();
|
|
96
|
+
});
|
|
97
|
+
app.use(
|
|
98
|
+
'/api',
|
|
99
|
+
createAccessRoutes(accessControl, workspaceService, authService, workflowService, eventBus, db, KB),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const server = await new Promise<Server>((resolve) => {
|
|
103
|
+
const s = app.listen(0, () => resolve(s));
|
|
104
|
+
});
|
|
105
|
+
const addr = server.address() as AddressInfo;
|
|
106
|
+
return { server, baseUrl: `http://127.0.0.1:${addr.port}`, canRead, canReadBatch };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function close(s: Server): Promise<void> {
|
|
110
|
+
return new Promise((resolve, reject) => s.close((e) => (e ? reject(e) : resolve())));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const fm = (body: string) => `---\n${body}\n---\n\n# Body\n`;
|
|
114
|
+
|
|
115
|
+
const FILES = {
|
|
116
|
+
[`${KB}/Groups/GTM/access.md`]: fm('read:\n - GTM Team'),
|
|
117
|
+
[`${KB}/Groups/GTM/battlecards/access.md`]: fm('read:\n - deny everyone'),
|
|
118
|
+
[`${KB}/Groups/GTM/slack.tool`]: fm('owner: Ali <ali@bevel.software>'),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
describe('GET /access/overrides', () => {
|
|
122
|
+
let h: Harness | null = null;
|
|
123
|
+
afterEach(async () => {
|
|
124
|
+
if (h) await close(h.server);
|
|
125
|
+
h = null;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const get = (path: string) =>
|
|
129
|
+
fetch(
|
|
130
|
+
`${h!.baseUrl}/api/workspace/${encodeURIComponent(WS)}/access/overrides?path=${encodeURIComponent(path)}`,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
it('returns the declarations under a folder the caller can read', async () => {
|
|
134
|
+
h = await makeHarness({ files: FILES });
|
|
135
|
+
|
|
136
|
+
const res = await get('Groups/GTM');
|
|
137
|
+
expect(res.status).toBe(200);
|
|
138
|
+
expect(await res.json()).toEqual({
|
|
139
|
+
truncated: false,
|
|
140
|
+
overrides: [
|
|
141
|
+
{
|
|
142
|
+
path: 'Groups/GTM/battlecards/access.md',
|
|
143
|
+
governs: 'Groups/GTM/battlecards',
|
|
144
|
+
source: 'access-md',
|
|
145
|
+
entries: [{ verb: 'read', deny: true, principal: { kind: 'everyone' } }],
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
path: 'Groups/GTM/slack.tool',
|
|
149
|
+
governs: 'Groups/GTM/slack.tool',
|
|
150
|
+
source: 'frontmatter',
|
|
151
|
+
entries: [
|
|
152
|
+
{
|
|
153
|
+
verb: 'owner',
|
|
154
|
+
deny: false,
|
|
155
|
+
principal: { kind: 'user', email: 'ali@bevel.software', name: 'Ali' },
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('403s a caller who cannot read the folder', async () => {
|
|
164
|
+
h = await makeHarness({ files: FILES, canRead: () => false });
|
|
165
|
+
|
|
166
|
+
const res = await get('Groups/GTM');
|
|
167
|
+
expect(res.status).toBe(403);
|
|
168
|
+
expect(await res.json()).toEqual({ error: 'You do not have access to this folder.' });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('drops rows governing something the caller cannot read', async () => {
|
|
172
|
+
h = await makeHarness({
|
|
173
|
+
files: FILES,
|
|
174
|
+
rowReadable: (p) => p !== 'Groups/GTM/battlecards',
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const res = await get('Groups/GTM');
|
|
178
|
+
const body = (await res.json()) as { overrides: { path: string }[] };
|
|
179
|
+
expect(body.overrides.map((o) => o.path)).toEqual(['Groups/GTM/slack.tool']);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('accepts a workspace-relative path exactly like a repo-relative one', async () => {
|
|
183
|
+
h = await makeHarness({ files: FILES });
|
|
184
|
+
|
|
185
|
+
const repoRel = (await (await get('Groups/GTM')).json()) as unknown;
|
|
186
|
+
const wsRel = (await (await get(`${KB}/Groups/GTM`)).json()) as unknown;
|
|
187
|
+
expect(wsRel).toEqual(repoRel);
|
|
188
|
+
expect(h.canRead).toHaveBeenCalledWith(WS, USER.email, 'Groups/GTM');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('400s a path that escapes the KB repo', async () => {
|
|
192
|
+
h = await makeHarness({ files: FILES });
|
|
193
|
+
|
|
194
|
+
expect((await get(`${KB}/../tmp`)).status).toBe(400);
|
|
195
|
+
expect((await get('/etc/passwd')).status).toBe(400);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('400s a target that is a file, not a folder', async () => {
|
|
199
|
+
h = await makeHarness({ files: FILES });
|
|
200
|
+
|
|
201
|
+
const res = await get('Groups/GTM/slack.tool');
|
|
202
|
+
expect(res.status).toBe(400);
|
|
203
|
+
expect(await res.json()).toEqual({ error: 'path must be a folder' });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('400s a missing path parameter', async () => {
|
|
207
|
+
h = await makeHarness({ files: FILES });
|
|
208
|
+
|
|
209
|
+
const res = await fetch(
|
|
210
|
+
`${h.baseUrl}/api/workspace/${encodeURIComponent(WS)}/access/overrides`,
|
|
211
|
+
);
|
|
212
|
+
expect(res.status).toBe(400);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -136,6 +136,17 @@ export interface IAccessControl {
|
|
|
136
136
|
relativePath: string,
|
|
137
137
|
): Promise<boolean>;
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Batched `canOwner` — one config load per call, same `owner:`-lists-only
|
|
141
|
+
* resolution (no admin rescue). Used by group enumeration to answer "which
|
|
142
|
+
* of these folders does the caller own?" without one model load per group.
|
|
143
|
+
*/
|
|
144
|
+
canOwnerBatch(
|
|
145
|
+
workspaceId: string,
|
|
146
|
+
userEmail: string,
|
|
147
|
+
relativePaths: string[],
|
|
148
|
+
): Promise<Map<string, boolean>>;
|
|
149
|
+
|
|
139
150
|
/**
|
|
140
151
|
* The set of principals (roles + direct users) that own this path — i.e.
|
|
141
152
|
* who to contact about it. Resolved from `owner:` lists only. Used by the
|
|
@@ -157,7 +157,7 @@ export interface UserEntry {
|
|
|
157
157
|
|
|
158
158
|
export type ParsedEntry = RoleEntry | UserEntry;
|
|
159
159
|
|
|
160
|
-
interface AccessFile {
|
|
160
|
+
export interface AccessFile {
|
|
161
161
|
/** repo-relative POSIX path — e.g. `access.md`, `Knowledge/Sales/access.md`. */
|
|
162
162
|
path: string;
|
|
163
163
|
/** repo-relative POSIX directory — `''` for root. */
|
|
@@ -353,6 +353,16 @@ export function extractFrontmatter(
|
|
|
353
353
|
return { ok: false, error: 'unterminated frontmatter — no closing `---` found' };
|
|
354
354
|
}
|
|
355
355
|
|
|
356
|
+
/** The text AFTER the closing frontmatter fence ('' when there is no fence). */
|
|
357
|
+
export function bodyAfterFrontmatter(text: string): string {
|
|
358
|
+
const lines = text.split(/\r?\n/);
|
|
359
|
+
if (lines.length === 0 || lines[0].trim() !== '---') return '';
|
|
360
|
+
for (let i = 1; i < lines.length; i++) {
|
|
361
|
+
if (lines[i].trim() === '---') return lines.slice(i + 1).join('\n');
|
|
362
|
+
}
|
|
363
|
+
return '';
|
|
364
|
+
}
|
|
365
|
+
|
|
356
366
|
// ---------------------------------------------------------------------------
|
|
357
367
|
// Canonicalisation
|
|
358
368
|
// ---------------------------------------------------------------------------
|
|
@@ -488,18 +498,86 @@ export function parseRolesYaml(
|
|
|
488
498
|
return { ok: true, index };
|
|
489
499
|
}
|
|
490
500
|
|
|
491
|
-
|
|
501
|
+
/**
|
|
502
|
+
* Does an `access.md` body declare access rules — i.e. is the file in the NEW
|
|
503
|
+
* (body-governs-the-folder) format?
|
|
504
|
+
*
|
|
505
|
+
* The two-format story: historically the FRONTMATTER carried the folder's
|
|
506
|
+
* rules and the file could not govern itself. The new format follows the
|
|
507
|
+
* convention every other file uses — frontmatter is about the FILE (who may
|
|
508
|
+
* read/write `access.md` itself), and the content (the body) is the folder's
|
|
509
|
+
* rules. The compat rule, applied per file: **when the body is not parsable as
|
|
510
|
+
* rules, the frontmatter is resolved for the folder as before.**
|
|
511
|
+
*
|
|
512
|
+
* "Parsable as rules" is deliberately shallow — the body YAML-parses to a
|
|
513
|
+
* mapping that names at least one known verb. A prose body (the repo-root
|
|
514
|
+
* README-style file) fails the YAML parse or carries no verb key and stays
|
|
515
|
+
* legacy. A body that DOES name a verb has claimed to be rules: shape errors
|
|
516
|
+
* inside it (a scalar verb, a malformed entry) are then hard parse ERRORS of
|
|
517
|
+
* the file, never a silent fallback to the frontmatter — falling back would
|
|
518
|
+
* let a typo in the body hand the folder to the frontmatter's (possibly
|
|
519
|
+
* `read: everyone`) self-rules.
|
|
520
|
+
*/
|
|
521
|
+
export function accessMdDeclaresBodyRules(text: string): boolean {
|
|
522
|
+
const body = bodyAfterFrontmatter(text);
|
|
523
|
+
if (!body.trim()) return false;
|
|
524
|
+
const parsed = parseYamlSubset(body);
|
|
525
|
+
if (!parsed.ok) return false;
|
|
526
|
+
const root = parsed.value;
|
|
527
|
+
if (root == null || typeof root !== 'object' || Array.isArray(root)) return false;
|
|
528
|
+
return Object.keys(root as Record<string, YamlValue>).some((k) => KNOWN_VERBS_SET.has(k));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* The access verbs an `access.md` declares FOR ITSELF — its frontmatter, and
|
|
533
|
+
* only in the new format (body-governed). A legacy file cannot govern itself:
|
|
534
|
+
* its frontmatter IS the folder's rules, so returning them as own-entries
|
|
535
|
+
* would double-apply them at the file scope.
|
|
536
|
+
*
|
|
537
|
+
* Parsed forgivingly (like node frontmatter): the new-format frontmatter may
|
|
538
|
+
* carry non-access keys, and a typo there must not make the file unreadable.
|
|
539
|
+
*/
|
|
540
|
+
export function accessMdSelfEntries(text: string): Record<Verb, ParsedEntry[]> | null {
|
|
541
|
+
if (!accessMdDeclaresBodyRules(text)) return null;
|
|
542
|
+
return parseOwnAccessEntries(text);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Parse one `access.md`'s FOLDER rules into its per-verb entry lists.
|
|
547
|
+
*
|
|
548
|
+
* Two formats (see {@link accessMdDeclaresBodyRules}): when the body declares
|
|
549
|
+
* rules, the body is parsed (new format — frontmatter then governs the file
|
|
550
|
+
* itself via {@link accessMdSelfEntries}); otherwise the frontmatter is parsed
|
|
551
|
+
* (legacy format), exactly as before.
|
|
552
|
+
*
|
|
553
|
+
* Exported so read-only surfaces can report WHERE rules are declared without
|
|
554
|
+
* reimplementing the parse (see `access-declarations.ts`). Exporting changes
|
|
555
|
+
* nothing about resolution — this is the same function the resolver builds its
|
|
556
|
+
* model from, so a display of declarations can never drift from the rules that
|
|
557
|
+
* are actually enforced.
|
|
558
|
+
*/
|
|
559
|
+
export function parseAccessFile(
|
|
492
560
|
text: string,
|
|
493
561
|
relativePath: string,
|
|
494
562
|
): { ok: true; file: AccessFile; warnings: string[] } | { ok: false; errors: string[] } {
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
563
|
+
const bodyFormat = accessMdDeclaresBodyRules(text);
|
|
564
|
+
let ruleSource: string;
|
|
565
|
+
if (bodyFormat) {
|
|
566
|
+
ruleSource = bodyAfterFrontmatter(text);
|
|
567
|
+
} else {
|
|
568
|
+
const fm = extractFrontmatter(text);
|
|
569
|
+
if (!fm.ok) return { ok: false, errors: [`${relativePath}: ${fm.error}`] };
|
|
570
|
+
ruleSource = fm.frontmatter;
|
|
571
|
+
}
|
|
572
|
+
const parsed = parseYamlSubset(ruleSource);
|
|
498
573
|
if (!parsed.ok) return { ok: false, errors: [`${relativePath}: ${parsed.error}`] };
|
|
499
574
|
|
|
500
575
|
const root = parsed.value;
|
|
501
576
|
if (root == null || typeof root !== 'object' || Array.isArray(root)) {
|
|
502
|
-
return {
|
|
577
|
+
return {
|
|
578
|
+
ok: false,
|
|
579
|
+
errors: [`${relativePath}: ${bodyFormat ? 'body' : 'frontmatter'} must be a mapping`],
|
|
580
|
+
};
|
|
503
581
|
}
|
|
504
582
|
|
|
505
583
|
const errors: string[] = [];
|
|
@@ -1222,6 +1300,22 @@ export class AccessControlService implements IAccessControl {
|
|
|
1222
1300
|
return hasPermissionResolved(model, 'download', userEmail, relativePath, own);
|
|
1223
1301
|
}
|
|
1224
1302
|
|
|
1303
|
+
async canOwnerBatch(
|
|
1304
|
+
workspaceId: string,
|
|
1305
|
+
userEmail: string,
|
|
1306
|
+
relativePaths: string[],
|
|
1307
|
+
): Promise<Map<string, boolean>> {
|
|
1308
|
+
const model = await this.loadModel(workspaceId);
|
|
1309
|
+
const repoDir = await this.repoDir(workspaceId);
|
|
1310
|
+
// Parallel own-entries reads, memoized — same shape as `canWriteBatch`.
|
|
1311
|
+
const owns = await Promise.all(relativePaths.map((p) => this.cachedOwnEntries(workspaceId, repoDir, p)));
|
|
1312
|
+
const result = new Map<string, boolean>();
|
|
1313
|
+
relativePaths.forEach((p, i) => {
|
|
1314
|
+
result.set(p, hasPermissionResolved(model, 'owner', userEmail, p, owns[i]));
|
|
1315
|
+
});
|
|
1316
|
+
return result;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1225
1319
|
async canOwner(
|
|
1226
1320
|
workspaceId: string,
|
|
1227
1321
|
userEmail: string,
|
|
@@ -1601,23 +1695,25 @@ export class AccessControlService implements IAccessControl {
|
|
|
1601
1695
|
}
|
|
1602
1696
|
|
|
1603
1697
|
/**
|
|
1604
|
-
* Read the access verbs a
|
|
1605
|
-
*
|
|
1606
|
-
*
|
|
1607
|
-
*
|
|
1608
|
-
*
|
|
1698
|
+
* Read the access verbs a file declares FOR ITSELF from the working tree.
|
|
1699
|
+
* Returns null when the file has no per-file access config (the common
|
|
1700
|
+
* case). `roles.yaml` is never a node; an `access.md` governs itself only
|
|
1701
|
+
* in the new (body-governed) format, where its frontmatter is self-access —
|
|
1702
|
+
* see {@link accessMdSelfEntries}. A legacy `access.md` still yields null:
|
|
1703
|
+
* its frontmatter IS the folder rules the dir chain already applies.
|
|
1609
1704
|
*/
|
|
1610
1705
|
private async readOwnEntries(
|
|
1611
1706
|
repoDir: string,
|
|
1612
1707
|
relativePath: string,
|
|
1613
1708
|
): Promise<OwnEntries | null> {
|
|
1614
|
-
if (
|
|
1709
|
+
if (relativePath === 'roles.yaml') return null;
|
|
1615
1710
|
let text: string;
|
|
1616
1711
|
try {
|
|
1617
1712
|
text = await fs.readFile(path.join(repoDir, relativePath), 'utf-8');
|
|
1618
1713
|
} catch {
|
|
1619
1714
|
return null;
|
|
1620
1715
|
}
|
|
1716
|
+
if (isAccessMdPath(relativePath)) return accessMdSelfEntries(text);
|
|
1621
1717
|
return parseOwnAccessEntries(text);
|
|
1622
1718
|
}
|
|
1623
1719
|
|
|
@@ -1632,9 +1728,10 @@ export class AccessControlService implements IAccessControl {
|
|
|
1632
1728
|
ref: string,
|
|
1633
1729
|
relativePath: string,
|
|
1634
1730
|
): Promise<OwnEntries | null> {
|
|
1635
|
-
if (
|
|
1731
|
+
if (relativePath === 'roles.yaml') return null;
|
|
1636
1732
|
const text = await this.showAtRef(repoDir, ref, relativePath);
|
|
1637
1733
|
if (text === null) return null;
|
|
1734
|
+
if (isAccessMdPath(relativePath)) return accessMdSelfEntries(text);
|
|
1638
1735
|
return parseOwnAccessEntries(text);
|
|
1639
1736
|
}
|
|
1640
1737
|
|
|
@@ -1654,14 +1751,15 @@ export class AccessControlService implements IAccessControl {
|
|
|
1654
1751
|
const result = new Map<string, OwnEntries | null>();
|
|
1655
1752
|
const wanted: string[] = [];
|
|
1656
1753
|
for (const p of relativePaths) {
|
|
1657
|
-
if (
|
|
1754
|
+
if (p === 'roles.yaml') result.set(p, null);
|
|
1658
1755
|
else if (!wanted.includes(p)) wanted.push(p);
|
|
1659
1756
|
}
|
|
1660
1757
|
if (wanted.length === 0) return result;
|
|
1661
1758
|
const texts = await catFileBatch(repoDir, wanted.map((p) => `${ref}:${p}`));
|
|
1662
1759
|
wanted.forEach((p, i) => {
|
|
1663
1760
|
const text = texts[i];
|
|
1664
|
-
|
|
1761
|
+
if (text === null) result.set(p, null);
|
|
1762
|
+
else result.set(p, isAccessMdPath(p) ? accessMdSelfEntries(text) : parseOwnAccessEntries(text));
|
|
1665
1763
|
});
|
|
1666
1764
|
return result;
|
|
1667
1765
|
}
|