@bevel-software/platform-core-backend 0.11.0 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/create-core-services.d.ts +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +2 -2
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +1 -253
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +3 -632
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts +2 -2
- package/dist/modules/access/access-declarations.d.ts.map +1 -1
- package/dist/modules/access/access-declarations.js +2 -2
- package/dist/modules/access/access-declarations.js.map +1 -1
- package/dist/modules/access/access-mutation.service.d.ts +3 -3
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
- package/dist/modules/access/access-mutation.service.js +3 -3
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access.routes.js +4 -4
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts +2 -2
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
- package/dist/modules/access/admin-locked-commit.js +2 -2
- package/dist/modules/access/admin-locked-commit.js.map +1 -1
- package/dist/modules/access/admin-route-helpers.js +1 -1
- package/dist/modules/access/admin-route-helpers.js.map +1 -1
- package/dist/modules/access/capability-registry.js +1 -1
- package/dist/modules/access/capability-registry.js.map +1 -1
- package/dist/modules/access/creator-access.d.ts +1 -45
- package/dist/modules/access/creator-access.d.ts.map +1 -1
- package/dist/modules/access/creator-access.js +4 -24
- package/dist/modules/access/creator-access.js.map +1 -1
- package/dist/modules/access/groups-admin.routes.js +1 -1
- package/dist/modules/access/groups-admin.routes.js.map +1 -1
- package/dist/modules/access/groups-admin.service.d.ts +1 -1
- package/dist/modules/access/groups-admin.service.d.ts.map +1 -1
- package/dist/modules/access/groups-admin.service.js +6 -5
- package/dist/modules/access/groups-admin.service.js.map +1 -1
- package/dist/modules/access/groups-edit.js +2 -2
- package/dist/modules/access/groups-edit.js.map +1 -1
- package/dist/modules/access/reference-scan.js +1 -1
- package/dist/modules/access/reference-scan.js.map +1 -1
- package/dist/modules/access/roles-admin.service.d.ts +1 -1
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +8 -7
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/access/roles-edit.js +1 -1
- package/dist/modules/access/roles-edit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.js +4 -4
- package/dist/modules/access/synced-groups-committer.js.map +1 -1
- package/dist/modules/access/synced-groups-writer.js +2 -2
- package/dist/modules/access/synced-groups-writer.js.map +1 -1
- package/dist/modules/access-model/access-errors.d.ts +34 -0
- package/dist/modules/access-model/access-errors.d.ts.map +1 -0
- package/dist/modules/access-model/access-errors.js +40 -0
- package/dist/modules/access-model/access-errors.js.map +1 -0
- package/dist/modules/access-model/access-grammar.d.ts +266 -0
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -0
- package/dist/modules/access-model/access-grammar.js +644 -0
- package/dist/modules/access-model/access-grammar.js.map +1 -0
- package/dist/modules/access-model/access-splice.d.ts +140 -0
- package/dist/modules/access-model/access-splice.d.ts.map +1 -0
- package/dist/modules/access-model/access-splice.js +389 -0
- package/dist/modules/access-model/access-splice.js.map +1 -0
- package/dist/modules/access-model/creator.d.ts +54 -0
- package/dist/modules/access-model/creator.d.ts.map +1 -0
- package/dist/modules/access-model/creator.js +30 -0
- package/dist/modules/access-model/creator.js.map +1 -0
- package/dist/modules/access-model/group-files.d.ts +83 -0
- package/dist/modules/access-model/group-files.d.ts.map +1 -0
- package/dist/modules/access-model/group-files.js +167 -0
- package/dist/modules/access-model/group-files.js.map +1 -0
- package/dist/modules/access-model/kb-read-filter.d.ts +41 -0
- package/dist/modules/access-model/kb-read-filter.d.ts.map +1 -0
- package/dist/modules/access-model/kb-read-filter.js +60 -0
- package/dist/modules/access-model/kb-read-filter.js.map +1 -0
- package/dist/modules/access-model/render-roles-yaml.d.ts +22 -0
- package/dist/modules/access-model/render-roles-yaml.d.ts.map +1 -0
- package/dist/modules/access-model/render-roles-yaml.js +56 -0
- package/dist/modules/access-model/render-roles-yaml.js.map +1 -0
- package/dist/modules/access-model/roles-yaml-guard.d.ts +56 -0
- package/dist/modules/access-model/roles-yaml-guard.d.ts.map +1 -0
- package/dist/modules/access-model/roles-yaml-guard.js +79 -0
- package/dist/modules/access-model/roles-yaml-guard.js.map +1 -0
- package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
- package/dist/modules/admin/admin-access.service.js +1 -1
- package/dist/modules/admin/admin-access.service.js.map +1 -1
- package/dist/modules/diff/diff.routes.js +4 -4
- package/dist/modules/diff/diff.routes.js.map +1 -1
- package/dist/modules/diff/diff.service.d.ts +1 -1
- package/dist/modules/diff/diff.service.d.ts.map +1 -1
- package/dist/modules/kb-fs/branch-name.d.ts +10 -0
- package/dist/modules/kb-fs/branch-name.d.ts.map +1 -0
- package/dist/modules/kb-fs/branch-name.js +76 -0
- package/dist/modules/kb-fs/branch-name.js.map +1 -0
- package/dist/modules/kb-fs/clone-config.d.ts +61 -0
- package/dist/modules/kb-fs/clone-config.d.ts.map +1 -0
- package/dist/modules/kb-fs/clone-config.js +69 -0
- package/dist/modules/kb-fs/clone-config.js.map +1 -0
- package/dist/modules/kb-fs/file-change-notifier.d.ts +38 -0
- package/dist/modules/kb-fs/file-change-notifier.d.ts.map +1 -0
- package/dist/modules/kb-fs/file-change-notifier.js +22 -0
- package/dist/modules/kb-fs/file-change-notifier.js.map +1 -0
- package/dist/modules/kb-fs/locking-filesystem.d.ts +137 -0
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -0
- package/dist/modules/kb-fs/locking-filesystem.js +553 -0
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -0
- package/dist/modules/kb-fs/mutex.d.ts +11 -0
- package/dist/modules/kb-fs/mutex.d.ts.map +1 -0
- package/dist/modules/kb-fs/mutex.js +23 -0
- package/dist/modules/kb-fs/mutex.js.map +1 -0
- package/dist/modules/kb-fs/read-only-filesystem.d.ts +24 -0
- package/dist/modules/kb-fs/read-only-filesystem.d.ts.map +1 -0
- package/dist/modules/kb-fs/read-only-filesystem.js +39 -0
- package/dist/modules/kb-fs/read-only-filesystem.js.map +1 -0
- package/dist/modules/plugins/join-proposals.d.ts +1 -1
- package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
- package/dist/modules/plugins/join-proposals.js +1 -1
- package/dist/modules/plugins/join-proposals.js.map +1 -1
- package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
- package/dist/modules/plugins/join-requests.service.js +1 -1
- package/dist/modules/plugins/join-requests.service.js.map +1 -1
- package/dist/modules/plugins/plugin-provision.service.js +3 -3
- package/dist/modules/plugins/plugin-provision.service.js.map +1 -1
- package/dist/modules/plugins/plugins.routes.js +2 -2
- package/dist/modules/plugins/plugins.routes.js.map +1 -1
- package/dist/modules/plugins/plugins.service.js +1 -1
- package/dist/modules/plugins/plugins.service.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/skills/pending-skills.service.d.ts.map +1 -1
- package/dist/modules/skills/pending-skills.service.js +1 -1
- package/dist/modules/skills/pending-skills.service.js.map +1 -1
- package/dist/modules/skills/skills.service.js +1 -1
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/tool-helpers/tool-context.d.ts +2 -2
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +4 -4
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.js +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +1 -1
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +2 -2
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +5 -5
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +1 -1
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +2 -2
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/session-ontology.service.js +1 -1
- package/dist/modules/workflow/session-ontology.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +2 -2
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +1 -1
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +4 -4
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js +1 -1
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js.map +1 -1
- package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts +1 -1
- package/dist/modules/workspace/startup/steps/roles-yaml.step.js +2 -2
- package/dist/modules/workspace/startup/steps/roles-yaml.step.js.map +1 -1
- package/dist/modules/workspace/startup/steps/seed-tree.js +1 -1
- package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
- package/dist/modules/workspace/workspace.routes.d.ts +1 -1
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +5 -4
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +2 -9
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +14 -15
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +3 -3
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/domain-errors.d.ts +202 -0
- package/dist/shared/domain-errors.d.ts.map +1 -0
- package/dist/shared/domain-errors.js +303 -0
- package/dist/shared/domain-errors.js.map +1 -0
- package/dist/shared/workspace-id.d.ts +27 -0
- package/dist/shared/workspace-id.d.ts.map +1 -0
- package/dist/shared/workspace-id.js +36 -0
- package/dist/shared/workspace-id.js.map +1 -0
- package/package.json +3 -3
- package/src/core/create-core-services.ts +2 -2
- package/src/modules/access/__tests__/access-control.service.test.ts +1 -1
- package/src/modules/access/__tests__/access-groups.test.ts +6 -5
- package/src/modules/access/__tests__/access-md-format.test.ts +3 -3
- package/src/modules/access/__tests__/access-mutation.service.test.ts +1 -1
- package/src/modules/access/__tests__/admin-locked-commit.test.ts +1 -1
- package/src/modules/access/__tests__/admin-route-helpers.test.ts +1 -1
- package/src/modules/access/__tests__/roles-admin.service.test.ts +2 -2
- package/src/modules/access/__tests__/roles-edit.test.ts +1 -1
- package/src/modules/access/__tests__/synced-groups-committer.test.ts +1 -1
- package/src/modules/access/__tests__/synced-groups-writer.test.ts +1 -1
- package/src/modules/access/access-control.service.ts +23 -768
- package/src/modules/access/access-declarations.ts +2 -2
- package/src/modules/access/access-mutation.service.ts +3 -3
- package/src/modules/access/access.routes.ts +5 -5
- package/src/modules/access/admin-locked-commit.ts +2 -2
- package/src/modules/access/admin-route-helpers.ts +1 -1
- package/src/modules/access/capability-registry.ts +1 -1
- package/src/modules/access/creator-access.ts +9 -72
- package/src/modules/access/groups-admin.routes.ts +1 -1
- package/src/modules/access/groups-admin.service.ts +6 -7
- package/src/modules/access/groups-edit.ts +2 -2
- package/src/modules/access/reference-scan.ts +1 -1
- package/src/modules/access/roles-admin.service.ts +8 -8
- package/src/modules/access/roles-edit.ts +1 -1
- package/src/modules/access/synced-groups-committer.ts +4 -4
- package/src/modules/access/synced-groups-writer.ts +2 -2
- package/src/modules/access-model/__tests__/access-grammar.test.ts +31 -0
- package/src/modules/{access → access-model}/__tests__/access-splice.test.ts +268 -268
- package/src/modules/{access → access-model}/access-errors.ts +1 -1
- package/src/modules/access-model/access-grammar.ts +778 -0
- package/src/modules/{access → access-model}/access-splice.ts +1 -1
- package/src/modules/access-model/creator.ts +79 -0
- package/src/modules/{access → access-model}/group-files.ts +1 -1
- package/src/modules/{access → access-model}/render-roles-yaml.ts +1 -1
- package/src/modules/{access → access-model}/roles-yaml-guard.ts +2 -2
- package/src/modules/admin/admin-access.service.ts +2 -1
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +2 -2
- package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +3 -2
- package/src/modules/diff/__tests__/diff.service.test.ts +3 -2
- package/src/modules/diff/diff.routes.ts +4 -4
- package/src/modules/diff/diff.service.ts +1 -1
- package/src/modules/{workflow/git → kb-fs}/__tests__/branch-name.test.ts +1 -1
- package/src/modules/{workflow → kb-fs}/__tests__/locking-filesystem.test.ts +1 -1
- package/src/modules/{workflow/git → kb-fs}/branch-name.ts +1 -1
- package/src/modules/{workflow → kb-fs}/locking-filesystem.ts +2 -2
- package/src/modules/plugins/__tests__/join-requests.service.test.ts +2 -4
- package/src/modules/plugins/__tests__/plugin-index.service.test.ts +1 -1
- package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
- package/src/modules/plugins/join-proposals.ts +1 -1
- package/src/modules/plugins/join-requests.service.ts +2 -1
- package/src/modules/plugins/plugin-provision.service.ts +3 -3
- package/src/modules/plugins/plugins.routes.ts +2 -2
- package/src/modules/plugins/plugins.service.ts +1 -1
- package/src/modules/secrets-vault/secrets-vault.routes.ts +1 -1
- package/src/modules/skills/__tests__/skills.service.test.ts +1 -1
- package/src/modules/skills/pending-skills.service.ts +2 -1
- package/src/modules/skills/skills.service.ts +1 -1
- package/src/modules/tool-helpers/tool-context.ts +6 -5
- package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +1 -1
- package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +1 -1
- package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +1 -1
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +1 -1
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +1 -1
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
- package/src/modules/tool-manuals/tool-manuals.routes.ts +2 -1
- package/src/modules/tool-manuals/tool-manuals.service.ts +1 -1
- package/src/modules/tool-manuals/tool-manuals.tools.ts +1 -1
- package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +1 -1
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +1 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +1 -1
- package/src/modules/workflow/file-lock.service.ts +1 -1
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +1 -1
- package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
- package/src/modules/workflow/git/__tests__/git.service.diffFileAtCommit.test.ts +1 -1
- package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +1 -1
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +1 -1
- package/src/modules/workflow/git/git.service.ts +5 -5
- package/src/modules/workflow/git/pull-request.service.ts +1 -1
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +2 -2
- package/src/modules/workflow/session-ontology.service.ts +1 -1
- package/src/modules/workflow/workflow.routes.ts +2 -2
- package/src/modules/workflow/workflow.service.ts +5 -5
- package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +1 -1
- package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +1 -1
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +1 -1
- package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -5
- package/src/modules/workspace/__tests__/workspace.service.test.ts +13 -1
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +1 -1
- package/src/modules/workspace/startup/kb-startup-runner.ts +1 -1
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +1 -1
- package/src/modules/workspace/startup/steps/groups-to-plugins.step.ts +1 -1
- package/src/modules/workspace/startup/steps/roles-yaml.step.ts +2 -2
- package/src/modules/workspace/startup/steps/seed-tree.ts +1 -1
- package/src/modules/workspace/workspace.routes.ts +6 -5
- package/src/modules/workspace/workspace.service.ts +14 -18
- package/src/modules/workspace/workspace.tools.ts +3 -3
- package/src/shared/__tests__/join-request.test.ts +1 -1
- package/src/shared/__tests__/workspace-id.test.ts +17 -0
- package/src/{modules/workflow/workflow.errors.ts → shared/domain-errors.ts} +6 -1
- package/src/shared/workspace-id.ts +36 -0
- /package/src/modules/{access → access-model}/__tests__/kb-read-filter.test.ts +0 -0
- /package/src/modules/{access → access-model}/__tests__/roles-yaml-guard.test.ts +0 -0
- /package/src/modules/{access → access-model}/kb-read-filter.ts +0 -0
- /package/src/modules/{workflow/git → kb-fs}/__tests__/clone-config.test.ts +0 -0
- /package/src/modules/{workflow → kb-fs}/__tests__/file-change-notifier.test.ts +0 -0
- /package/src/modules/{workflow/git → kb-fs}/__tests__/mutex.test.ts +0 -0
- /package/src/modules/{workflow/git → kb-fs}/clone-config.ts +0 -0
- /package/src/modules/{workflow → kb-fs}/file-change-notifier.ts +0 -0
- /package/src/modules/{workflow/git → kb-fs}/mutex.ts +0 -0
- /package/src/modules/{workflow → kb-fs}/read-only-filesystem.ts +0 -0
|
@@ -13,16 +13,34 @@ import type {
|
|
|
13
13
|
GrantSources,
|
|
14
14
|
ResolvedPrincipal,
|
|
15
15
|
} from './access-control.interface.js';
|
|
16
|
-
import { AccessConfigError } from '
|
|
17
|
-
// NOTE: deliberate module cycle — group-files.ts imports this module's parsing
|
|
18
|
-
// primitives. Benign: both sides only dereference the other's exports inside
|
|
19
|
-
// function bodies, never at module-evaluation time.
|
|
16
|
+
import { AccessConfigError } from '../access-model/access-errors.js';
|
|
20
17
|
import {
|
|
21
18
|
GROUPS_YAML,
|
|
22
19
|
SYNCED_GROUPS_YAML,
|
|
23
20
|
parseGroupsFile,
|
|
24
21
|
type GroupsIndex,
|
|
25
|
-
} from '
|
|
22
|
+
} from '../access-model/group-files.js';
|
|
23
|
+
import {
|
|
24
|
+
ADMIN_CANONICAL,
|
|
25
|
+
EVERYONE_CANONICAL,
|
|
26
|
+
EVERYONE_DISPLAY,
|
|
27
|
+
KNOWN_VERBS,
|
|
28
|
+
ROLE_TOKEN_PREFIX,
|
|
29
|
+
accessMdSelfEntries,
|
|
30
|
+
canonicalEmail,
|
|
31
|
+
canonicalRoleName,
|
|
32
|
+
isAccessMdPath,
|
|
33
|
+
mergeGroupsIntoRoles,
|
|
34
|
+
parseAccessFile,
|
|
35
|
+
parseOwnAccessEntries,
|
|
36
|
+
parseRolesYaml,
|
|
37
|
+
sourceVerbsFor,
|
|
38
|
+
type AccessFile,
|
|
39
|
+
type OwnEntries,
|
|
40
|
+
type ParsedEntry,
|
|
41
|
+
type RolesIndex,
|
|
42
|
+
type Verb,
|
|
43
|
+
} from '../access-model/access-grammar.js';
|
|
26
44
|
import {
|
|
27
45
|
DIRECTORY_SYNC_BOT_EMAIL,
|
|
28
46
|
DIRECTORY_SYNC_BOT_NAME,
|
|
@@ -94,157 +112,6 @@ function sha256Email(email: string): string {
|
|
|
94
112
|
return createHash('sha256').update(email.trim().toLowerCase()).digest('hex');
|
|
95
113
|
}
|
|
96
114
|
|
|
97
|
-
// ---------------------------------------------------------------------------
|
|
98
|
-
// Constants — kept in lockstep with knowledge-base/lib/access-control.js
|
|
99
|
-
// ---------------------------------------------------------------------------
|
|
100
|
-
|
|
101
|
-
export const ADMIN_CANONICAL = 'admin';
|
|
102
|
-
/**
|
|
103
|
-
* Verbs the resolver understands in an `access.md` frontmatter. Each verb
|
|
104
|
-
* is a list of grants (role or `Name <email>` references, optionally
|
|
105
|
-
* prefixed with `deny `). Keep `Verb` and `KNOWN_VERBS` in lockstep —
|
|
106
|
-
* `AccessFile.entries` is statically keyed on this union.
|
|
107
|
-
*
|
|
108
|
-
* `read` controls who may VIEW a path (the file viewer, embed surface, and
|
|
109
|
-
* the agent's read tools). It is **default-deny**: a path with no effective
|
|
110
|
-
* `read:` or `owner:` grant is not readable. To make content public, list the
|
|
111
|
-
* built-in role `everyone` under `read:`.
|
|
112
|
-
*
|
|
113
|
-
* The verbs nest: `owner` is a superset of `read` + `write` + `download`, and
|
|
114
|
-
* `write` is itself a superset of `read` (anyone who can edit can view). An
|
|
115
|
-
* `owner` grant therefore confers all three lower verbs, a `write` grant
|
|
116
|
-
* additionally confers `read`, and `owner` also marks the principal as a
|
|
117
|
-
* contact point for the node (surfaced in the UI so users know who to ask).
|
|
118
|
-
* See `sourceVerbsFor` for how these implications fold into resolution.
|
|
119
|
-
*/
|
|
120
|
-
export const KNOWN_VERBS = ['read', 'write', 'download', 'owner'] as const;
|
|
121
|
-
export type Verb = (typeof KNOWN_VERBS)[number];
|
|
122
|
-
const KNOWN_VERBS_SET: ReadonlySet<string> = new Set<string>(KNOWN_VERBS);
|
|
123
|
-
export const EVERYONE_CANONICAL = 'everyone';
|
|
124
|
-
/** Display name for the built-in `everyone` role in the share UI. */
|
|
125
|
-
export const EVERYONE_DISPLAY = 'Everyone';
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Verbs whose entries contribute to resolving `verb`, target verb first.
|
|
129
|
-
* `owner` implies `read`, `write`, and `download`; `write` additionally
|
|
130
|
-
* implies `read`. So resolving `read` folds in `write` and `owner`, resolving
|
|
131
|
-
* `write`/`download` folds in `owner`, and resolving `owner` uses only `owner`.
|
|
132
|
-
*
|
|
133
|
-
* The implication is **grant-only** (see `resolveAtPath`): a superset grant
|
|
134
|
-
* confers the lower verb, but a superset *denial* does not — `deny write` says
|
|
135
|
-
* nothing about `read`, so it never strips a separate read grant. The target
|
|
136
|
-
* verb itself contributes both its grants and its denials.
|
|
137
|
-
*/
|
|
138
|
-
export function sourceVerbsFor(verb: Verb): Verb[] {
|
|
139
|
-
switch (verb) {
|
|
140
|
-
case 'owner':
|
|
141
|
-
return ['owner'];
|
|
142
|
-
case 'read':
|
|
143
|
-
return ['read', 'write', 'owner'];
|
|
144
|
-
default:
|
|
145
|
-
return [verb, 'owner'];
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
export const RESERVED_ROLE_NAMES = new Set(['deny', EVERYONE_CANONICAL]);
|
|
149
|
-
export const DENY_PREFIX = 'deny ';
|
|
150
|
-
/**
|
|
151
|
-
* Member-entry prefix in roles.yaml that references a GROUP instead of an
|
|
152
|
-
* email: `- group:Engineering`. Explicit on purpose — membership kind is
|
|
153
|
-
* never guessed from string shape. Valid on EVERY role including Admin, with
|
|
154
|
-
* one kept invariant (see `parseRolesYaml`): Admin must always retain at
|
|
155
|
-
* least one direct email member, so a misconfigured or unreachable directory
|
|
156
|
-
* can never leave the deployment without a rescuable admin.
|
|
157
|
-
*/
|
|
158
|
-
export const GROUP_REF_PREFIX = 'group:';
|
|
159
|
-
/**
|
|
160
|
-
* Explicit ROLE token prefix in access.md entries: `role/<Name>` resolves to
|
|
161
|
-
* the roles.yaml role only, never a group. A BARE name resolves GROUP-FIRST
|
|
162
|
-
* and falls back to the role — so `role/` is the escape hatch when a group
|
|
163
|
-
* shares the role's name. The prefix is reserved in the group name-safety
|
|
164
|
-
* rules (a group may never be named `role/...`), and every role is also
|
|
165
|
-
* registered in the principal index under its `role/<canonical>` alias.
|
|
166
|
-
*/
|
|
167
|
-
export const ROLE_TOKEN_PREFIX = 'role/';
|
|
168
|
-
|
|
169
|
-
export const USER_REF_REGEX = /^(.+?)\s+<\s*([^<>\s]+@[^<>\s]+)\s*>\s*$/;
|
|
170
|
-
export const EMAIL_REGEX = /^[^<>\s@]+@[^<>\s@]+\.[^<>\s@]+$/;
|
|
171
|
-
|
|
172
|
-
// ---------------------------------------------------------------------------
|
|
173
|
-
// Domain types
|
|
174
|
-
// ---------------------------------------------------------------------------
|
|
175
|
-
|
|
176
|
-
export interface RoleEntry {
|
|
177
|
-
kind: 'role';
|
|
178
|
-
role: string; // canonical (lowercase, single-spaced)
|
|
179
|
-
displayRole: string; // original casing/spacing
|
|
180
|
-
deny: boolean;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
export interface UserEntry {
|
|
184
|
-
kind: 'user';
|
|
185
|
-
email: string; // canonical (lowercased, trimmed)
|
|
186
|
-
displayName: string;
|
|
187
|
-
deny: boolean;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
export type ParsedEntry = RoleEntry | UserEntry;
|
|
191
|
-
|
|
192
|
-
export interface AccessFile {
|
|
193
|
-
/** repo-relative POSIX path — e.g. `access.md`, `Knowledge/Sales/access.md`. */
|
|
194
|
-
path: string;
|
|
195
|
-
/** repo-relative POSIX directory — `''` for root. */
|
|
196
|
-
dir: string;
|
|
197
|
-
/**
|
|
198
|
-
* Verb → list of grants/denials. Always carries an entry for every
|
|
199
|
-
* known verb (empty array when the verb wasn't declared in the file) so
|
|
200
|
-
* the resolver doesn't need to null-check at every chain step.
|
|
201
|
-
*/
|
|
202
|
-
entries: Record<Verb, ParsedEntry[]>;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function emptyEntries(): Record<Verb, ParsedEntry[]> {
|
|
206
|
-
const out = {} as Record<Verb, ParsedEntry[]>;
|
|
207
|
-
for (const v of KNOWN_VERBS) out[v] = [];
|
|
208
|
-
return out;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
export function isAccessMdPath(p: string): boolean {
|
|
212
|
-
return p === 'access.md' || p.endsWith('/access.md');
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Extensions of node files whose OWN `---` frontmatter can carry access verbs
|
|
217
|
-
* the resolver enforces (`readOwnEntries` → `parseOwnAccessEntries`). The
|
|
218
|
-
* SINGLE source of truth for every surface that enumerates candidate files —
|
|
219
|
-
* the access-declarations scan and the shared `KbReferenceScanner` (which
|
|
220
|
-
* must scan/rewrite the same set, or a rename strands a live `.tool`
|
|
221
|
-
* frontmatter grant). `access.md` is covered by `.md`.
|
|
222
|
-
*/
|
|
223
|
-
export const ACCESS_FRONTMATTER_EXTENSIONS = ['.md', '.tool'] as const;
|
|
224
|
-
|
|
225
|
-
/** True when `p` is a file the resolver reads access frontmatter from. */
|
|
226
|
-
export function hasAccessFrontmatterExtension(p: string): boolean {
|
|
227
|
-
return ACCESS_FRONTMATTER_EXTENSIONS.some((ext) => p.endsWith(ext));
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* The PRINCIPAL index — canonical name → member emails. Despite the name it
|
|
232
|
-
* holds both kinds of named principal after `mergeGroupsIntoRoles` runs:
|
|
233
|
-
* roles.yaml roles (`kind: 'role'`, the default) and the active group file's
|
|
234
|
-
* groups (`kind: 'group'`). Grant resolution treats them identically — a
|
|
235
|
-
* grant names a principal, the principal has member emails — which is what
|
|
236
|
-
* lets the whole closeness-first resolver work on groups without changes.
|
|
237
|
-
*
|
|
238
|
-
* `groupRefs` carries a role's `group:<Name>` member entries between parse
|
|
239
|
-
* and merge; the merge expands them into `emails`/`byEmail`.
|
|
240
|
-
*/
|
|
241
|
-
interface RolesIndex {
|
|
242
|
-
byCanonical: Map<
|
|
243
|
-
string,
|
|
244
|
-
{ displayName: string; emails: Set<string>; groupRefs?: Set<string>; kind?: 'role' | 'group' }
|
|
245
|
-
>;
|
|
246
|
-
byEmail: Map<string, Set<string>>;
|
|
247
|
-
}
|
|
248
115
|
|
|
249
116
|
interface AccessModel {
|
|
250
117
|
roles: RolesIndex;
|
|
@@ -280,450 +147,6 @@ function roleKnown(roles: RolesIndex, canonicalRole: string): boolean {
|
|
|
280
147
|
return roles.byCanonical.has(canonicalRole) || isBuiltInRole(canonicalRole);
|
|
281
148
|
}
|
|
282
149
|
|
|
283
|
-
// ---------------------------------------------------------------------------
|
|
284
|
-
// Tiny YAML subset parser — handles only block mappings + block sequences
|
|
285
|
-
// of plain scalars. See `knowledge-base/lib/access-control.js` for the
|
|
286
|
-
// reference implementation (this file is the TypeScript port).
|
|
287
|
-
// ---------------------------------------------------------------------------
|
|
288
|
-
|
|
289
|
-
type YamlValue = string | YamlValue[] | { [key: string]: YamlValue } | null;
|
|
290
|
-
|
|
291
|
-
interface YamlOk {
|
|
292
|
-
ok: true;
|
|
293
|
-
value: YamlValue;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
interface YamlErr {
|
|
297
|
-
ok: false;
|
|
298
|
-
error: string;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Strip a trailing `# comment` the way the YAML-subset tokeniser reads a
|
|
303
|
-
* line: a `#` at the start (after only whitespace) or preceded by whitespace
|
|
304
|
-
* begins a comment. Exported so the reference scanner matches tokens against
|
|
305
|
-
* the SAME comment rule the resolver parses with (a `- GTM Team # sales`
|
|
306
|
-
* entry is the token `GTM Team`, never `GTM Team # sales`).
|
|
307
|
-
*/
|
|
308
|
-
export function stripComment(line: string): string {
|
|
309
|
-
let inWs = true;
|
|
310
|
-
for (let i = 0; i < line.length; i++) {
|
|
311
|
-
const ch = line[i];
|
|
312
|
-
if (ch === '#' && (inWs || (i > 0 && /\s/.test(line[i - 1])))) {
|
|
313
|
-
return line.slice(0, i);
|
|
314
|
-
}
|
|
315
|
-
if (!/\s/.test(ch)) inWs = false;
|
|
316
|
-
}
|
|
317
|
-
return line;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
interface Token {
|
|
321
|
-
lineNum: number;
|
|
322
|
-
indent: number;
|
|
323
|
-
kind: 'kv' | 'item';
|
|
324
|
-
key?: string;
|
|
325
|
-
value: string;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
function tokenise(
|
|
329
|
-
text: string,
|
|
330
|
-
opts?: { tolerateEmptyKeys?: boolean },
|
|
331
|
-
): YamlErr | { ok: true; tokens: Token[] } {
|
|
332
|
-
const lines = text.split(/\r?\n/);
|
|
333
|
-
const tokens: Token[] = [];
|
|
334
|
-
// Tolerated blank keys are uniquified as runs of spaces so a SECOND blank
|
|
335
|
-
// key doesn't trip the duplicate-key check (which would retire every valid
|
|
336
|
-
// group after it). All-whitespace keys still canonicalize to '' downstream,
|
|
337
|
-
// so the entry-level "empty group name — skipped" handling sees them all.
|
|
338
|
-
let blankKeySeq = 0;
|
|
339
|
-
for (let i = 0; i < lines.length; i++) {
|
|
340
|
-
const stripped = stripComment(lines[i]).replace(/\s+$/, '');
|
|
341
|
-
if (!stripped.trim()) continue;
|
|
342
|
-
const indentMatch = stripped.match(/^( *)/);
|
|
343
|
-
const indent = indentMatch ? indentMatch[1].length : 0;
|
|
344
|
-
const content = stripped.slice(indent);
|
|
345
|
-
const lineNum = i + 1;
|
|
346
|
-
|
|
347
|
-
if (content.startsWith('- ') || content === '-') {
|
|
348
|
-
const value = content === '-' ? '' : content.slice(2).trim();
|
|
349
|
-
tokens.push({ lineNum, indent, kind: 'item', value });
|
|
350
|
-
continue;
|
|
351
|
-
}
|
|
352
|
-
const colonIdx = content.indexOf(':');
|
|
353
|
-
if (colonIdx < 0) {
|
|
354
|
-
return { ok: false, error: `line ${lineNum}: expected 'key:' or '- value' but got '${content}'` };
|
|
355
|
-
}
|
|
356
|
-
let key = content.slice(0, colonIdx).trim();
|
|
357
|
-
const valuePart = content.slice(colonIdx + 1).trim();
|
|
358
|
-
// An empty key is normally a hard error (roles.yaml/access.md want loud
|
|
359
|
-
// failures), but a parser with ENTRY-level forgiveness (the group files:
|
|
360
|
-
// one bad entry must not retire every other group) keeps it as a
|
|
361
|
-
// whitespace key for its own skip-with-warning handling.
|
|
362
|
-
if (!key) {
|
|
363
|
-
if (!opts?.tolerateEmptyKeys) {
|
|
364
|
-
return { ok: false, error: `line ${lineNum}: empty mapping key` };
|
|
365
|
-
}
|
|
366
|
-
key = ' '.repeat(++blankKeySeq);
|
|
367
|
-
}
|
|
368
|
-
tokens.push({ lineNum, indent, kind: 'kv', key, value: valuePart });
|
|
369
|
-
}
|
|
370
|
-
return { ok: true, tokens };
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
export function parseYamlSubset(
|
|
374
|
-
text: string,
|
|
375
|
-
opts?: { tolerateEmptyKeys?: boolean },
|
|
376
|
-
): YamlOk | YamlErr {
|
|
377
|
-
const tok = tokenise(text, opts);
|
|
378
|
-
if (!tok.ok) return tok;
|
|
379
|
-
if (tok.tokens.length === 0) return { ok: true, value: {} };
|
|
380
|
-
|
|
381
|
-
type Frame = { indent: number; container: YamlValue[] | { [key: string]: YamlValue }; kind: 'map' | 'list' };
|
|
382
|
-
const root: { [key: string]: YamlValue } = {};
|
|
383
|
-
const stack: Frame[] = [{ indent: -1, container: root, kind: 'map' }];
|
|
384
|
-
|
|
385
|
-
for (let t = 0; t < tok.tokens.length; t++) {
|
|
386
|
-
const cur = tok.tokens[t];
|
|
387
|
-
while (stack.length > 1 && stack[stack.length - 1].indent >= cur.indent) {
|
|
388
|
-
stack.pop();
|
|
389
|
-
}
|
|
390
|
-
const top = stack[stack.length - 1];
|
|
391
|
-
|
|
392
|
-
if (cur.kind === 'item') {
|
|
393
|
-
if (top.kind !== 'list') {
|
|
394
|
-
return {
|
|
395
|
-
ok: false,
|
|
396
|
-
error: `line ${cur.lineNum}: list item with no enclosing list (indent ${cur.indent})`,
|
|
397
|
-
};
|
|
398
|
-
}
|
|
399
|
-
(top.container as YamlValue[]).push(cur.value);
|
|
400
|
-
continue;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
if (top.kind !== 'map') {
|
|
404
|
-
return {
|
|
405
|
-
ok: false,
|
|
406
|
-
error: `line ${cur.lineNum}: mapping key '${cur.key}' inside a list — not supported`,
|
|
407
|
-
};
|
|
408
|
-
}
|
|
409
|
-
const map = top.container as { [key: string]: YamlValue };
|
|
410
|
-
const key = cur.key as string;
|
|
411
|
-
if (Object.prototype.hasOwnProperty.call(map, key)) {
|
|
412
|
-
return { ok: false, error: `line ${cur.lineNum}: duplicate key '${key}'` };
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
if (cur.value !== '') {
|
|
416
|
-
// Inline empty collections are the only flow-style YAML we accept, so
|
|
417
|
-
// `owner: []` / `download: []` read as an empty list rather than the
|
|
418
|
-
// scalar string "[]".
|
|
419
|
-
if (cur.value === '[]') {
|
|
420
|
-
map[key] = [];
|
|
421
|
-
continue;
|
|
422
|
-
}
|
|
423
|
-
map[key] = cur.value;
|
|
424
|
-
continue;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
const next = tok.tokens[t + 1];
|
|
428
|
-
if (!next || next.indent <= cur.indent) {
|
|
429
|
-
map[key] = null;
|
|
430
|
-
continue;
|
|
431
|
-
}
|
|
432
|
-
if (next.kind === 'item') {
|
|
433
|
-
const list: YamlValue[] = [];
|
|
434
|
-
map[key] = list;
|
|
435
|
-
stack.push({ indent: cur.indent, container: list, kind: 'list' });
|
|
436
|
-
} else {
|
|
437
|
-
const sub: { [key: string]: YamlValue } = {};
|
|
438
|
-
map[key] = sub;
|
|
439
|
-
stack.push({ indent: cur.indent, container: sub, kind: 'map' });
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
return { ok: true, value: root };
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// ---------------------------------------------------------------------------
|
|
447
|
-
// Frontmatter extraction
|
|
448
|
-
// ---------------------------------------------------------------------------
|
|
449
|
-
|
|
450
|
-
export function extractFrontmatter(
|
|
451
|
-
text: string,
|
|
452
|
-
): { ok: true; frontmatter: string } | { ok: false; error: string } {
|
|
453
|
-
const lines = text.split(/\r?\n/);
|
|
454
|
-
if (lines.length === 0 || lines[0].trim() !== '---') {
|
|
455
|
-
return { ok: false, error: 'expected `---` on the first line' };
|
|
456
|
-
}
|
|
457
|
-
for (let i = 1; i < lines.length; i++) {
|
|
458
|
-
if (lines[i].trim() === '---') {
|
|
459
|
-
return { ok: true, frontmatter: lines.slice(1, i).join('\n') };
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
return { ok: false, error: 'unterminated frontmatter — no closing `---` found' };
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/** The text AFTER the closing frontmatter fence ('' when there is no fence). */
|
|
466
|
-
export function bodyAfterFrontmatter(text: string): string {
|
|
467
|
-
const lines = text.split(/\r?\n/);
|
|
468
|
-
if (lines.length === 0 || lines[0].trim() !== '---') return '';
|
|
469
|
-
for (let i = 1; i < lines.length; i++) {
|
|
470
|
-
if (lines[i].trim() === '---') return lines.slice(i + 1).join('\n');
|
|
471
|
-
}
|
|
472
|
-
return '';
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// ---------------------------------------------------------------------------
|
|
476
|
-
// Canonicalisation
|
|
477
|
-
// ---------------------------------------------------------------------------
|
|
478
|
-
|
|
479
|
-
export function canonicalRoleName(name: string): string {
|
|
480
|
-
return name.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
export function canonicalEmail(email: string): string {
|
|
484
|
-
return email.trim().toLowerCase();
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// ---------------------------------------------------------------------------
|
|
488
|
-
// Entry parser
|
|
489
|
-
// ---------------------------------------------------------------------------
|
|
490
|
-
|
|
491
|
-
export function parseAccessEntry(
|
|
492
|
-
raw: unknown,
|
|
493
|
-
): { ok: true; entry: ParsedEntry } | { ok: false; error: string } {
|
|
494
|
-
if (typeof raw !== 'string') return { ok: false, error: 'entry must be a string' };
|
|
495
|
-
const trimmed = raw.trim();
|
|
496
|
-
if (!trimmed) return { ok: false, error: 'empty entry' };
|
|
497
|
-
|
|
498
|
-
let deny = false;
|
|
499
|
-
let body = trimmed;
|
|
500
|
-
if (trimmed.startsWith(DENY_PREFIX)) {
|
|
501
|
-
deny = true;
|
|
502
|
-
body = trimmed.slice(DENY_PREFIX.length).trim();
|
|
503
|
-
if (!body) return { ok: false, error: `'deny' with no principal` };
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
const userMatch = body.match(USER_REF_REGEX);
|
|
507
|
-
if (userMatch) {
|
|
508
|
-
const displayName = userMatch[1].trim();
|
|
509
|
-
const email = canonicalEmail(userMatch[2]);
|
|
510
|
-
if (!displayName) return { ok: false, error: `user reference '${body}' has no name` };
|
|
511
|
-
if (!EMAIL_REGEX.test(email)) {
|
|
512
|
-
return { ok: false, error: `user reference '${body}' has malformed email '${email}'` };
|
|
513
|
-
}
|
|
514
|
-
return { ok: true, entry: { kind: 'user', email, displayName, deny } };
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
if (body.includes('<') || body.includes('>')) {
|
|
518
|
-
return {
|
|
519
|
-
ok: false,
|
|
520
|
-
error: `entry '${body}' looks like a user reference but doesn't match 'Name <email>' shape`,
|
|
521
|
-
};
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
let role = canonicalRoleName(body);
|
|
525
|
-
if (!role) return { ok: false, error: `empty role name in entry '${raw}'` };
|
|
526
|
-
// Explicit role token: normalize `role/ <Name>` spacing so the canonical
|
|
527
|
-
// form is always `role/<canonicalName>` — the exact alias key the principal
|
|
528
|
-
// index registers for every role.
|
|
529
|
-
if (role.startsWith(ROLE_TOKEN_PREFIX)) {
|
|
530
|
-
const suffix = canonicalRoleName(role.slice(ROLE_TOKEN_PREFIX.length));
|
|
531
|
-
if (!suffix) return { ok: false, error: `entry '${body}' names no role after '${ROLE_TOKEN_PREFIX}'` };
|
|
532
|
-
role = `${ROLE_TOKEN_PREFIX}${suffix}`;
|
|
533
|
-
}
|
|
534
|
-
return { ok: true, entry: { kind: 'role', role, displayRole: body, deny } };
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
// ---------------------------------------------------------------------------
|
|
538
|
-
// roles.yaml + access.md parsers
|
|
539
|
-
// ---------------------------------------------------------------------------
|
|
540
|
-
|
|
541
|
-
export function parseRolesYaml(
|
|
542
|
-
text: string,
|
|
543
|
-
): { ok: true; index: RolesIndex } | { ok: false; errors: string[] } {
|
|
544
|
-
const parsed = parseYamlSubset(text);
|
|
545
|
-
if (!parsed.ok) return { ok: false, errors: [`roles.yaml: ${parsed.error}`] };
|
|
546
|
-
|
|
547
|
-
const errors: string[] = [];
|
|
548
|
-
const root = parsed.value;
|
|
549
|
-
if (root == null || typeof root !== 'object' || Array.isArray(root)) {
|
|
550
|
-
return { ok: false, errors: [`roles.yaml: must be a top-level mapping`] };
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
const rolesNode = (root as Record<string, YamlValue>).roles;
|
|
554
|
-
if (rolesNode == null || typeof rolesNode !== 'object' || Array.isArray(rolesNode)) {
|
|
555
|
-
return { ok: false, errors: [`roles.yaml: missing top-level 'roles:' mapping`] };
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
const index: RolesIndex = {
|
|
559
|
-
byCanonical: new Map(),
|
|
560
|
-
byEmail: new Map(),
|
|
561
|
-
};
|
|
562
|
-
|
|
563
|
-
for (const [displayName, value] of Object.entries(rolesNode as Record<string, YamlValue>)) {
|
|
564
|
-
const canonical = canonicalRoleName(displayName);
|
|
565
|
-
if (!canonical) {
|
|
566
|
-
errors.push(`roles.yaml: empty role name`);
|
|
567
|
-
continue;
|
|
568
|
-
}
|
|
569
|
-
if (RESERVED_ROLE_NAMES.has(canonical)) {
|
|
570
|
-
errors.push(
|
|
571
|
-
`roles.yaml: role '${displayName}' uses reserved name '${canonical}' — this token has special meaning in access entries and cannot be a roles.yaml role`,
|
|
572
|
-
);
|
|
573
|
-
continue;
|
|
574
|
-
}
|
|
575
|
-
if (canonical.startsWith(ROLE_TOKEN_PREFIX)) {
|
|
576
|
-
errors.push(
|
|
577
|
-
`roles.yaml: role '${displayName}' starts with the reserved '${ROLE_TOKEN_PREFIX}' prefix — that spelling is the explicit role token in access entries`,
|
|
578
|
-
);
|
|
579
|
-
continue;
|
|
580
|
-
}
|
|
581
|
-
if (index.byCanonical.has(canonical)) {
|
|
582
|
-
const prev = index.byCanonical.get(canonical)!.displayName;
|
|
583
|
-
errors.push(
|
|
584
|
-
`roles.yaml: role '${displayName}' canonicalises to '${canonical}', which is already declared as '${prev}'`,
|
|
585
|
-
);
|
|
586
|
-
continue;
|
|
587
|
-
}
|
|
588
|
-
if (!Array.isArray(value)) {
|
|
589
|
-
errors.push(`roles.yaml: role '${displayName}' must be a list of emails`);
|
|
590
|
-
continue;
|
|
591
|
-
}
|
|
592
|
-
const emails = new Set<string>();
|
|
593
|
-
const groupRefs = new Set<string>();
|
|
594
|
-
for (const rawEmail of value) {
|
|
595
|
-
if (typeof rawEmail !== 'string') {
|
|
596
|
-
errors.push(`roles.yaml: role '${displayName}' has a non-string entry`);
|
|
597
|
-
continue;
|
|
598
|
-
}
|
|
599
|
-
// `- group:<Name>` assigns the role to a whole group (expanded against
|
|
600
|
-
// the active group source by `mergeGroupsIntoRoles`). Allowed on every
|
|
601
|
-
// role, Admin included — the Admin invariant below only demands at
|
|
602
|
-
// least one DIRECT email member so a broken directory can never leave
|
|
603
|
-
// the deployment adminless.
|
|
604
|
-
if (rawEmail.trim().toLowerCase().startsWith(GROUP_REF_PREFIX)) {
|
|
605
|
-
const refName = canonicalRoleName(rawEmail.trim().slice(GROUP_REF_PREFIX.length));
|
|
606
|
-
if (!refName) {
|
|
607
|
-
errors.push(`roles.yaml: role '${displayName}' has an empty group reference '${rawEmail}'`);
|
|
608
|
-
continue;
|
|
609
|
-
}
|
|
610
|
-
groupRefs.add(refName);
|
|
611
|
-
continue;
|
|
612
|
-
}
|
|
613
|
-
const email = canonicalEmail(rawEmail);
|
|
614
|
-
if (!EMAIL_REGEX.test(email)) {
|
|
615
|
-
errors.push(`roles.yaml: role '${displayName}' has malformed email '${rawEmail}'`);
|
|
616
|
-
continue;
|
|
617
|
-
}
|
|
618
|
-
emails.add(email);
|
|
619
|
-
let set = index.byEmail.get(email);
|
|
620
|
-
if (!set) {
|
|
621
|
-
set = new Set();
|
|
622
|
-
index.byEmail.set(email, set);
|
|
623
|
-
}
|
|
624
|
-
set.add(canonical);
|
|
625
|
-
}
|
|
626
|
-
index.byCanonical.set(canonical, { displayName: displayName.trim(), emails, groupRefs });
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
if (!index.byCanonical.has(ADMIN_CANONICAL)) {
|
|
630
|
-
errors.push(`roles.yaml: must declare at least one 'Admin' role`);
|
|
631
|
-
} else if (index.byCanonical.get(ADMIN_CANONICAL)!.emails.size === 0) {
|
|
632
|
-
// The kept invariant: Admin may reference groups, but must ALWAYS retain
|
|
633
|
-
// at least one direct email member — the rescue story requires an admin
|
|
634
|
-
// whose membership does not depend on a reachable, well-configured
|
|
635
|
-
// directory. A group-only Admin is as hard an error as an adminless one.
|
|
636
|
-
errors.push(
|
|
637
|
-
`roles.yaml: 'Admin' role has no direct email members — Admin must keep at least one individual email (group references alone are not enough)`,
|
|
638
|
-
);
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
if (errors.length) return { ok: false, errors };
|
|
642
|
-
return { ok: true, index };
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
/**
|
|
646
|
-
* Merge the active group source into the principal index and expand role →
|
|
647
|
-
* group assignments. Mutates `index` in place; returns human-readable
|
|
648
|
-
* warnings (callers log them — nothing here ever throws, because group
|
|
649
|
-
* problems must degrade, not brick access resolution).
|
|
650
|
-
*
|
|
651
|
-
* Rules (the grant-grammar precedence):
|
|
652
|
-
* - Every role is ALSO registered under its explicit `role/<canonical>`
|
|
653
|
-
* alias — the token that always resolves to the role.
|
|
654
|
-
* - A BARE name resolves GROUP-FIRST: when a group's canonical name
|
|
655
|
-
* collides with a role's, the bare key resolves to the GROUP (warned);
|
|
656
|
-
* the role stays reachable via `role/<canonical>`.
|
|
657
|
-
* - Role `group:<Name>` refs — Admin's included — expand against the
|
|
658
|
-
* merged groups; an unknown ref contributes nothing (warned).
|
|
659
|
-
* - `byEmail` is rebuilt so each member holds exactly the tokens that
|
|
660
|
-
* resolve to a principal they belong to (bare + `role/` alias for roles,
|
|
661
|
-
* bare for groups).
|
|
662
|
-
*/
|
|
663
|
-
export function mergeGroupsIntoRoles(
|
|
664
|
-
index: RolesIndex,
|
|
665
|
-
groups: GroupsIndex,
|
|
666
|
-
sourceFile: string,
|
|
667
|
-
): string[] {
|
|
668
|
-
const warnings: string[] = [];
|
|
669
|
-
// Snapshot before any group lands: at this point the index holds roles only.
|
|
670
|
-
const roleRecords = new Map(index.byCanonical);
|
|
671
|
-
|
|
672
|
-
// 1. Expand role → group assignments (Admin included — its safety net is
|
|
673
|
-
// the parse-time "at least one direct email" invariant, not a merge skip).
|
|
674
|
-
for (const [, principal] of roleRecords) {
|
|
675
|
-
if (!principal.groupRefs?.size) continue;
|
|
676
|
-
for (const ref of principal.groupRefs) {
|
|
677
|
-
const group = groups.get(ref);
|
|
678
|
-
if (!group) {
|
|
679
|
-
warnings.push(
|
|
680
|
-
`roles.yaml: role '${principal.displayName}' references unknown group '${ref}' — reference ignored`,
|
|
681
|
-
);
|
|
682
|
-
continue;
|
|
683
|
-
}
|
|
684
|
-
for (const email of group.emails) principal.emails.add(email);
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// 2. Bare-name precedence: groups win the bare token; the role keeps its
|
|
689
|
-
// `role/<canonical>` alias registered below.
|
|
690
|
-
for (const [canonical, def] of groups) {
|
|
691
|
-
if (roleRecords.has(canonical)) {
|
|
692
|
-
warnings.push(
|
|
693
|
-
`${sourceFile}: group '${def.displayName}' shares its name with a role — the bare name now resolves to the GROUP; use '${ROLE_TOKEN_PREFIX}${canonical}' to reference the role`,
|
|
694
|
-
);
|
|
695
|
-
}
|
|
696
|
-
index.byCanonical.set(canonical, {
|
|
697
|
-
displayName: def.displayName,
|
|
698
|
-
emails: new Set(def.emails),
|
|
699
|
-
kind: 'group',
|
|
700
|
-
});
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
// 3. Explicit `role/<canonical>` alias for every role (same record — the
|
|
704
|
-
// alias and the bare key, when the role still owns it, stay in lockstep).
|
|
705
|
-
for (const [canonical, principal] of roleRecords) {
|
|
706
|
-
principal.kind = 'role';
|
|
707
|
-
index.byCanonical.set(`${ROLE_TOKEN_PREFIX}${canonical}`, principal);
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
// 4. Rebuild the email → tokens map from the final index so membership
|
|
711
|
-
// reflects the post-precedence keys (a collided role's members no longer
|
|
712
|
-
// hold the bare token unless the group also contains them).
|
|
713
|
-
index.byEmail.clear();
|
|
714
|
-
for (const [key, principal] of index.byCanonical) {
|
|
715
|
-
for (const email of principal.emails) {
|
|
716
|
-
let set = index.byEmail.get(email);
|
|
717
|
-
if (!set) {
|
|
718
|
-
set = new Set();
|
|
719
|
-
index.byEmail.set(email, set);
|
|
720
|
-
}
|
|
721
|
-
set.add(key);
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
return warnings;
|
|
726
|
-
}
|
|
727
150
|
|
|
728
151
|
/**
|
|
729
152
|
* Health of the ACTIVE groups source as of the last load. `ok: false` means
|
|
@@ -799,126 +222,6 @@ export async function loadActiveGroups(
|
|
|
799
222
|
return { groups: parsed.groups, sourceFile, warnings: parsed.warnings, health: { ok: true } };
|
|
800
223
|
}
|
|
801
224
|
|
|
802
|
-
/**
|
|
803
|
-
* Does an `access.md` body declare access rules — i.e. is the file in the NEW
|
|
804
|
-
* (body-governs-the-folder) format?
|
|
805
|
-
*
|
|
806
|
-
* The two-format story: historically the FRONTMATTER carried the folder's
|
|
807
|
-
* rules and the file could not govern itself. The new format follows the
|
|
808
|
-
* convention every other file uses — frontmatter is about the FILE (who may
|
|
809
|
-
* read/write `access.md` itself), and the content (the body) is the folder's
|
|
810
|
-
* rules. The compat rule, applied per file: **when the body is not parsable as
|
|
811
|
-
* rules, the frontmatter is resolved for the folder as before.**
|
|
812
|
-
*
|
|
813
|
-
* "Parsable as rules" is deliberately shallow — the body YAML-parses to a
|
|
814
|
-
* mapping that names at least one known verb. A prose body (the repo-root
|
|
815
|
-
* README-style file) fails the YAML parse or carries no verb key and stays
|
|
816
|
-
* legacy. A body that DOES name a verb has claimed to be rules: shape errors
|
|
817
|
-
* inside it (a scalar verb, a malformed entry) are then hard parse ERRORS of
|
|
818
|
-
* the file, never a silent fallback to the frontmatter — falling back would
|
|
819
|
-
* let a typo in the body hand the folder to the frontmatter's (possibly
|
|
820
|
-
* `read: everyone`) self-rules.
|
|
821
|
-
*/
|
|
822
|
-
export function accessMdDeclaresBodyRules(text: string): boolean {
|
|
823
|
-
const body = bodyAfterFrontmatter(text);
|
|
824
|
-
if (!body.trim()) return false;
|
|
825
|
-
const parsed = parseYamlSubset(body);
|
|
826
|
-
if (!parsed.ok) return false;
|
|
827
|
-
const root = parsed.value;
|
|
828
|
-
if (root == null || typeof root !== 'object' || Array.isArray(root)) return false;
|
|
829
|
-
return Object.keys(root as Record<string, YamlValue>).some((k) => KNOWN_VERBS_SET.has(k));
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
/**
|
|
833
|
-
* The access verbs an `access.md` declares FOR ITSELF — its frontmatter, and
|
|
834
|
-
* only in the new format (body-governed). A legacy file cannot govern itself:
|
|
835
|
-
* its frontmatter IS the folder's rules, so returning them as own-entries
|
|
836
|
-
* would double-apply them at the file scope.
|
|
837
|
-
*
|
|
838
|
-
* Parsed forgivingly (like node frontmatter): the new-format frontmatter may
|
|
839
|
-
* carry non-access keys, and a typo there must not make the file unreadable.
|
|
840
|
-
*/
|
|
841
|
-
export function accessMdSelfEntries(text: string): Record<Verb, ParsedEntry[]> | null {
|
|
842
|
-
if (!accessMdDeclaresBodyRules(text)) return null;
|
|
843
|
-
return parseOwnAccessEntries(text);
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
/**
|
|
847
|
-
* Parse one `access.md`'s FOLDER rules into its per-verb entry lists.
|
|
848
|
-
*
|
|
849
|
-
* Two formats (see {@link accessMdDeclaresBodyRules}): when the body declares
|
|
850
|
-
* rules, the body is parsed (new format — frontmatter then governs the file
|
|
851
|
-
* itself via {@link accessMdSelfEntries}); otherwise the frontmatter is parsed
|
|
852
|
-
* (legacy format), exactly as before.
|
|
853
|
-
*
|
|
854
|
-
* Exported so read-only surfaces can report WHERE rules are declared without
|
|
855
|
-
* reimplementing the parse (see `access-declarations.ts`). Exporting changes
|
|
856
|
-
* nothing about resolution — this is the same function the resolver builds its
|
|
857
|
-
* model from, so a display of declarations can never drift from the rules that
|
|
858
|
-
* are actually enforced.
|
|
859
|
-
*/
|
|
860
|
-
export function parseAccessFile(
|
|
861
|
-
text: string,
|
|
862
|
-
relativePath: string,
|
|
863
|
-
): { ok: true; file: AccessFile; warnings: string[] } | { ok: false; errors: string[] } {
|
|
864
|
-
const bodyFormat = accessMdDeclaresBodyRules(text);
|
|
865
|
-
let ruleSource: string;
|
|
866
|
-
if (bodyFormat) {
|
|
867
|
-
ruleSource = bodyAfterFrontmatter(text);
|
|
868
|
-
} else {
|
|
869
|
-
const fm = extractFrontmatter(text);
|
|
870
|
-
if (!fm.ok) return { ok: false, errors: [`${relativePath}: ${fm.error}`] };
|
|
871
|
-
ruleSource = fm.frontmatter;
|
|
872
|
-
}
|
|
873
|
-
const parsed = parseYamlSubset(ruleSource);
|
|
874
|
-
if (!parsed.ok) return { ok: false, errors: [`${relativePath}: ${parsed.error}`] };
|
|
875
|
-
|
|
876
|
-
const root = parsed.value;
|
|
877
|
-
if (root == null || typeof root !== 'object' || Array.isArray(root)) {
|
|
878
|
-
return {
|
|
879
|
-
ok: false,
|
|
880
|
-
errors: [`${relativePath}: ${bodyFormat ? 'body' : 'frontmatter'} must be a mapping`],
|
|
881
|
-
};
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
const errors: string[] = [];
|
|
885
|
-
const warnings: string[] = [];
|
|
886
|
-
const entries = emptyEntries();
|
|
887
|
-
|
|
888
|
-
for (const [key, value] of Object.entries(root as Record<string, YamlValue>)) {
|
|
889
|
-
if (!KNOWN_VERBS_SET.has(key)) {
|
|
890
|
-
// Forgiving by design: unknown keys (typos, future verbs, custom
|
|
891
|
-
// metadata an operator chose to colocate) must not break the access
|
|
892
|
-
// tree. Warn so operators see the typo in logs, then move on as if
|
|
893
|
-
// the key didn't exist. The verbs we DO understand still parse.
|
|
894
|
-
warnings.push(
|
|
895
|
-
`${relativePath}: unknown access key '${key}' — ignored (known: ${[...KNOWN_VERBS].join(', ')})`,
|
|
896
|
-
);
|
|
897
|
-
continue;
|
|
898
|
-
}
|
|
899
|
-
const verb = key as Verb;
|
|
900
|
-
if (!Array.isArray(value)) {
|
|
901
|
-
errors.push(`${relativePath}: '${key}:' must be a list`);
|
|
902
|
-
continue;
|
|
903
|
-
}
|
|
904
|
-
const list: ParsedEntry[] = [];
|
|
905
|
-
for (const raw of value) {
|
|
906
|
-
const result = parseAccessEntry(raw);
|
|
907
|
-
if (!result.ok) {
|
|
908
|
-
errors.push(`${relativePath}: ${result.error}`);
|
|
909
|
-
continue;
|
|
910
|
-
}
|
|
911
|
-
list.push(result.entry);
|
|
912
|
-
}
|
|
913
|
-
entries[verb] = list;
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
if (errors.length) return { ok: false, errors };
|
|
917
|
-
|
|
918
|
-
const slash = relativePath.lastIndexOf('/');
|
|
919
|
-
const dir = slash === -1 ? '' : relativePath.slice(0, slash);
|
|
920
|
-
return { ok: true, file: { path: relativePath, dir, entries }, warnings };
|
|
921
|
-
}
|
|
922
225
|
|
|
923
226
|
// ---------------------------------------------------------------------------
|
|
924
227
|
// Resolver — walks repo root → file dir, accumulating per-principal state.
|
|
@@ -926,54 +229,6 @@ export function parseAccessFile(
|
|
|
926
229
|
|
|
927
230
|
type GrantState = 'grant' | 'denied';
|
|
928
231
|
|
|
929
|
-
/**
|
|
930
|
-
* Per-verb access entries declared in a single node file's *own* frontmatter
|
|
931
|
-
* (the same YAML block that carries `nodeType:`). This is the most-specific
|
|
932
|
-
* scope — applied after every directory `access.md` in the chain.
|
|
933
|
-
*/
|
|
934
|
-
type OwnEntries = Record<Verb, ParsedEntry[]>;
|
|
935
|
-
|
|
936
|
-
/**
|
|
937
|
-
* Parse the access verbs a node file declares in its own YAML frontmatter.
|
|
938
|
-
* Returns the per-verb entry lists, or null when the file has no frontmatter
|
|
939
|
-
* or declares no access verb at all.
|
|
940
|
-
*
|
|
941
|
-
* Forgiving by design — a node's frontmatter legitimately carries non-access
|
|
942
|
-
* keys (notably `nodeType:`), so unknown keys are ignored, and a malformed
|
|
943
|
-
* entry is dropped rather than failing the file. A typo in a node's `owner:`
|
|
944
|
-
* must never make the node unreadable; it just doesn't grant anything.
|
|
945
|
-
*/
|
|
946
|
-
export function parseOwnAccessEntries(text: string): OwnEntries | null {
|
|
947
|
-
const fm = extractFrontmatter(text);
|
|
948
|
-
if (!fm.ok) return null;
|
|
949
|
-
const parsed = parseYamlSubset(fm.frontmatter);
|
|
950
|
-
if (!parsed.ok) return null;
|
|
951
|
-
const root = parsed.value;
|
|
952
|
-
if (root == null || typeof root !== 'object' || Array.isArray(root)) return null;
|
|
953
|
-
|
|
954
|
-
const entries = emptyEntries();
|
|
955
|
-
let sawVerb = false;
|
|
956
|
-
for (const [key, value] of Object.entries(root as Record<string, YamlValue>)) {
|
|
957
|
-
if (!KNOWN_VERBS_SET.has(key)) continue; // ignore nodeType, etc.
|
|
958
|
-
// Accept both the list form (`owner:\n - A\n - B`) and the convenience
|
|
959
|
-
// single-value scalar form (`owner: Test <test@test.com>`) — the latter
|
|
960
|
-
// is the natural way to name one owner in a node's own frontmatter.
|
|
961
|
-
// Anything else (a mapping, null) is malformed → skip forgivingly.
|
|
962
|
-
let raws: YamlValue[];
|
|
963
|
-
if (Array.isArray(value)) raws = value;
|
|
964
|
-
else if (typeof value === 'string' && value.trim()) raws = [value];
|
|
965
|
-
else continue;
|
|
966
|
-
const verb = key as Verb;
|
|
967
|
-
const list: ParsedEntry[] = [];
|
|
968
|
-
for (const raw of raws) {
|
|
969
|
-
const result = parseAccessEntry(raw);
|
|
970
|
-
if (result.ok) list.push(result.entry);
|
|
971
|
-
}
|
|
972
|
-
entries[verb] = list;
|
|
973
|
-
sawVerb = true;
|
|
974
|
-
}
|
|
975
|
-
return sawVerb ? entries : null;
|
|
976
|
-
}
|
|
977
232
|
|
|
978
233
|
/**
|
|
979
234
|
* Where a single access scope's rules live. `'own'` is the node's own
|