@bevel-software/platform-core-backend 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/dist/assets.d.ts +5 -0
- package/dist/assets.d.ts.map +1 -0
- package/dist/assets.js +23 -0
- package/dist/assets.js.map +1 -0
- package/dist/core/core-ports.d.ts +83 -0
- package/dist/core/core-ports.d.ts.map +1 -0
- package/dist/core/core-ports.js +12 -0
- package/dist/core/core-ports.js.map +1 -0
- package/dist/core/create-core-server.d.ts +62 -0
- package/dist/core/create-core-server.d.ts.map +1 -0
- package/dist/core/create-core-server.js +204 -0
- package/dist/core/create-core-server.js.map +1 -0
- package/dist/core/create-core-services.d.ts +95 -0
- package/dist/core/create-core-services.d.ts.map +1 -0
- package/dist/core/create-core-services.js +352 -0
- package/dist/core/create-core-services.js.map +1 -0
- package/dist/core-config.d.ts +119 -0
- package/dist/core-config.d.ts.map +1 -0
- package/dist/core-config.js +240 -0
- package/dist/core-config.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/modules/access/access-control.interface.d.ts +348 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -0
- package/dist/modules/access/access-control.interface.js +2 -0
- package/dist/modules/access/access-control.interface.js.map +1 -0
- package/dist/modules/access/access-control.service.d.ts +323 -0
- package/dist/modules/access/access-control.service.d.ts.map +1 -0
- package/dist/modules/access/access-control.service.js +1431 -0
- package/dist/modules/access/access-control.service.js.map +1 -0
- package/dist/modules/access/access-errors.d.ts +34 -0
- package/dist/modules/access/access-errors.d.ts.map +1 -0
- package/dist/modules/access/access-errors.js +40 -0
- package/dist/modules/access/access-errors.js.map +1 -0
- package/dist/modules/access/access-mutation.service.d.ts +176 -0
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -0
- package/dist/modules/access/access-mutation.service.js +307 -0
- package/dist/modules/access/access-mutation.service.js.map +1 -0
- package/dist/modules/access/access-splice.d.ts +98 -0
- package/dist/modules/access/access-splice.d.ts.map +1 -0
- package/dist/modules/access/access-splice.js +346 -0
- package/dist/modules/access/access-splice.js.map +1 -0
- package/dist/modules/access/access.routes.d.ts +10 -0
- package/dist/modules/access/access.routes.d.ts.map +1 -0
- package/dist/modules/access/access.routes.js +757 -0
- package/dist/modules/access/access.routes.js.map +1 -0
- package/dist/modules/access/creator-access.d.ts +113 -0
- package/dist/modules/access/creator-access.d.ts.map +1 -0
- package/dist/modules/access/creator-access.js +212 -0
- package/dist/modules/access/creator-access.js.map +1 -0
- package/dist/modules/access/kb-read-filter.d.ts +41 -0
- package/dist/modules/access/kb-read-filter.d.ts.map +1 -0
- package/dist/modules/access/kb-read-filter.js +60 -0
- package/dist/modules/access/kb-read-filter.js.map +1 -0
- package/dist/modules/access/roles-admin.service.d.ts +240 -0
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -0
- package/dist/modules/access/roles-admin.service.js +630 -0
- package/dist/modules/access/roles-admin.service.js.map +1 -0
- package/dist/modules/access/roles-edit.d.ts +91 -0
- package/dist/modules/access/roles-edit.d.ts.map +1 -0
- package/dist/modules/access/roles-edit.js +232 -0
- package/dist/modules/access/roles-edit.js.map +1 -0
- package/dist/modules/access/roles-yaml-guard.d.ts +56 -0
- package/dist/modules/access/roles-yaml-guard.d.ts.map +1 -0
- package/dist/modules/access/roles-yaml-guard.js +79 -0
- package/dist/modules/access/roles-yaml-guard.js.map +1 -0
- package/dist/modules/admin/admin-access.routes.d.ts +18 -0
- package/dist/modules/admin/admin-access.routes.d.ts.map +1 -0
- package/dist/modules/admin/admin-access.routes.js +26 -0
- package/dist/modules/admin/admin-access.routes.js.map +1 -0
- package/dist/modules/admin/admin-access.service.d.ts +24 -0
- package/dist/modules/admin/admin-access.service.d.ts.map +1 -0
- package/dist/modules/admin/admin-access.service.js +41 -0
- package/dist/modules/admin/admin-access.service.js.map +1 -0
- package/dist/modules/admin/admin.interface.d.ts +11 -0
- package/dist/modules/admin/admin.interface.d.ts.map +1 -0
- package/dist/modules/admin/admin.interface.js +2 -0
- package/dist/modules/admin/admin.interface.js.map +1 -0
- package/dist/modules/auth/account-erasure.service.d.ts +80 -0
- package/dist/modules/auth/account-erasure.service.d.ts.map +1 -0
- package/dist/modules/auth/account-erasure.service.js +91 -0
- package/dist/modules/auth/account-erasure.service.js.map +1 -0
- package/dist/modules/auth/auth.middleware.d.ts +23 -0
- package/dist/modules/auth/auth.middleware.d.ts.map +1 -0
- package/dist/modules/auth/auth.middleware.js +68 -0
- package/dist/modules/auth/auth.middleware.js.map +1 -0
- package/dist/modules/auth/auth.routes.d.ts +25 -0
- package/dist/modules/auth/auth.routes.d.ts.map +1 -0
- package/dist/modules/auth/auth.routes.js +72 -0
- package/dist/modules/auth/auth.routes.js.map +1 -0
- package/dist/modules/auth/auth.service.d.ts +76 -0
- package/dist/modules/auth/auth.service.d.ts.map +1 -0
- package/dist/modules/auth/auth.service.js +168 -0
- package/dist/modules/auth/auth.service.js.map +1 -0
- package/dist/modules/code-mode/code-mode-names.d.ts +16 -0
- package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -0
- package/dist/modules/code-mode/code-mode-names.js +30 -0
- package/dist/modules/code-mode/code-mode-names.js.map +1 -0
- package/dist/modules/code-mode/code-mode.tool.d.ts +13 -0
- package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -0
- package/dist/modules/code-mode/code-mode.tool.js +112 -0
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -0
- package/dist/modules/code-mode/index.d.ts +3 -0
- package/dist/modules/code-mode/index.d.ts.map +1 -0
- package/dist/modules/code-mode/index.js +3 -0
- package/dist/modules/code-mode/index.js.map +1 -0
- package/dist/modules/database/connection.d.ts +6 -0
- package/dist/modules/database/connection.d.ts.map +1 -0
- package/dist/modules/database/connection.js +12 -0
- package/dist/modules/database/connection.js.map +1 -0
- package/dist/modules/database/core-schema.d.ts +2273 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -0
- package/dist/modules/database/core-schema.js +413 -0
- package/dist/modules/database/core-schema.js.map +1 -0
- package/dist/modules/database/migrate.d.ts +10 -0
- package/dist/modules/database/migrate.d.ts.map +1 -0
- package/dist/modules/database/migrate.js +40 -0
- package/dist/modules/database/migrate.js.map +1 -0
- package/dist/modules/database/schema.d.ts +12 -0
- package/dist/modules/database/schema.d.ts.map +1 -0
- package/dist/modules/database/schema.js +12 -0
- package/dist/modules/database/schema.js.map +1 -0
- package/dist/modules/diff/diff-paths.d.ts +2 -0
- package/dist/modules/diff/diff-paths.d.ts.map +1 -0
- package/dist/modules/diff/diff-paths.js +9 -0
- package/dist/modules/diff/diff-paths.js.map +1 -0
- package/dist/modules/diff/diff.config.d.ts +8 -0
- package/dist/modules/diff/diff.config.d.ts.map +1 -0
- package/dist/modules/diff/diff.config.js +13 -0
- package/dist/modules/diff/diff.config.js.map +1 -0
- package/dist/modules/diff/diff.interface.d.ts +73 -0
- package/dist/modules/diff/diff.interface.d.ts.map +1 -0
- package/dist/modules/diff/diff.interface.js +2 -0
- package/dist/modules/diff/diff.interface.js.map +1 -0
- package/dist/modules/diff/diff.routes.d.ts +35 -0
- package/dist/modules/diff/diff.routes.d.ts.map +1 -0
- package/dist/modules/diff/diff.routes.js +228 -0
- package/dist/modules/diff/diff.routes.js.map +1 -0
- package/dist/modules/diff/diff.service.d.ts +51 -0
- package/dist/modules/diff/diff.service.d.ts.map +1 -0
- package/dist/modules/diff/diff.service.js +489 -0
- package/dist/modules/diff/diff.service.js.map +1 -0
- package/dist/modules/diff/line-diff.d.ts +16 -0
- package/dist/modules/diff/line-diff.d.ts.map +1 -0
- package/dist/modules/diff/line-diff.js +67 -0
- package/dist/modules/diff/line-diff.js.map +1 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +27 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -0
- package/dist/modules/mcp/mcp-auth.middleware.js +116 -0
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -0
- package/dist/modules/mcp/mcp-session-store.d.ts +74 -0
- package/dist/modules/mcp/mcp-session-store.d.ts.map +1 -0
- package/dist/modules/mcp/mcp-session-store.js +131 -0
- package/dist/modules/mcp/mcp-session-store.js.map +1 -0
- package/dist/modules/mcp/mcp.routes.d.ts +24 -0
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -0
- package/dist/modules/mcp/mcp.routes.js +229 -0
- package/dist/modules/mcp/mcp.routes.js.map +1 -0
- package/dist/modules/mcp/mcp.service.d.ts +198 -0
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -0
- package/dist/modules/mcp/mcp.service.js +840 -0
- package/dist/modules/mcp/mcp.service.js.map +1 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +76 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +291 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -0
- package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts +18 -0
- package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts.map +1 -0
- package/dist/modules/mcp/oauth/oauth-consent.routes.js +52 -0
- package/dist/modules/mcp/oauth/oauth-consent.routes.js.map +1 -0
- package/dist/modules/mcp/oauth/oauth-state.d.ts +32 -0
- package/dist/modules/mcp/oauth/oauth-state.d.ts.map +1 -0
- package/dist/modules/mcp/oauth/oauth-state.js +34 -0
- package/dist/modules/mcp/oauth/oauth-state.js.map +1 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +92 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +728 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -0
- package/dist/modules/secrets-vault/index.d.ts +7 -0
- package/dist/modules/secrets-vault/index.d.ts.map +1 -0
- package/dist/modules/secrets-vault/index.js +6 -0
- package/dist/modules/secrets-vault/index.js.map +1 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +63 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +269 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -0
- package/dist/modules/secrets-vault/secrets-variable-loader.d.ts +46 -0
- package/dist/modules/secrets-vault/secrets-variable-loader.d.ts.map +1 -0
- package/dist/modules/secrets-vault/secrets-variable-loader.js +87 -0
- package/dist/modules/secrets-vault/secrets-variable-loader.js.map +1 -0
- package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +210 -0
- package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -0
- package/dist/modules/secrets-vault/secrets-vault.contract.js +90 -0
- package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +30 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.js +490 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -0
- package/dist/modules/skills/index.d.ts +5 -0
- package/dist/modules/skills/index.d.ts.map +1 -0
- package/dist/modules/skills/index.js +4 -0
- package/dist/modules/skills/index.js.map +1 -0
- package/dist/modules/skills/skills.contract.d.ts +58 -0
- package/dist/modules/skills/skills.contract.d.ts.map +1 -0
- package/dist/modules/skills/skills.contract.js +10 -0
- package/dist/modules/skills/skills.contract.js.map +1 -0
- package/dist/modules/skills/skills.routes.d.ts +15 -0
- package/dist/modules/skills/skills.routes.d.ts.map +1 -0
- package/dist/modules/skills/skills.routes.js +34 -0
- package/dist/modules/skills/skills.routes.js.map +1 -0
- package/dist/modules/skills/skills.service.d.ts +21 -0
- package/dist/modules/skills/skills.service.d.ts.map +1 -0
- package/dist/modules/skills/skills.service.js +202 -0
- package/dist/modules/skills/skills.service.js.map +1 -0
- package/dist/modules/skills/skills.tools.d.ts +15 -0
- package/dist/modules/skills/skills.tools.d.ts.map +1 -0
- package/dist/modules/skills/skills.tools.js +96 -0
- package/dist/modules/skills/skills.tools.js.map +1 -0
- package/dist/modules/tool-auth/external-api-key.errors.d.ts +14 -0
- package/dist/modules/tool-auth/external-api-key.errors.d.ts.map +1 -0
- package/dist/modules/tool-auth/external-api-key.errors.js +23 -0
- package/dist/modules/tool-auth/external-api-key.errors.js.map +1 -0
- package/dist/modules/tool-auth/external-api-key.interface.d.ts +90 -0
- package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -0
- package/dist/modules/tool-auth/external-api-key.interface.js +2 -0
- package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -0
- package/dist/modules/tool-auth/external-api-key.service.d.ts +25 -0
- package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -0
- package/dist/modules/tool-auth/external-api-key.service.js +164 -0
- package/dist/modules/tool-auth/external-api-key.service.js.map +1 -0
- package/dist/modules/tool-auth/internal-token.service.d.ts +62 -0
- package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -0
- package/dist/modules/tool-auth/internal-token.service.js +85 -0
- package/dist/modules/tool-auth/internal-token.service.js.map +1 -0
- package/dist/modules/tool-auth/llm-usage-meter.d.ts +16 -0
- package/dist/modules/tool-auth/llm-usage-meter.d.ts.map +1 -0
- package/dist/modules/tool-auth/llm-usage-meter.js +6 -0
- package/dist/modules/tool-auth/llm-usage-meter.js.map +1 -0
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +101 -0
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -0
- package/dist/modules/tool-auth/tool-auth.middleware.js +166 -0
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -0
- package/dist/modules/tool-helpers/index.d.ts +19 -0
- package/dist/modules/tool-helpers/index.d.ts.map +1 -0
- package/dist/modules/tool-helpers/index.js +18 -0
- package/dist/modules/tool-helpers/index.js.map +1 -0
- package/dist/modules/tool-helpers/tool-context.d.ts +35 -0
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -0
- package/dist/modules/tool-helpers/tool-context.js +66 -0
- package/dist/modules/tool-helpers/tool-context.js.map +1 -0
- package/dist/modules/tool-helpers/tool-def.d.ts +33 -0
- package/dist/modules/tool-helpers/tool-def.d.ts.map +1 -0
- package/dist/modules/tool-helpers/tool-def.js +57 -0
- package/dist/modules/tool-helpers/tool-def.js.map +1 -0
- package/dist/modules/tool-helpers/tool-handler.d.ts +19 -0
- package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -0
- package/dist/modules/tool-helpers/tool-handler.js +84 -0
- package/dist/modules/tool-helpers/tool-handler.js.map +1 -0
- package/dist/modules/tool-helpers/tool.contract.d.ts +64 -0
- package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -0
- package/dist/modules/tool-helpers/tool.contract.js +24 -0
- package/dist/modules/tool-helpers/tool.contract.js.map +1 -0
- package/dist/modules/tool-helpers/validate-token.d.ts +31 -0
- package/dist/modules/tool-helpers/validate-token.d.ts.map +1 -0
- package/dist/modules/tool-helpers/validate-token.js +20 -0
- package/dist/modules/tool-helpers/validate-token.js.map +1 -0
- package/dist/modules/tool-manuals/index.d.ts +5 -0
- package/dist/modules/tool-manuals/index.d.ts.map +1 -0
- package/dist/modules/tool-manuals/index.js +5 -0
- package/dist/modules/tool-manuals/index.js.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +182 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.js +19 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.js.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +24 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.js +102 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +108 -0
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.service.js +710 -0
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +35 -0
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.tools.js +157 -0
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -0
- package/dist/modules/tool-registry/manual.routes.d.ts +6 -0
- package/dist/modules/tool-registry/manual.routes.d.ts.map +1 -0
- package/dist/modules/tool-registry/manual.routes.js +76 -0
- package/dist/modules/tool-registry/manual.routes.js.map +1 -0
- package/dist/modules/tool-registry/tool-registry.d.ts +23 -0
- package/dist/modules/tool-registry/tool-registry.d.ts.map +1 -0
- package/dist/modules/tool-registry/tool-registry.js +46 -0
- package/dist/modules/tool-registry/tool-registry.js.map +1 -0
- package/dist/modules/tool-registry/tool.contract.d.ts +48 -0
- package/dist/modules/tool-registry/tool.contract.d.ts.map +1 -0
- package/dist/modules/tool-registry/tool.contract.js +2 -0
- package/dist/modules/tool-registry/tool.contract.js.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +14 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.js +437 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -0
- package/dist/modules/workflow/event-bus.d.ts +107 -0
- package/dist/modules/workflow/event-bus.d.ts.map +1 -0
- package/dist/modules/workflow/event-bus.js +224 -0
- package/dist/modules/workflow/event-bus.js.map +1 -0
- package/dist/modules/workflow/events.routes.d.ts +80 -0
- package/dist/modules/workflow/events.routes.d.ts.map +1 -0
- package/dist/modules/workflow/events.routes.js +216 -0
- package/dist/modules/workflow/events.routes.js.map +1 -0
- package/dist/modules/workflow/file-change-notifier.d.ts +38 -0
- package/dist/modules/workflow/file-change-notifier.d.ts.map +1 -0
- package/dist/modules/workflow/file-change-notifier.js +22 -0
- package/dist/modules/workflow/file-change-notifier.js.map +1 -0
- package/dist/modules/workflow/file-lock.service.d.ts +57 -0
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -0
- package/dist/modules/workflow/file-lock.service.js +202 -0
- package/dist/modules/workflow/file-lock.service.js.map +1 -0
- package/dist/modules/workflow/git/branch-name.d.ts +10 -0
- package/dist/modules/workflow/git/branch-name.d.ts.map +1 -0
- package/dist/modules/workflow/git/branch-name.js +76 -0
- package/dist/modules/workflow/git/branch-name.js.map +1 -0
- package/dist/modules/workflow/git/clone-config.d.ts +61 -0
- package/dist/modules/workflow/git/clone-config.d.ts.map +1 -0
- package/dist/modules/workflow/git/clone-config.js +69 -0
- package/dist/modules/workflow/git/clone-config.js.map +1 -0
- package/dist/modules/workflow/git/git-version.d.ts +34 -0
- package/dist/modules/workflow/git/git-version.d.ts.map +1 -0
- package/dist/modules/workflow/git/git-version.js +63 -0
- package/dist/modules/workflow/git/git-version.js.map +1 -0
- package/dist/modules/workflow/git/git.service.d.ts +427 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -0
- package/dist/modules/workflow/git/git.service.js +1987 -0
- package/dist/modules/workflow/git/git.service.js.map +1 -0
- package/dist/modules/workflow/git/mutex.d.ts +11 -0
- package/dist/modules/workflow/git/mutex.d.ts.map +1 -0
- package/dist/modules/workflow/git/mutex.js +23 -0
- package/dist/modules/workflow/git/mutex.js.map +1 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts +124 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -0
- package/dist/modules/workflow/git/pull-request.service.js +328 -0
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -0
- package/dist/modules/workflow/locking-filesystem.d.ts +133 -0
- package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -0
- package/dist/modules/workflow/locking-filesystem.js +400 -0
- package/dist/modules/workflow/locking-filesystem.js.map +1 -0
- package/dist/modules/workflow/pending-commits.service.d.ts +221 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -0
- package/dist/modules/workflow/pending-commits.service.js +364 -0
- package/dist/modules/workflow/pending-commits.service.js.map +1 -0
- package/dist/modules/workflow/pending-commits.worker.d.ts +148 -0
- package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -0
- package/dist/modules/workflow/pending-commits.worker.js +245 -0
- package/dist/modules/workflow/pending-commits.worker.js.map +1 -0
- package/dist/modules/workflow/read-only-filesystem.d.ts +24 -0
- package/dist/modules/workflow/read-only-filesystem.d.ts.map +1 -0
- package/dist/modules/workflow/read-only-filesystem.js +39 -0
- package/dist/modules/workflow/read-only-filesystem.js.map +1 -0
- package/dist/modules/workflow/recovery-bot.d.ts +31 -0
- package/dist/modules/workflow/recovery-bot.d.ts.map +1 -0
- package/dist/modules/workflow/recovery-bot.js +64 -0
- package/dist/modules/workflow/recovery-bot.js.map +1 -0
- package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts +137 -0
- package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts.map +1 -0
- package/dist/modules/workflow/review-workflow/review-workflow.interface.js +2 -0
- package/dist/modules/workflow/review-workflow/review-workflow.interface.js.map +1 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts +26 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +636 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -0
- package/dist/modules/workflow/sanitize-error.d.ts +29 -0
- package/dist/modules/workflow/sanitize-error.d.ts.map +1 -0
- package/dist/modules/workflow/sanitize-error.js +60 -0
- package/dist/modules/workflow/sanitize-error.js.map +1 -0
- package/dist/modules/workflow/session-ontology.policy.d.ts +52 -0
- package/dist/modules/workflow/session-ontology.policy.d.ts.map +1 -0
- package/dist/modules/workflow/session-ontology.policy.js +62 -0
- package/dist/modules/workflow/session-ontology.policy.js.map +1 -0
- package/dist/modules/workflow/session-ontology.service.d.ts +100 -0
- package/dist/modules/workflow/session-ontology.service.d.ts.map +1 -0
- package/dist/modules/workflow/session-ontology.service.js +143 -0
- package/dist/modules/workflow/session-ontology.service.js.map +1 -0
- package/dist/modules/workflow/workflow-hooks.d.ts +87 -0
- package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -0
- package/dist/modules/workflow/workflow-hooks.js +35 -0
- package/dist/modules/workflow/workflow-hooks.js.map +1 -0
- package/dist/modules/workflow/workflow.errors.d.ts +171 -0
- package/dist/modules/workflow/workflow.errors.d.ts.map +1 -0
- package/dist/modules/workflow/workflow.errors.js +260 -0
- package/dist/modules/workflow/workflow.errors.js.map +1 -0
- package/dist/modules/workflow/workflow.routes.d.ts +26 -0
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -0
- package/dist/modules/workflow/workflow.routes.js +804 -0
- package/dist/modules/workflow/workflow.routes.js.map +1 -0
- package/dist/modules/workflow/workflow.service.d.ts +385 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -0
- package/dist/modules/workflow/workflow.service.js +1155 -0
- package/dist/modules/workflow/workflow.service.js.map +1 -0
- package/dist/modules/workspace/bevel-ignore.d.ts +25 -0
- package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -0
- package/dist/modules/workspace/bevel-ignore.js +57 -0
- package/dist/modules/workspace/bevel-ignore.js.map +1 -0
- package/dist/modules/workspace/kb-seed.interface.d.ts +36 -0
- package/dist/modules/workspace/kb-seed.interface.d.ts.map +1 -0
- package/dist/modules/workspace/kb-seed.interface.js +2 -0
- package/dist/modules/workspace/kb-seed.interface.js.map +1 -0
- package/dist/modules/workspace/kb-seed.service.d.ts +70 -0
- package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -0
- package/dist/modules/workspace/kb-seed.service.js +325 -0
- package/dist/modules/workspace/kb-seed.service.js.map +1 -0
- package/dist/modules/workspace/routine-write-policy.d.ts +49 -0
- package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -0
- package/dist/modules/workspace/routine-write-policy.js +46 -0
- package/dist/modules/workspace/routine-write-policy.js.map +1 -0
- package/dist/modules/workspace/session-ontology.gate.d.ts +112 -0
- package/dist/modules/workspace/session-ontology.gate.d.ts.map +1 -0
- package/dist/modules/workspace/session-ontology.gate.js +161 -0
- package/dist/modules/workspace/session-ontology.gate.js.map +1 -0
- package/dist/modules/workspace/session-sink.d.ts +25 -0
- package/dist/modules/workspace/session-sink.d.ts.map +1 -0
- package/dist/modules/workspace/session-sink.js +8 -0
- package/dist/modules/workspace/session-sink.js.map +1 -0
- package/dist/modules/workspace/spill-store.d.ts +24 -0
- package/dist/modules/workspace/spill-store.d.ts.map +1 -0
- package/dist/modules/workspace/spill-store.js +87 -0
- package/dist/modules/workspace/spill-store.js.map +1 -0
- package/dist/modules/workspace/workspace.routes.d.ts +25 -0
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -0
- package/dist/modules/workspace/workspace.routes.js +1046 -0
- package/dist/modules/workspace/workspace.routes.js.map +1 -0
- package/dist/modules/workspace/workspace.service.d.ts +339 -0
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -0
- package/dist/modules/workspace/workspace.service.js +1221 -0
- package/dist/modules/workspace/workspace.service.js.map +1 -0
- package/dist/modules/workspace/workspace.tools.d.ts +19 -0
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -0
- package/dist/modules/workspace/workspace.tools.js +767 -0
- package/dist/modules/workspace/workspace.tools.js.map +1 -0
- package/dist/shared/frontmatter-id.d.ts +35 -0
- package/dist/shared/frontmatter-id.d.ts.map +1 -0
- package/dist/shared/frontmatter-id.js +68 -0
- package/dist/shared/frontmatter-id.js.map +1 -0
- package/dist/shared/fs-walk.d.ts +9 -0
- package/dist/shared/fs-walk.d.ts.map +1 -0
- package/dist/shared/fs-walk.js +34 -0
- package/dist/shared/fs-walk.js.map +1 -0
- package/dist/shared/hash-email.d.ts +11 -0
- package/dist/shared/hash-email.d.ts.map +1 -0
- package/dist/shared/hash-email.js +14 -0
- package/dist/shared/hash-email.js.map +1 -0
- package/dist/shared/kb-layout.d.ts +38 -0
- package/dist/shared/kb-layout.d.ts.map +1 -0
- package/dist/shared/kb-layout.js +102 -0
- package/dist/shared/kb-layout.js.map +1 -0
- package/dist/shared/kb-layout.test.d.ts +2 -0
- package/dist/shared/kb-layout.test.d.ts.map +1 -0
- package/dist/shared/kb-layout.test.js +67 -0
- package/dist/shared/kb-layout.test.js.map +1 -0
- package/dist/shared/ssrf.d.ts +20 -0
- package/dist/shared/ssrf.d.ts.map +1 -0
- package/dist/shared/ssrf.js +89 -0
- package/dist/shared/ssrf.js.map +1 -0
- package/dist/shared/token-crypto.d.ts +16 -0
- package/dist/shared/token-crypto.d.ts.map +1 -0
- package/dist/shared/token-crypto.js +49 -0
- package/dist/shared/token-crypto.js.map +1 -0
- package/dist/shared/ttl-cache.d.ts +16 -0
- package/dist/shared/ttl-cache.d.ts.map +1 -0
- package/dist/shared/ttl-cache.js +27 -0
- package/dist/shared/ttl-cache.js.map +1 -0
- package/dist/shared/utcp-namespace.d.ts +46 -0
- package/dist/shared/utcp-namespace.d.ts.map +1 -0
- package/dist/shared/utcp-namespace.js +82 -0
- package/dist/shared/utcp-namespace.js.map +1 -0
- package/dist/version.d.ts +17 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +19 -0
- package/dist/version.js.map +1 -0
- package/kb-template/.bevelignore +21 -0
- package/kb-template/Agents/.gitkeep +0 -0
- package/kb-template/Agents/README.md +38 -0
- package/kb-template/CLAUDE.md +419 -0
- package/kb-template/Data/.gitkeep +0 -0
- package/kb-template/Data/README.md +39 -0
- package/kb-template/KnowledgeBase/.gitkeep +0 -0
- package/kb-template/KnowledgeBase/README.md +37 -0
- package/kb-template/Pipelines/.gitkeep +0 -0
- package/kb-template/Pipelines/README.md +38 -0
- package/kb-template/Skills/.gitkeep +0 -0
- package/kb-template/Skills/README.md +32 -0
- package/kb-template/Tools/.gitkeep +0 -0
- package/kb-template/Tools/README.md +42 -0
- package/kb-template/access.md +36 -0
- package/migrations/0000_core_init.sql +243 -0
- package/migrations/meta/0000_snapshot.json +1480 -0
- package/migrations/meta/_journal.json +13 -0
- package/package.json +80 -0
- package/src/assets.ts +25 -0
- package/src/core/core-ports.ts +91 -0
- package/src/core/create-core-server.ts +338 -0
- package/src/core/create-core-services.ts +501 -0
- package/src/core-config.ts +257 -0
- package/src/index.ts +69 -0
- package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -0
- package/src/modules/access/__tests__/access-control.read-batch.test.ts +132 -0
- package/src/modules/access/__tests__/access-control.service.test.ts +1011 -0
- package/src/modules/access/__tests__/access-mutation.service.test.ts +439 -0
- package/src/modules/access/__tests__/access-splice.test.ts +247 -0
- package/src/modules/access/__tests__/access.routes.revoke.test.ts +407 -0
- package/src/modules/access/__tests__/creator-access.test.ts +217 -0
- package/src/modules/access/__tests__/grant-sources.test.ts +204 -0
- package/src/modules/access/__tests__/kb-read-filter.test.ts +70 -0
- package/src/modules/access/__tests__/roles-admin.service.test.ts +469 -0
- package/src/modules/access/__tests__/roles-edit.test.ts +138 -0
- package/src/modules/access/__tests__/roles-yaml-guard.test.ts +70 -0
- package/src/modules/access/__tests__/roles.routes.test.ts +234 -0
- package/src/modules/access/access-control.interface.ts +383 -0
- package/src/modules/access/access-control.service.ts +1794 -0
- package/src/modules/access/access-errors.ts +60 -0
- package/src/modules/access/access-mutation.service.ts +369 -0
- package/src/modules/access/access-splice.ts +413 -0
- package/src/modules/access/access.routes.ts +894 -0
- package/src/modules/access/creator-access.ts +270 -0
- package/src/modules/access/kb-read-filter.ts +76 -0
- package/src/modules/access/roles-admin.service.ts +741 -0
- package/src/modules/access/roles-edit.ts +248 -0
- package/src/modules/access/roles-yaml-guard.ts +89 -0
- package/src/modules/admin/admin-access.routes.ts +29 -0
- package/src/modules/admin/admin-access.service.ts +38 -0
- package/src/modules/admin/admin.interface.ts +10 -0
- package/src/modules/auth/account-erasure.service.ts +183 -0
- package/src/modules/auth/auth.middleware.ts +78 -0
- package/src/modules/auth/auth.routes.ts +97 -0
- package/src/modules/auth/auth.service.ts +199 -0
- package/src/modules/code-mode/code-mode-names.ts +38 -0
- package/src/modules/code-mode/code-mode.tool.ts +117 -0
- package/src/modules/code-mode/index.ts +6 -0
- package/src/modules/database/connection.ts +15 -0
- package/src/modules/database/core-schema.ts +431 -0
- package/src/modules/database/migrate.ts +43 -0
- package/src/modules/database/schema.ts +11 -0
- package/src/modules/diff/__tests__/diff.routes.read-gate.test.ts +87 -0
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -0
- package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +184 -0
- package/src/modules/diff/__tests__/diff.service.test.ts +202 -0
- package/src/modules/diff/diff-paths.ts +9 -0
- package/src/modules/diff/diff.config.ts +12 -0
- package/src/modules/diff/diff.interface.ts +78 -0
- package/src/modules/diff/diff.routes.ts +277 -0
- package/src/modules/diff/diff.service.ts +508 -0
- package/src/modules/diff/line-diff.ts +79 -0
- package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +287 -0
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +249 -0
- package/src/modules/mcp/__tests__/mcp-proxy-helpers.test.ts +253 -0
- package/src/modules/mcp/__tests__/mcp-session-store.test.ts +151 -0
- package/src/modules/mcp/__tests__/mcp.e2e.test.ts +150 -0
- package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +92 -0
- package/src/modules/mcp/__tests__/mcp.service.test.ts +414 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +126 -0
- package/src/modules/mcp/mcp-session-store.ts +162 -0
- package/src/modules/mcp/mcp.routes.ts +263 -0
- package/src/modules/mcp/mcp.service.ts +973 -0
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +401 -0
- package/src/modules/mcp/oauth/oauth-consent.routes.ts +60 -0
- package/src/modules/mcp/oauth/oauth-state.ts +61 -0
- package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +277 -0
- package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +206 -0
- package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +178 -0
- package/src/modules/secrets-vault/__tests__/scopes-covered.test.ts +101 -0
- package/src/modules/secrets-vault/__tests__/secrets-variable-loader.test.ts +54 -0
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -0
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +792 -0
- package/src/modules/secrets-vault/index.ts +25 -0
- package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +289 -0
- package/src/modules/secrets-vault/secrets-variable-loader.ts +104 -0
- package/src/modules/secrets-vault/secrets-vault.contract.ts +270 -0
- package/src/modules/secrets-vault/secrets-vault.routes.ts +526 -0
- package/src/modules/skills/__tests__/skills.service.test.ts +176 -0
- package/src/modules/skills/index.ts +10 -0
- package/src/modules/skills/skills.contract.ts +54 -0
- package/src/modules/skills/skills.routes.ts +38 -0
- package/src/modules/skills/skills.service.ts +245 -0
- package/src/modules/skills/skills.tools.ts +121 -0
- package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +358 -0
- package/src/modules/tool-auth/__tests__/internal-token.service.test.ts +79 -0
- package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +88 -0
- package/src/modules/tool-auth/external-api-key.errors.ts +25 -0
- package/src/modules/tool-auth/external-api-key.interface.ts +99 -0
- package/src/modules/tool-auth/external-api-key.service.ts +209 -0
- package/src/modules/tool-auth/internal-token.service.ts +124 -0
- package/src/modules/tool-auth/llm-usage-meter.ts +19 -0
- package/src/modules/tool-auth/tool-auth.middleware.ts +220 -0
- package/src/modules/tool-helpers/__tests__/phase0-loopback-spike.test.ts +129 -0
- package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +95 -0
- package/src/modules/tool-helpers/__tests__/tools-surface.test.ts +146 -0
- package/src/modules/tool-helpers/__tests__/validate-token.test.ts +75 -0
- package/src/modules/tool-helpers/index.ts +18 -0
- package/src/modules/tool-helpers/tool-context.ts +98 -0
- package/src/modules/tool-helpers/tool-def.ts +73 -0
- package/src/modules/tool-helpers/tool-handler.ts +91 -0
- package/src/modules/tool-helpers/tool.contract.ts +85 -0
- package/src/modules/tool-helpers/validate-token.ts +50 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +200 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +499 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -0
- package/src/modules/tool-manuals/index.ts +12 -0
- package/src/modules/tool-manuals/tool-manuals.contract.ts +183 -0
- package/src/modules/tool-manuals/tool-manuals.routes.ts +110 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +781 -0
- package/src/modules/tool-manuals/tool-manuals.tools.ts +202 -0
- package/src/modules/tool-registry/__tests__/tool-registry.test.ts +64 -0
- package/src/modules/tool-registry/manual.routes.ts +87 -0
- package/src/modules/tool-registry/tool-registry.ts +55 -0
- package/src/modules/tool-registry/tool.contract.ts +52 -0
- package/src/modules/workflow/__tests__/event-bus.test.ts +322 -0
- package/src/modules/workflow/__tests__/file-change-notifier.test.ts +51 -0
- package/src/modules/workflow/__tests__/format-affected-owners.test.ts +67 -0
- package/src/modules/workflow/__tests__/locking-filesystem.test.ts +445 -0
- package/src/modules/workflow/__tests__/pending-commits.canonical.test.ts +36 -0
- package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +243 -0
- package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +308 -0
- package/src/modules/workflow/__tests__/sanitize-error.test.ts +67 -0
- package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +62 -0
- package/src/modules/workflow/__tests__/session-ontology.service.test.ts +200 -0
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +232 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +380 -0
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +340 -0
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +192 -0
- package/src/modules/workflow/agent-tools/workflow.tools.ts +502 -0
- package/src/modules/workflow/event-bus.ts +262 -0
- package/src/modules/workflow/events.routes.ts +266 -0
- package/src/modules/workflow/file-change-notifier.ts +54 -0
- package/src/modules/workflow/file-lock.service.ts +269 -0
- package/src/modules/workflow/git/__tests__/branch-name.test.ts +86 -0
- package/src/modules/workflow/git/__tests__/branchAuthor.test.ts +80 -0
- package/src/modules/workflow/git/__tests__/clone-config.test.ts +15 -0
- package/src/modules/workflow/git/__tests__/git-test-helpers.ts +47 -0
- package/src/modules/workflow/git/__tests__/git-version.failure.test.ts +24 -0
- package/src/modules/workflow/git/__tests__/git-version.test.ts +49 -0
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +389 -0
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +124 -0
- package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +105 -0
- package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +211 -0
- package/src/modules/workflow/git/__tests__/git.service.createBranch.test.ts +154 -0
- package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +320 -0
- package/src/modules/workflow/git/__tests__/git.service.diffFileAtCommit.test.ts +202 -0
- package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +453 -0
- package/src/modules/workflow/git/__tests__/git.service.list-branches.test.ts +198 -0
- package/src/modules/workflow/git/__tests__/git.service.listTrackedFiles.test.ts +125 -0
- package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +219 -0
- package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +145 -0
- package/src/modules/workflow/git/__tests__/git.service.pendingChanges.test.ts +240 -0
- package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +272 -0
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +232 -0
- package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +101 -0
- package/src/modules/workflow/git/__tests__/git.service.status.test.ts +127 -0
- package/src/modules/workflow/git/__tests__/mutex.test.ts +78 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +225 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +101 -0
- package/src/modules/workflow/git/branch-name.ts +70 -0
- package/src/modules/workflow/git/clone-config.ts +71 -0
- package/src/modules/workflow/git/git-version.ts +70 -0
- package/src/modules/workflow/git/git.service.ts +2248 -0
- package/src/modules/workflow/git/mutex.ts +22 -0
- package/src/modules/workflow/git/pull-request.service.ts +447 -0
- package/src/modules/workflow/locking-filesystem.ts +489 -0
- package/src/modules/workflow/pending-commits.service.ts +444 -0
- package/src/modules/workflow/pending-commits.worker.ts +361 -0
- package/src/modules/workflow/read-only-filesystem.ts +54 -0
- package/src/modules/workflow/recovery-bot.ts +73 -0
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +560 -0
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +176 -0
- package/src/modules/workflow/review-workflow/__tests__/merge-gate.test.ts +258 -0
- package/src/modules/workflow/review-workflow/review-workflow.interface.ts +199 -0
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +806 -0
- package/src/modules/workflow/sanitize-error.ts +62 -0
- package/src/modules/workflow/session-ontology.policy.ts +70 -0
- package/src/modules/workflow/session-ontology.service.ts +178 -0
- package/src/modules/workflow/workflow-hooks.ts +101 -0
- package/src/modules/workflow/workflow.errors.ts +285 -0
- package/src/modules/workflow/workflow.routes.ts +858 -0
- package/src/modules/workflow/workflow.service.ts +1555 -0
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +213 -0
- package/src/modules/workspace/__tests__/routine-write-policy.test.ts +68 -0
- package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -0
- package/src/modules/workspace/__tests__/spill-store.test.ts +43 -0
- package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +233 -0
- package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +176 -0
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +303 -0
- package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +276 -0
- package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +120 -0
- package/src/modules/workspace/__tests__/workspace.service.test.ts +565 -0
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +504 -0
- package/src/modules/workspace/bevel-ignore.ts +62 -0
- package/src/modules/workspace/kb-seed.interface.ts +36 -0
- package/src/modules/workspace/kb-seed.service.ts +361 -0
- package/src/modules/workspace/routine-write-policy.ts +79 -0
- package/src/modules/workspace/session-ontology.gate.ts +189 -0
- package/src/modules/workspace/session-sink.ts +25 -0
- package/src/modules/workspace/spill-store.ts +87 -0
- package/src/modules/workspace/workspace.routes.ts +1114 -0
- package/src/modules/workspace/workspace.service.ts +1343 -0
- package/src/modules/workspace/workspace.tools.ts +885 -0
- package/src/shared/__tests__/frontmatter-id.test.ts +88 -0
- package/src/shared/__tests__/fs-walk.test.ts +42 -0
- package/src/shared/__tests__/ssrf.test.ts +53 -0
- package/src/shared/__tests__/utcp-namespace.test.ts +19 -0
- package/src/shared/frontmatter-id.ts +72 -0
- package/src/shared/fs-walk.ts +30 -0
- package/src/shared/hash-email.ts +14 -0
- package/src/shared/kb-layout.test.ts +88 -0
- package/src/shared/kb-layout.ts +101 -0
- package/src/shared/ssrf.ts +78 -0
- package/src/shared/token-crypto.ts +54 -0
- package/src/shared/ttl-cache.ts +27 -0
- package/src/shared/utcp-namespace.ts +88 -0
- package/src/version.ts +19 -0
|
@@ -0,0 +1,1431 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFile, spawn } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { AccessConfigError } from './access-errors.js';
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
/**
|
|
9
|
+
* Read many objects from a git repo in ONE `git cat-file --batch` process.
|
|
10
|
+
* `specs` are `<ref>:<path>` lines; the result array is index-aligned with
|
|
11
|
+
* them — the blob's text, or null when the spec is missing/ambiguous at the
|
|
12
|
+
* ref or resolves to a non-blob (e.g. a directory). Output protocol per
|
|
13
|
+
* request: `<oid> <type> <size>\n<size bytes>\n`, or `<spec> missing\n`
|
|
14
|
+
* (the spec may itself contain spaces, hence the endsWith checks).
|
|
15
|
+
*/
|
|
16
|
+
function catFileBatch(repoDir, specs) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const child = spawn('git', ['-C', repoDir, 'cat-file', '--batch'], {
|
|
19
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
20
|
+
});
|
|
21
|
+
const chunks = [];
|
|
22
|
+
const errChunks = [];
|
|
23
|
+
child.stdout.on('data', (c) => chunks.push(c));
|
|
24
|
+
child.stderr.on('data', (c) => errChunks.push(c));
|
|
25
|
+
// A dying git can close stdin mid-write; the 'close' handler below still
|
|
26
|
+
// fires with the exit code, which is the error we want to surface.
|
|
27
|
+
child.stdin.on('error', () => undefined);
|
|
28
|
+
child.on('error', reject);
|
|
29
|
+
child.on('close', (code) => {
|
|
30
|
+
if (code !== 0) {
|
|
31
|
+
reject(new Error(`git cat-file --batch exited ${code}: ${Buffer.concat(errChunks).toString('utf-8').trim()}`));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const out = Buffer.concat(chunks);
|
|
36
|
+
const results = [];
|
|
37
|
+
let off = 0;
|
|
38
|
+
for (let i = 0; i < specs.length; i++) {
|
|
39
|
+
const nl = out.indexOf(0x0a, off);
|
|
40
|
+
if (nl < 0)
|
|
41
|
+
throw new Error('unexpected end of git cat-file output');
|
|
42
|
+
const header = out.subarray(off, nl).toString('utf-8');
|
|
43
|
+
off = nl + 1;
|
|
44
|
+
if (header.endsWith(' missing') || header.endsWith(' ambiguous')) {
|
|
45
|
+
results.push(null);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const parts = header.split(' ');
|
|
49
|
+
const size = Number(parts[2]);
|
|
50
|
+
if (parts.length !== 3 || !Number.isInteger(size) || size < 0) {
|
|
51
|
+
throw new Error(`unexpected git cat-file header: ${header}`);
|
|
52
|
+
}
|
|
53
|
+
// Non-blob (a directory path resolves to a tree) → null, but the
|
|
54
|
+
// payload still has to be skipped to stay aligned.
|
|
55
|
+
results.push(parts[1] === 'blob' ? out.subarray(off, off + size).toString('utf-8') : null);
|
|
56
|
+
off += size + 1; // payload + trailing LF
|
|
57
|
+
}
|
|
58
|
+
resolve(results);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
reject(err);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
child.stdin.write(`${specs.join('\n')}\n`);
|
|
65
|
+
child.stdin.end();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/** Mirrors `shared/hash-email.ts`. Duplicated to avoid a cross-cutting import. */
|
|
69
|
+
function sha256Email(email) {
|
|
70
|
+
return createHash('sha256').update(email.trim().toLowerCase()).digest('hex');
|
|
71
|
+
}
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Constants — kept in lockstep with knowledge-base/lib/access-control.js
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
export const ADMIN_CANONICAL = 'admin';
|
|
76
|
+
/**
|
|
77
|
+
* Verbs the resolver understands in an `access.md` frontmatter. Each verb
|
|
78
|
+
* is a list of grants (role or `Name <email>` references, optionally
|
|
79
|
+
* prefixed with `deny `). Keep `Verb` and `KNOWN_VERBS` in lockstep —
|
|
80
|
+
* `AccessFile.entries` is statically keyed on this union.
|
|
81
|
+
*
|
|
82
|
+
* `read` controls who may VIEW a path (the file viewer, embed surface, and
|
|
83
|
+
* the agent's read tools). It is **default-deny**: a path with no effective
|
|
84
|
+
* `read:` or `owner:` grant is not readable. To make content public, list the
|
|
85
|
+
* built-in role `everyone` under `read:`.
|
|
86
|
+
*
|
|
87
|
+
* The verbs nest: `owner` is a superset of `read` + `write` + `download`, and
|
|
88
|
+
* `write` is itself a superset of `read` (anyone who can edit can view). An
|
|
89
|
+
* `owner` grant therefore confers all three lower verbs, a `write` grant
|
|
90
|
+
* additionally confers `read`, and `owner` also marks the principal as a
|
|
91
|
+
* contact point for the node (surfaced in the UI so users know who to ask).
|
|
92
|
+
* See `sourceVerbsFor` for how these implications fold into resolution.
|
|
93
|
+
*/
|
|
94
|
+
export const KNOWN_VERBS = ['read', 'write', 'download', 'owner'];
|
|
95
|
+
const KNOWN_VERBS_SET = new Set(KNOWN_VERBS);
|
|
96
|
+
export const EVERYONE_CANONICAL = 'everyone';
|
|
97
|
+
/** Display name for the built-in `everyone` role in the share UI. */
|
|
98
|
+
export const EVERYONE_DISPLAY = 'Everyone';
|
|
99
|
+
/**
|
|
100
|
+
* Verbs whose entries contribute to resolving `verb`, target verb first.
|
|
101
|
+
* `owner` implies `read`, `write`, and `download`; `write` additionally
|
|
102
|
+
* implies `read`. So resolving `read` folds in `write` and `owner`, resolving
|
|
103
|
+
* `write`/`download` folds in `owner`, and resolving `owner` uses only `owner`.
|
|
104
|
+
*
|
|
105
|
+
* The implication is **grant-only** (see `resolveAtPath`): a superset grant
|
|
106
|
+
* confers the lower verb, but a superset *denial* does not — `deny write` says
|
|
107
|
+
* nothing about `read`, so it never strips a separate read grant. The target
|
|
108
|
+
* verb itself contributes both its grants and its denials.
|
|
109
|
+
*/
|
|
110
|
+
export function sourceVerbsFor(verb) {
|
|
111
|
+
switch (verb) {
|
|
112
|
+
case 'owner':
|
|
113
|
+
return ['owner'];
|
|
114
|
+
case 'read':
|
|
115
|
+
return ['read', 'write', 'owner'];
|
|
116
|
+
default:
|
|
117
|
+
return [verb, 'owner'];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export const RESERVED_ROLE_NAMES = new Set(['deny', EVERYONE_CANONICAL]);
|
|
121
|
+
export const DENY_PREFIX = 'deny ';
|
|
122
|
+
export const USER_REF_REGEX = /^(.+?)\s+<\s*([^<>\s]+@[^<>\s]+)\s*>\s*$/;
|
|
123
|
+
export const EMAIL_REGEX = /^[^<>\s@]+@[^<>\s@]+\.[^<>\s@]+$/;
|
|
124
|
+
function emptyEntries() {
|
|
125
|
+
const out = {};
|
|
126
|
+
for (const v of KNOWN_VERBS)
|
|
127
|
+
out[v] = [];
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
export function isAccessMdPath(p) {
|
|
131
|
+
return p === 'access.md' || p.endsWith('/access.md');
|
|
132
|
+
}
|
|
133
|
+
function isBuiltInRole(canonicalRole) {
|
|
134
|
+
return canonicalRole === EVERYONE_CANONICAL;
|
|
135
|
+
}
|
|
136
|
+
function roleKnown(roles, canonicalRole) {
|
|
137
|
+
return roles.byCanonical.has(canonicalRole) || isBuiltInRole(canonicalRole);
|
|
138
|
+
}
|
|
139
|
+
function stripComment(line) {
|
|
140
|
+
let inWs = true;
|
|
141
|
+
for (let i = 0; i < line.length; i++) {
|
|
142
|
+
const ch = line[i];
|
|
143
|
+
if (ch === '#' && (inWs || (i > 0 && /\s/.test(line[i - 1])))) {
|
|
144
|
+
return line.slice(0, i);
|
|
145
|
+
}
|
|
146
|
+
if (!/\s/.test(ch))
|
|
147
|
+
inWs = false;
|
|
148
|
+
}
|
|
149
|
+
return line;
|
|
150
|
+
}
|
|
151
|
+
function tokenise(text) {
|
|
152
|
+
const lines = text.split(/\r?\n/);
|
|
153
|
+
const tokens = [];
|
|
154
|
+
for (let i = 0; i < lines.length; i++) {
|
|
155
|
+
const stripped = stripComment(lines[i]).replace(/\s+$/, '');
|
|
156
|
+
if (!stripped.trim())
|
|
157
|
+
continue;
|
|
158
|
+
const indentMatch = stripped.match(/^( *)/);
|
|
159
|
+
const indent = indentMatch ? indentMatch[1].length : 0;
|
|
160
|
+
const content = stripped.slice(indent);
|
|
161
|
+
const lineNum = i + 1;
|
|
162
|
+
if (content.startsWith('- ') || content === '-') {
|
|
163
|
+
const value = content === '-' ? '' : content.slice(2).trim();
|
|
164
|
+
tokens.push({ lineNum, indent, kind: 'item', value });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const colonIdx = content.indexOf(':');
|
|
168
|
+
if (colonIdx < 0) {
|
|
169
|
+
return { ok: false, error: `line ${lineNum}: expected 'key:' or '- value' but got '${content}'` };
|
|
170
|
+
}
|
|
171
|
+
const key = content.slice(0, colonIdx).trim();
|
|
172
|
+
const valuePart = content.slice(colonIdx + 1).trim();
|
|
173
|
+
if (!key)
|
|
174
|
+
return { ok: false, error: `line ${lineNum}: empty mapping key` };
|
|
175
|
+
tokens.push({ lineNum, indent, kind: 'kv', key, value: valuePart });
|
|
176
|
+
}
|
|
177
|
+
return { ok: true, tokens };
|
|
178
|
+
}
|
|
179
|
+
export function parseYamlSubset(text) {
|
|
180
|
+
const tok = tokenise(text);
|
|
181
|
+
if (!tok.ok)
|
|
182
|
+
return tok;
|
|
183
|
+
if (tok.tokens.length === 0)
|
|
184
|
+
return { ok: true, value: {} };
|
|
185
|
+
const root = {};
|
|
186
|
+
const stack = [{ indent: -1, container: root, kind: 'map' }];
|
|
187
|
+
for (let t = 0; t < tok.tokens.length; t++) {
|
|
188
|
+
const cur = tok.tokens[t];
|
|
189
|
+
while (stack.length > 1 && stack[stack.length - 1].indent >= cur.indent) {
|
|
190
|
+
stack.pop();
|
|
191
|
+
}
|
|
192
|
+
const top = stack[stack.length - 1];
|
|
193
|
+
if (cur.kind === 'item') {
|
|
194
|
+
if (top.kind !== 'list') {
|
|
195
|
+
return {
|
|
196
|
+
ok: false,
|
|
197
|
+
error: `line ${cur.lineNum}: list item with no enclosing list (indent ${cur.indent})`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
top.container.push(cur.value);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (top.kind !== 'map') {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
error: `line ${cur.lineNum}: mapping key '${cur.key}' inside a list — not supported`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const map = top.container;
|
|
210
|
+
const key = cur.key;
|
|
211
|
+
if (Object.prototype.hasOwnProperty.call(map, key)) {
|
|
212
|
+
return { ok: false, error: `line ${cur.lineNum}: duplicate key '${key}'` };
|
|
213
|
+
}
|
|
214
|
+
if (cur.value !== '') {
|
|
215
|
+
// Inline empty collections are the only flow-style YAML we accept, so
|
|
216
|
+
// `owner: []` / `download: []` read as an empty list rather than the
|
|
217
|
+
// scalar string "[]".
|
|
218
|
+
if (cur.value === '[]') {
|
|
219
|
+
map[key] = [];
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
map[key] = cur.value;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const next = tok.tokens[t + 1];
|
|
226
|
+
if (!next || next.indent <= cur.indent) {
|
|
227
|
+
map[key] = null;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (next.kind === 'item') {
|
|
231
|
+
const list = [];
|
|
232
|
+
map[key] = list;
|
|
233
|
+
stack.push({ indent: cur.indent, container: list, kind: 'list' });
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
const sub = {};
|
|
237
|
+
map[key] = sub;
|
|
238
|
+
stack.push({ indent: cur.indent, container: sub, kind: 'map' });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return { ok: true, value: root };
|
|
242
|
+
}
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Frontmatter extraction
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
export function extractFrontmatter(text) {
|
|
247
|
+
const lines = text.split(/\r?\n/);
|
|
248
|
+
if (lines.length === 0 || lines[0].trim() !== '---') {
|
|
249
|
+
return { ok: false, error: 'expected `---` on the first line' };
|
|
250
|
+
}
|
|
251
|
+
for (let i = 1; i < lines.length; i++) {
|
|
252
|
+
if (lines[i].trim() === '---') {
|
|
253
|
+
return { ok: true, frontmatter: lines.slice(1, i).join('\n') };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return { ok: false, error: 'unterminated frontmatter — no closing `---` found' };
|
|
257
|
+
}
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Canonicalisation
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
export function canonicalRoleName(name) {
|
|
262
|
+
return name.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
263
|
+
}
|
|
264
|
+
export function canonicalEmail(email) {
|
|
265
|
+
return email.trim().toLowerCase();
|
|
266
|
+
}
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// Entry parser
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
export function parseAccessEntry(raw) {
|
|
271
|
+
if (typeof raw !== 'string')
|
|
272
|
+
return { ok: false, error: 'entry must be a string' };
|
|
273
|
+
const trimmed = raw.trim();
|
|
274
|
+
if (!trimmed)
|
|
275
|
+
return { ok: false, error: 'empty entry' };
|
|
276
|
+
let deny = false;
|
|
277
|
+
let body = trimmed;
|
|
278
|
+
if (trimmed.startsWith(DENY_PREFIX)) {
|
|
279
|
+
deny = true;
|
|
280
|
+
body = trimmed.slice(DENY_PREFIX.length).trim();
|
|
281
|
+
if (!body)
|
|
282
|
+
return { ok: false, error: `'deny' with no principal` };
|
|
283
|
+
}
|
|
284
|
+
const userMatch = body.match(USER_REF_REGEX);
|
|
285
|
+
if (userMatch) {
|
|
286
|
+
const displayName = userMatch[1].trim();
|
|
287
|
+
const email = canonicalEmail(userMatch[2]);
|
|
288
|
+
if (!displayName)
|
|
289
|
+
return { ok: false, error: `user reference '${body}' has no name` };
|
|
290
|
+
if (!EMAIL_REGEX.test(email)) {
|
|
291
|
+
return { ok: false, error: `user reference '${body}' has malformed email '${email}'` };
|
|
292
|
+
}
|
|
293
|
+
return { ok: true, entry: { kind: 'user', email, displayName, deny } };
|
|
294
|
+
}
|
|
295
|
+
if (body.includes('<') || body.includes('>')) {
|
|
296
|
+
return {
|
|
297
|
+
ok: false,
|
|
298
|
+
error: `entry '${body}' looks like a user reference but doesn't match 'Name <email>' shape`,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const role = canonicalRoleName(body);
|
|
302
|
+
if (!role)
|
|
303
|
+
return { ok: false, error: `empty role name in entry '${raw}'` };
|
|
304
|
+
return { ok: true, entry: { kind: 'role', role, displayRole: body, deny } };
|
|
305
|
+
}
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// roles.yaml + access.md parsers
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
export function parseRolesYaml(text) {
|
|
310
|
+
const parsed = parseYamlSubset(text);
|
|
311
|
+
if (!parsed.ok)
|
|
312
|
+
return { ok: false, errors: [`roles.yaml: ${parsed.error}`] };
|
|
313
|
+
const errors = [];
|
|
314
|
+
const root = parsed.value;
|
|
315
|
+
if (root == null || typeof root !== 'object' || Array.isArray(root)) {
|
|
316
|
+
return { ok: false, errors: [`roles.yaml: must be a top-level mapping`] };
|
|
317
|
+
}
|
|
318
|
+
const rolesNode = root.roles;
|
|
319
|
+
if (rolesNode == null || typeof rolesNode !== 'object' || Array.isArray(rolesNode)) {
|
|
320
|
+
return { ok: false, errors: [`roles.yaml: missing top-level 'roles:' mapping`] };
|
|
321
|
+
}
|
|
322
|
+
const index = {
|
|
323
|
+
byCanonical: new Map(),
|
|
324
|
+
byEmail: new Map(),
|
|
325
|
+
};
|
|
326
|
+
for (const [displayName, value] of Object.entries(rolesNode)) {
|
|
327
|
+
const canonical = canonicalRoleName(displayName);
|
|
328
|
+
if (!canonical) {
|
|
329
|
+
errors.push(`roles.yaml: empty role name`);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (RESERVED_ROLE_NAMES.has(canonical)) {
|
|
333
|
+
errors.push(`roles.yaml: role '${displayName}' uses reserved name '${canonical}' — this token has special meaning in access entries and cannot be a roles.yaml role`);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (index.byCanonical.has(canonical)) {
|
|
337
|
+
const prev = index.byCanonical.get(canonical).displayName;
|
|
338
|
+
errors.push(`roles.yaml: role '${displayName}' canonicalises to '${canonical}', which is already declared as '${prev}'`);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (!Array.isArray(value)) {
|
|
342
|
+
errors.push(`roles.yaml: role '${displayName}' must be a list of emails`);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const emails = new Set();
|
|
346
|
+
for (const rawEmail of value) {
|
|
347
|
+
if (typeof rawEmail !== 'string') {
|
|
348
|
+
errors.push(`roles.yaml: role '${displayName}' has a non-string entry`);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const email = canonicalEmail(rawEmail);
|
|
352
|
+
if (!EMAIL_REGEX.test(email)) {
|
|
353
|
+
errors.push(`roles.yaml: role '${displayName}' has malformed email '${rawEmail}'`);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
emails.add(email);
|
|
357
|
+
let set = index.byEmail.get(email);
|
|
358
|
+
if (!set) {
|
|
359
|
+
set = new Set();
|
|
360
|
+
index.byEmail.set(email, set);
|
|
361
|
+
}
|
|
362
|
+
set.add(canonical);
|
|
363
|
+
}
|
|
364
|
+
index.byCanonical.set(canonical, { displayName: displayName.trim(), emails });
|
|
365
|
+
}
|
|
366
|
+
if (!index.byCanonical.has(ADMIN_CANONICAL)) {
|
|
367
|
+
errors.push(`roles.yaml: must declare at least one 'Admin' role`);
|
|
368
|
+
}
|
|
369
|
+
else if (index.byCanonical.get(ADMIN_CANONICAL).emails.size === 0) {
|
|
370
|
+
errors.push(`roles.yaml: 'Admin' role has no emails`);
|
|
371
|
+
}
|
|
372
|
+
if (errors.length)
|
|
373
|
+
return { ok: false, errors };
|
|
374
|
+
return { ok: true, index };
|
|
375
|
+
}
|
|
376
|
+
function parseAccessFile(text, relativePath) {
|
|
377
|
+
const fm = extractFrontmatter(text);
|
|
378
|
+
if (!fm.ok)
|
|
379
|
+
return { ok: false, errors: [`${relativePath}: ${fm.error}`] };
|
|
380
|
+
const parsed = parseYamlSubset(fm.frontmatter);
|
|
381
|
+
if (!parsed.ok)
|
|
382
|
+
return { ok: false, errors: [`${relativePath}: ${parsed.error}`] };
|
|
383
|
+
const root = parsed.value;
|
|
384
|
+
if (root == null || typeof root !== 'object' || Array.isArray(root)) {
|
|
385
|
+
return { ok: false, errors: [`${relativePath}: frontmatter must be a mapping`] };
|
|
386
|
+
}
|
|
387
|
+
const errors = [];
|
|
388
|
+
const warnings = [];
|
|
389
|
+
const entries = emptyEntries();
|
|
390
|
+
for (const [key, value] of Object.entries(root)) {
|
|
391
|
+
if (!KNOWN_VERBS_SET.has(key)) {
|
|
392
|
+
// Forgiving by design: unknown keys (typos, future verbs, custom
|
|
393
|
+
// metadata an operator chose to colocate) must not break the access
|
|
394
|
+
// tree. Warn so operators see the typo in logs, then move on as if
|
|
395
|
+
// the key didn't exist. The verbs we DO understand still parse.
|
|
396
|
+
warnings.push(`${relativePath}: unknown access key '${key}' — ignored (known: ${[...KNOWN_VERBS].join(', ')})`);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const verb = key;
|
|
400
|
+
if (!Array.isArray(value)) {
|
|
401
|
+
errors.push(`${relativePath}: '${key}:' must be a list`);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const list = [];
|
|
405
|
+
for (const raw of value) {
|
|
406
|
+
const result = parseAccessEntry(raw);
|
|
407
|
+
if (!result.ok) {
|
|
408
|
+
errors.push(`${relativePath}: ${result.error}`);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
list.push(result.entry);
|
|
412
|
+
}
|
|
413
|
+
entries[verb] = list;
|
|
414
|
+
}
|
|
415
|
+
if (errors.length)
|
|
416
|
+
return { ok: false, errors };
|
|
417
|
+
const slash = relativePath.lastIndexOf('/');
|
|
418
|
+
const dir = slash === -1 ? '' : relativePath.slice(0, slash);
|
|
419
|
+
return { ok: true, file: { path: relativePath, dir, entries }, warnings };
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Parse the access verbs a node file declares in its own YAML frontmatter.
|
|
423
|
+
* Returns the per-verb entry lists, or null when the file has no frontmatter
|
|
424
|
+
* or declares no access verb at all.
|
|
425
|
+
*
|
|
426
|
+
* Forgiving by design — a node's frontmatter legitimately carries non-access
|
|
427
|
+
* keys (notably `nodeType:`), so unknown keys are ignored, and a malformed
|
|
428
|
+
* entry is dropped rather than failing the file. A typo in a node's `owner:`
|
|
429
|
+
* must never make the node unreadable; it just doesn't grant anything.
|
|
430
|
+
*/
|
|
431
|
+
export function parseOwnAccessEntries(text) {
|
|
432
|
+
const fm = extractFrontmatter(text);
|
|
433
|
+
if (!fm.ok)
|
|
434
|
+
return null;
|
|
435
|
+
const parsed = parseYamlSubset(fm.frontmatter);
|
|
436
|
+
if (!parsed.ok)
|
|
437
|
+
return null;
|
|
438
|
+
const root = parsed.value;
|
|
439
|
+
if (root == null || typeof root !== 'object' || Array.isArray(root))
|
|
440
|
+
return null;
|
|
441
|
+
const entries = emptyEntries();
|
|
442
|
+
let sawVerb = false;
|
|
443
|
+
for (const [key, value] of Object.entries(root)) {
|
|
444
|
+
if (!KNOWN_VERBS_SET.has(key))
|
|
445
|
+
continue; // ignore nodeType, etc.
|
|
446
|
+
// Accept both the list form (`owner:\n - A\n - B`) and the convenience
|
|
447
|
+
// single-value scalar form (`owner: Test <test@test.com>`) — the latter
|
|
448
|
+
// is the natural way to name one owner in a node's own frontmatter.
|
|
449
|
+
// Anything else (a mapping, null) is malformed → skip forgivingly.
|
|
450
|
+
let raws;
|
|
451
|
+
if (Array.isArray(value))
|
|
452
|
+
raws = value;
|
|
453
|
+
else if (typeof value === 'string' && value.trim())
|
|
454
|
+
raws = [value];
|
|
455
|
+
else
|
|
456
|
+
continue;
|
|
457
|
+
const verb = key;
|
|
458
|
+
const list = [];
|
|
459
|
+
for (const raw of raws) {
|
|
460
|
+
const result = parseAccessEntry(raw);
|
|
461
|
+
if (result.ok)
|
|
462
|
+
list.push(result.entry);
|
|
463
|
+
}
|
|
464
|
+
entries[verb] = list;
|
|
465
|
+
sawVerb = true;
|
|
466
|
+
}
|
|
467
|
+
return sawVerb ? entries : null;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Resolve the per-scope verdicts for `verb` at `relativePath`, ordered
|
|
471
|
+
* **closest-to-the-file first**: the node's own frontmatter, then its
|
|
472
|
+
* directory's `access.md`, then each parent up to the repo root.
|
|
473
|
+
*
|
|
474
|
+
* Permission resolution honours closeness *before* tier: the first scope that
|
|
475
|
+
* yields any verdict for a principal wins, and only ties *within* that scope
|
|
476
|
+
* fall back to the email > role > everyone ordering (see
|
|
477
|
+
* `hasPermissionResolved`). `collapseScopes` flattens this into the
|
|
478
|
+
* closest-wins-per-principal view the display helpers use.
|
|
479
|
+
*/
|
|
480
|
+
function resolveScopes(model, verb, relativePath, fileOwn) {
|
|
481
|
+
const target = verb;
|
|
482
|
+
const supersets = sourceVerbsFor(verb).filter((v) => v !== verb);
|
|
483
|
+
// Build one scope's effective verdicts. The target verb contributes both
|
|
484
|
+
// grants and denials; superset verbs contribute grants only (a `deny write`
|
|
485
|
+
// never strips `read`). Within the scope a grant always wins over a deny of
|
|
486
|
+
// the same principal, so a same-file superset grant overrides a target deny
|
|
487
|
+
// (e.g. `owner:` beats `deny write`, `write:` beats `deny read`).
|
|
488
|
+
const buildScope = (entries, filterRoles, source) => {
|
|
489
|
+
const byRole = new Map();
|
|
490
|
+
const byEmail = new Map();
|
|
491
|
+
const set = (entry, state) => {
|
|
492
|
+
const map = entry.kind === 'role' ? byRole : byEmail;
|
|
493
|
+
const key = entry.kind === 'role' ? entry.role : entry.email;
|
|
494
|
+
if (map.get(key) === 'grant')
|
|
495
|
+
return; // a grant in this scope sticks
|
|
496
|
+
map.set(key, state);
|
|
497
|
+
};
|
|
498
|
+
for (const entry of entries[target]) {
|
|
499
|
+
if (filterRoles && entry.kind === 'role' && !roleKnown(model.roles, entry.role))
|
|
500
|
+
continue;
|
|
501
|
+
set(entry, entry.deny ? 'denied' : 'grant');
|
|
502
|
+
}
|
|
503
|
+
for (const src of supersets) {
|
|
504
|
+
for (const entry of entries[src]) {
|
|
505
|
+
if (entry.deny)
|
|
506
|
+
continue; // grant-only fold
|
|
507
|
+
if (filterRoles && entry.kind === 'role' && !roleKnown(model.roles, entry.role))
|
|
508
|
+
continue;
|
|
509
|
+
set(entry, 'grant');
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return { byRole, byEmail, source };
|
|
513
|
+
};
|
|
514
|
+
const scopes = [];
|
|
515
|
+
// The node's own frontmatter is the most specific scope. Role refs there are
|
|
516
|
+
// not pre-filtered (the dir chain is, in loadModel), so drop unknown roles.
|
|
517
|
+
if (fileOwn)
|
|
518
|
+
scopes.push(buildScope(fileOwn, true, { kind: 'own' }));
|
|
519
|
+
const chain = dirChainFor(relativePath);
|
|
520
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
521
|
+
const file = model.accessFilesByDir.get(chain[i]);
|
|
522
|
+
if (file)
|
|
523
|
+
scopes.push(buildScope(file.entries, false, { kind: 'access-md', path: file.path }));
|
|
524
|
+
}
|
|
525
|
+
return scopes;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Flatten ordered scopes (closest→farthest) into a single closest-wins
|
|
529
|
+
* per-principal view. Used by the display / eligibility helpers, which only
|
|
530
|
+
* need "what's this principal's effective verdict?" — not the scope-by-scope
|
|
531
|
+
* precedence the permission decision applies.
|
|
532
|
+
*/
|
|
533
|
+
function collapseScopes(scopes) {
|
|
534
|
+
const byRole = new Map();
|
|
535
|
+
const byEmail = new Map();
|
|
536
|
+
for (let i = scopes.length - 1; i >= 0; i--) {
|
|
537
|
+
for (const [k, v] of scopes[i].byRole)
|
|
538
|
+
byRole.set(k, v);
|
|
539
|
+
for (const [k, v] of scopes[i].byEmail)
|
|
540
|
+
byEmail.set(k, v);
|
|
541
|
+
}
|
|
542
|
+
return { byRole, byEmail };
|
|
543
|
+
}
|
|
544
|
+
function resolveAtPath(model, verb, relativePath, fileOwn) {
|
|
545
|
+
return collapseScopes(resolveScopes(model, verb, relativePath, fileOwn));
|
|
546
|
+
}
|
|
547
|
+
function isAdminEmail(model, email) {
|
|
548
|
+
const roles = model.roles.byEmail.get(email);
|
|
549
|
+
return !!roles && roles.has(ADMIN_CANONICAL);
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Resolve whether `userEmail` has `verb` on `relativePath`.
|
|
553
|
+
*
|
|
554
|
+
* Precedence is **closeness first, tier second**: scopes are walked from the
|
|
555
|
+
* node's own frontmatter outward to the repo root, and the first scope that
|
|
556
|
+
* yields any verdict for the caller decides. Only *within* a single scope do
|
|
557
|
+
* the tiers break the tie, most-specific first: a direct email entry beats a
|
|
558
|
+
* role entry, which beats the built-in `everyone` role. When two of the
|
|
559
|
+
* caller's roles conflict at the same scope, grant wins (a deny on one role
|
|
560
|
+
* does not undo a grant via another). A closer scope's `everyone` grant
|
|
561
|
+
* therefore overrides a farther scope's email deny, and vice versa.
|
|
562
|
+
* Default-deny: no verdict at any scope → no access.
|
|
563
|
+
*
|
|
564
|
+
* Two hardcoded overrides for `write`, applied before scope resolution:
|
|
565
|
+
* - `roles.yaml` is Admin-only, regardless of any access.md content.
|
|
566
|
+
* Hard rule — the file that decides who's an admin can't be edited by
|
|
567
|
+
* a non-admin without creating a privilege-escalation loop.
|
|
568
|
+
* - Any `access.md` file is always writable by admins, even if the file
|
|
569
|
+
* itself excludes them or fails to parse cleanly. These are the rescue
|
|
570
|
+
* mechanism for the rest of the tree — without this, a typo in
|
|
571
|
+
* `access.md` could permanently lock admins out of fixing it.
|
|
572
|
+
*
|
|
573
|
+
* No special-cases for `read`/`download` — they fall through to scope resolution.
|
|
574
|
+
*/
|
|
575
|
+
function hasPermissionResolved(model, verb, userEmail, relativePath, fileOwn) {
|
|
576
|
+
const email = canonicalEmail(userEmail);
|
|
577
|
+
if (verb === 'write') {
|
|
578
|
+
if (relativePath === 'roles.yaml')
|
|
579
|
+
return isAdminEmail(model, email);
|
|
580
|
+
if (isAccessMdPath(relativePath) && isAdminEmail(model, email))
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
const userRoles = model.roles.byEmail.get(email);
|
|
584
|
+
const scopes = resolveScopes(model, verb, relativePath, fileOwn);
|
|
585
|
+
for (const scope of scopes) {
|
|
586
|
+
// Tier 1 — direct email entry is the most specific verdict at this scope.
|
|
587
|
+
const direct = scope.byEmail.get(email);
|
|
588
|
+
if (direct)
|
|
589
|
+
return direct === 'grant';
|
|
590
|
+
// Tier 2 — the caller's roles. A grant via any one of them wins over a deny
|
|
591
|
+
// via another at this same scope (a `deny Engineer` doesn't undo an
|
|
592
|
+
// unrelated `Admin` grant).
|
|
593
|
+
if (userRoles && userRoles.size) {
|
|
594
|
+
let grant = false;
|
|
595
|
+
let deny = false;
|
|
596
|
+
for (const r of userRoles) {
|
|
597
|
+
const s = scope.byRole.get(r);
|
|
598
|
+
if (s === 'denied')
|
|
599
|
+
deny = true;
|
|
600
|
+
else if (s === 'grant')
|
|
601
|
+
grant = true;
|
|
602
|
+
}
|
|
603
|
+
if (grant)
|
|
604
|
+
return true;
|
|
605
|
+
if (deny)
|
|
606
|
+
return false;
|
|
607
|
+
}
|
|
608
|
+
// Tier 3 — the built-in `everyone` role.
|
|
609
|
+
const everyone = scope.byRole.get(EVERYONE_CANONICAL);
|
|
610
|
+
if (everyone)
|
|
611
|
+
return everyone === 'grant';
|
|
612
|
+
// No verdict at this scope — fall through to the next (farther) one.
|
|
613
|
+
}
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* The `access.md` that a FOLDER target's own grants live in — its direct scope.
|
|
618
|
+
* Root (`''`) → `access.md`; otherwise `<dir>/access.md`. (Mirrors
|
|
619
|
+
* `accessMdPathForFolder` in the mutation service; duplicated here to keep the
|
|
620
|
+
* resolver free of a write-side import.)
|
|
621
|
+
*/
|
|
622
|
+
function ownAccessMdForFolder(repoRelDir) {
|
|
623
|
+
return repoRelDir ? `${repoRelDir}/access.md` : 'access.md';
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Map the scope that granted a principal to a `GrantSource`, given the target.
|
|
627
|
+
* The scope's own source (`own` frontmatter vs an `access.md` path) plus the
|
|
628
|
+
* target kind decides direct-vs-ancestor:
|
|
629
|
+
* - file target: own-frontmatter scope → direct; any `access.md` → ancestor.
|
|
630
|
+
* - folder target: the folder's OWN `access.md` → direct; any other → ancestor.
|
|
631
|
+
*/
|
|
632
|
+
function scopeToGrantSource(scope, kind, relativePath) {
|
|
633
|
+
if (scope.source.kind === 'own')
|
|
634
|
+
return { kind: 'direct' };
|
|
635
|
+
const path = scope.source.path;
|
|
636
|
+
const ownPath = kind === 'folder' ? ownAccessMdForFolder(relativePath) : null;
|
|
637
|
+
if (ownPath !== null && path === ownPath)
|
|
638
|
+
return { kind: 'direct' };
|
|
639
|
+
return { kind: 'ancestor', path };
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Resolve EVERY file scope that names a principal for `verb`, ordered
|
|
643
|
+
* closest-first — the source-returning twin of `hasPermissionResolved`, but
|
|
644
|
+
* returning the WHOLE list of removable entries rather than just the winner.
|
|
645
|
+
*
|
|
646
|
+
* Only the principal's OWN named entry yields a source — a `user` by their email,
|
|
647
|
+
* a `role` by its role token. A grant that reaches the user via a group they
|
|
648
|
+
* belong to, the built-in `everyone`, or admin-rescue is NOT their entry, so it
|
|
649
|
+
* never adds a source (the group/role shows as its own row instead). The list is:
|
|
650
|
+
* - `[]` (verb omitted by the caller) when the principal effectively holds no
|
|
651
|
+
* `verb` via a named entry: they're not named, OR their CLOSEST own-email
|
|
652
|
+
* verdict is a `deny` (cut off — any farther grant is dead), OR they only
|
|
653
|
+
* resolve via a group/everyone/rescue.
|
|
654
|
+
* - otherwise `[closest, …farther]` — each scope where the principal's own
|
|
655
|
+
* entry GRANTS the verb, closest-first, up to (but not including) a closer
|
|
656
|
+
* own-email `deny`. `[0]` is the effective source.
|
|
657
|
+
*
|
|
658
|
+
* Why all of them, not just `[0]`: the dialog must tell "granted here" apart from
|
|
659
|
+
* "granted here AND also inherited from a parent" (both collapse to `direct`
|
|
660
|
+
* under closest-wins), and the revoke flow needs the inherited remainder that
|
|
661
|
+
* survives removing the direct entry. A group/everyone grant at some scope does
|
|
662
|
+
* not add a source AND does not hide a farther own-entry the principal is named
|
|
663
|
+
* in (removing it is still meaningful if the group grant is later removed).
|
|
664
|
+
*/
|
|
665
|
+
function resolveGrantSourcesForVerb(model, verb, kind, relativePath, principal, fileOwn) {
|
|
666
|
+
const scopes = resolveScopes(model, verb, relativePath, fileOwn);
|
|
667
|
+
const out = [];
|
|
668
|
+
if (principal.kind === 'role') {
|
|
669
|
+
const role = canonicalRoleName(principal.role);
|
|
670
|
+
for (const scope of scopes) {
|
|
671
|
+
const s = scope.byRole.get(role);
|
|
672
|
+
if (s === 'denied')
|
|
673
|
+
break; // a closer deny of this role cuts off farther grants
|
|
674
|
+
if (s === 'grant')
|
|
675
|
+
out.push(scopeToGrantSource(scope, kind, relativePath));
|
|
676
|
+
}
|
|
677
|
+
return out;
|
|
678
|
+
}
|
|
679
|
+
const email = canonicalEmail(principal.email);
|
|
680
|
+
for (const scope of scopes) {
|
|
681
|
+
// The principal's OWN email entry at this scope is the only thing that yields
|
|
682
|
+
// a removable source. A grant adds it; a deny cuts off everything farther
|
|
683
|
+
// (closeness-first: a closer deny shadows farther grants).
|
|
684
|
+
const direct = scope.byEmail.get(email);
|
|
685
|
+
if (direct === 'denied')
|
|
686
|
+
break;
|
|
687
|
+
if (direct === 'grant')
|
|
688
|
+
out.push(scopeToGrantSource(scope, kind, relativePath));
|
|
689
|
+
// A group/everyone grant or deny at this scope is NOT this user's own entry:
|
|
690
|
+
// it neither adds a source nor hides a farther own-entry, so we keep walking.
|
|
691
|
+
}
|
|
692
|
+
return out;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Build the repo-root → path chain of directory scopes that govern
|
|
696
|
+
* `relativePath`, e.g. `Knowledge/Sales/Foo.md` →
|
|
697
|
+
* `['', 'Knowledge', 'Knowledge/Sales', 'Knowledge/Sales/Foo.md']`.
|
|
698
|
+
*
|
|
699
|
+
* Every segment is included, INCLUDING the leaf. For a file leaf the leaf
|
|
700
|
+
* entry simply never matches a directory key in `accessFilesByDir` (those are
|
|
701
|
+
* keyed by the access.md's parent dir), so it's a harmless no-op. For a
|
|
702
|
+
* DIRECTORY leaf (e.g. resolving `canRead('Knowledge/Secret')` for a folder),
|
|
703
|
+
* including it means `Knowledge/Secret/access.md`'s own rules apply to the
|
|
704
|
+
* folder itself — without this, a folder's own read grant would not apply to
|
|
705
|
+
* the directory node that names it.
|
|
706
|
+
*/
|
|
707
|
+
function dirChainFor(relativePath) {
|
|
708
|
+
const chain = [''];
|
|
709
|
+
let acc = '';
|
|
710
|
+
for (const p of relativePath.split('/')) {
|
|
711
|
+
if (!p)
|
|
712
|
+
continue;
|
|
713
|
+
acc = acc ? `${acc}/${p}` : p;
|
|
714
|
+
chain.push(acc);
|
|
715
|
+
}
|
|
716
|
+
return chain;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Whether this path is readable by every signed-in user via the built-in
|
|
720
|
+
* `everyone` role. Used only for display semantics: when this is true
|
|
721
|
+
* the UI can say "everyone can see this" instead of listing a meaningless
|
|
722
|
+
* role set. Any effective denial that carves someone out keeps the node
|
|
723
|
+
* reported as restricted.
|
|
724
|
+
*/
|
|
725
|
+
function canEveryoneReadResolved(model, relativePath, fileOwn) {
|
|
726
|
+
const { byRole, byEmail } = resolveAtPath(model, 'read', relativePath, fileOwn);
|
|
727
|
+
// Baseline: an unnamed signed-in user (no email/role entries) reads only via
|
|
728
|
+
// `everyone`. As a single principal, its collapsed verdict is its closest —
|
|
729
|
+
// exactly what that user resolves to.
|
|
730
|
+
if (byRole.get(EVERYONE_CANONICAL) !== 'grant')
|
|
731
|
+
return false;
|
|
732
|
+
// Every *named* principal must also still read. A collapsed deny may have
|
|
733
|
+
// been shadowed by a closer-scope `everyone` grant, so re-resolve each
|
|
734
|
+
// candidate through the closeness-first gate rather than trusting the
|
|
735
|
+
// collapsed deny state. (Role members live in roles.yaml; inline-named users
|
|
736
|
+
// come from `byEmail`.)
|
|
737
|
+
const candidates = new Set();
|
|
738
|
+
for (const email of model.roles.byEmail.keys())
|
|
739
|
+
candidates.add(email);
|
|
740
|
+
for (const email of byEmail.keys())
|
|
741
|
+
candidates.add(email);
|
|
742
|
+
for (const email of candidates) {
|
|
743
|
+
if (!hasPermissionResolved(model, 'read', email, relativePath, fileOwn))
|
|
744
|
+
return false;
|
|
745
|
+
}
|
|
746
|
+
return true;
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Resolve whether `userEmail` may READ `relativePath`.
|
|
750
|
+
*
|
|
751
|
+
* Read is default-deny: a path is readable only when resolution grants the
|
|
752
|
+
* caller `read` directly, via one of their roles, via the built-in `everyone`
|
|
753
|
+
* role, or via `owner:` (owners implicitly read).
|
|
754
|
+
*
|
|
755
|
+
* No admin rescue (mirrors `download`): admins read a node only if an
|
|
756
|
+
* `access.md` lists them — directly, via a role, through `everyone`, or as an
|
|
757
|
+
* owner.
|
|
758
|
+
*/
|
|
759
|
+
function canReadResolved(model, userEmail, relativePath, fileOwn) {
|
|
760
|
+
return hasPermissionResolved(model, 'read', userEmail, relativePath, fileOwn);
|
|
761
|
+
}
|
|
762
|
+
function eligibleHoldersResolved(model, verb, relativePath, fileOwn) {
|
|
763
|
+
const { byRole, byEmail } = resolveAtPath(model, verb, relativePath, fileOwn);
|
|
764
|
+
const roleSet = new Set();
|
|
765
|
+
for (const [canonical, state] of byRole) {
|
|
766
|
+
if (state !== 'grant')
|
|
767
|
+
continue;
|
|
768
|
+
const role = model.roles.byCanonical.get(canonical);
|
|
769
|
+
roleSet.add(role ? role.displayName : canonical);
|
|
770
|
+
}
|
|
771
|
+
// Mirror the admin overrides applied in `hasPermissionResolved`: write on
|
|
772
|
+
// `roles.yaml` and on any `access.md` is granted to Admin even if the
|
|
773
|
+
// file content doesn't list it. Without surfacing that here, the
|
|
774
|
+
// "restricted to …" banner under-reports who can actually fix a bad
|
|
775
|
+
// access.md (it'd say "restricted to no one" when Admin is the answer).
|
|
776
|
+
if (verb === 'write' &&
|
|
777
|
+
(relativePath === 'roles.yaml' || isAccessMdPath(relativePath))) {
|
|
778
|
+
const adminRole = model.roles.byCanonical.get(ADMIN_CANONICAL);
|
|
779
|
+
roleSet.add(adminRole ? adminRole.displayName : ADMIN_CANONICAL);
|
|
780
|
+
}
|
|
781
|
+
const roles = [...roleSet].sort();
|
|
782
|
+
const users = [];
|
|
783
|
+
for (const [email, state] of byEmail) {
|
|
784
|
+
if (state !== 'grant')
|
|
785
|
+
continue;
|
|
786
|
+
users.push({ name: '', email });
|
|
787
|
+
}
|
|
788
|
+
users.sort((a, b) => a.email.localeCompare(b.email));
|
|
789
|
+
return { roles, users };
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Expand the eligible-writer set to the underlying emails.
|
|
793
|
+
*
|
|
794
|
+
* Algorithm: for every email in roles.yaml, ask `canWriteResolved` whether
|
|
795
|
+
* that email can write this path. That walks both the role-level and
|
|
796
|
+
* email-level state with the correct precedence (user-level entries trump
|
|
797
|
+
* role-level), so we don't have to reimplement the resolution logic — we
|
|
798
|
+
* just enumerate candidates and let the resolver answer each one.
|
|
799
|
+
*
|
|
800
|
+
* Direct user grants (emails named inline with no `roles.yaml` entry) are
|
|
801
|
+
* picked up from `byEmail` separately.
|
|
802
|
+
*/
|
|
803
|
+
function eligibleHolderEmailsResolved(model, verb, relativePath, fileOwn) {
|
|
804
|
+
const { byEmail } = resolveAtPath(model, verb, relativePath, fileOwn);
|
|
805
|
+
const out = new Map();
|
|
806
|
+
// Candidate emails: all emails in roles.yaml, plus everyone named directly
|
|
807
|
+
// in an access.md entry at this path's scope (whether granted or denied —
|
|
808
|
+
// `hasPermissionResolved` filters denials out below). The built-in
|
|
809
|
+
// `everyone` role can grant arbitrary signed-in users, but this finite
|
|
810
|
+
// expansion can only return emails the access tree explicitly names.
|
|
811
|
+
const candidates = new Set();
|
|
812
|
+
for (const email of model.roles.byEmail.keys())
|
|
813
|
+
candidates.add(email);
|
|
814
|
+
for (const email of byEmail.keys())
|
|
815
|
+
candidates.add(email);
|
|
816
|
+
for (const email of candidates) {
|
|
817
|
+
if (hasPermissionResolved(model, verb, email, relativePath, fileOwn)) {
|
|
818
|
+
out.set(email, { name: '', email });
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return out;
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* The set of explicitly-named individuals (roles.yaml members + emails named
|
|
825
|
+
* inline at this path's scope) who do NOT hold `verb` here. This is the
|
|
826
|
+
* general exclusion set used by the merge gate to subtract from a blanket
|
|
827
|
+
* `everyone` grant: it catches denials at *any* tier — a `deny email`, a
|
|
828
|
+
* `deny role` covering one of the user's roles, or a `deny everyone` carve-out
|
|
829
|
+
* — not just direct user denials, because each candidate is run through full
|
|
830
|
+
* scope resolution. (The complement of `eligibleHolderEmailsResolved` over the
|
|
831
|
+
* same candidate set.)
|
|
832
|
+
*/
|
|
833
|
+
function ineligibleNamedEmailsResolved(model, verb, relativePath, fileOwn) {
|
|
834
|
+
const { byEmail } = resolveAtPath(model, verb, relativePath, fileOwn);
|
|
835
|
+
const candidates = new Set();
|
|
836
|
+
for (const email of model.roles.byEmail.keys())
|
|
837
|
+
candidates.add(email);
|
|
838
|
+
for (const email of byEmail.keys())
|
|
839
|
+
candidates.add(email);
|
|
840
|
+
const out = new Set();
|
|
841
|
+
for (const email of candidates) {
|
|
842
|
+
if (!hasPermissionResolved(model, verb, email, relativePath, fileOwn))
|
|
843
|
+
out.add(email);
|
|
844
|
+
}
|
|
845
|
+
return out;
|
|
846
|
+
}
|
|
847
|
+
// ---------------------------------------------------------------------------
|
|
848
|
+
// Service
|
|
849
|
+
// ---------------------------------------------------------------------------
|
|
850
|
+
export class AccessControlService {
|
|
851
|
+
workspaceService;
|
|
852
|
+
kbDirName;
|
|
853
|
+
/**
|
|
854
|
+
* Per-workspace cache: `model` is the resolved access tree and `loadedAt`
|
|
855
|
+
* is when we computed it. We re-load on a TTL — the validator runs on
|
|
856
|
+
* every commit so stale state surfaces quickly anyway, and the read cost
|
|
857
|
+
* is small (a few small files).
|
|
858
|
+
*/
|
|
859
|
+
cache = new Map();
|
|
860
|
+
static CACHE_TTL_MS = 5_000;
|
|
861
|
+
/**
|
|
862
|
+
* Per-workspace memo of node frontmatter access entries (keyed by
|
|
863
|
+
* repo-relative path), for the BATCH checks — the explorer tree resolves
|
|
864
|
+
* every KB file on each load, which would otherwise cost one `fs.readFile`
|
|
865
|
+
* per node per tree build. Dropped by `invalidate()` — which fires on every
|
|
866
|
+
* commit / pull / branch switch, i.e. on every path a frontmatter edit can
|
|
867
|
+
* land through — so that is the PRIMARY freshness mechanism; the TTL is
|
|
868
|
+
* only a long backstop against a missed invalidation. It is deliberately
|
|
869
|
+
* NOT the model cache's 5s TTL: at 5s every tree build was effectively
|
|
870
|
+
* cold (~one full-KB read sweep per build). Single-file gates (`canRead` /
|
|
871
|
+
* `canWrite`) stay uncached — the write gate reads disk-fresh on purpose.
|
|
872
|
+
*/
|
|
873
|
+
ownEntriesCache = new Map();
|
|
874
|
+
static OWN_ENTRIES_TTL_MS = 5 * 60_000;
|
|
875
|
+
constructor(workspaceService, kbDirName) {
|
|
876
|
+
this.workspaceService = workspaceService;
|
|
877
|
+
this.kbDirName = kbDirName;
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Drop a workspace's cached model + frontmatter memo. Call after operations
|
|
881
|
+
* that mutate the working tree — commit, push, pull, branch switch.
|
|
882
|
+
*/
|
|
883
|
+
invalidate(workspaceId) {
|
|
884
|
+
this.cache.delete(workspaceId);
|
|
885
|
+
this.ownEntriesCache.delete(workspaceId);
|
|
886
|
+
}
|
|
887
|
+
/** Memoized `readOwnEntries` for the batch paths; see `ownEntriesCache`. */
|
|
888
|
+
async cachedOwnEntries(workspaceId, repoDir, relativePath) {
|
|
889
|
+
let ws = this.ownEntriesCache.get(workspaceId);
|
|
890
|
+
if (!ws || Date.now() - ws.loadedAt > AccessControlService.OWN_ENTRIES_TTL_MS) {
|
|
891
|
+
ws = { loadedAt: Date.now(), byPath: new Map() };
|
|
892
|
+
this.ownEntriesCache.set(workspaceId, ws);
|
|
893
|
+
}
|
|
894
|
+
const hit = ws.byPath.get(relativePath);
|
|
895
|
+
if (hit !== undefined || ws.byPath.has(relativePath))
|
|
896
|
+
return hit ?? null;
|
|
897
|
+
const own = await this.readOwnEntries(repoDir, relativePath);
|
|
898
|
+
ws.byPath.set(relativePath, own);
|
|
899
|
+
return own;
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Validate a candidate `roles.yaml` against the resolver's OWN parser without
|
|
903
|
+
* writing it. See `IAccessControl.validateRolesYaml` — this is the gate that
|
|
904
|
+
* makes a malformed-`roles.yaml` admin lockout structurally impossible: the
|
|
905
|
+
* roles-admin service runs it on every candidate before committing, and
|
|
906
|
+
* because it IS `parseRolesYaml`, any text that passes here is loadable by
|
|
907
|
+
* `loadModel`.
|
|
908
|
+
*/
|
|
909
|
+
validateRolesYaml(text) {
|
|
910
|
+
const parsed = parseRolesYaml(text);
|
|
911
|
+
return parsed.ok ? { ok: true } : { ok: false, errors: parsed.errors };
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Advisory scan of folder `access.md` files for references to a role (by
|
|
915
|
+
* canonical name). See `IAccessControl.referencesToRole` — undercounts node
|
|
916
|
+
* frontmatter by design; powers the delete warning only, never the rename
|
|
917
|
+
* gate.
|
|
918
|
+
*/
|
|
919
|
+
async referencesToRole(workspaceId, canonicalRole) {
|
|
920
|
+
const model = await this.loadModel(workspaceId);
|
|
921
|
+
const out = [];
|
|
922
|
+
for (const file of model.accessFilesByDir.values()) {
|
|
923
|
+
for (const verb of KNOWN_VERBS) {
|
|
924
|
+
for (const entry of file.entries[verb]) {
|
|
925
|
+
if (entry.kind === 'role' && entry.role === canonicalRole) {
|
|
926
|
+
out.push({ path: file.path, verb });
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return out;
|
|
932
|
+
}
|
|
933
|
+
async canWrite(workspaceId, userEmail, relativePath) {
|
|
934
|
+
const model = await this.loadModel(workspaceId);
|
|
935
|
+
const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
|
|
936
|
+
return hasPermissionResolved(model, 'write', userEmail, relativePath, own);
|
|
937
|
+
}
|
|
938
|
+
async canRead(workspaceId, userEmail, relativePath) {
|
|
939
|
+
const model = await this.loadModel(workspaceId);
|
|
940
|
+
const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
|
|
941
|
+
return canReadResolved(model, userEmail, relativePath, own);
|
|
942
|
+
}
|
|
943
|
+
async canReadBatch(workspaceId, userEmail, relativePaths) {
|
|
944
|
+
const model = await this.loadModel(workspaceId);
|
|
945
|
+
const repoDir = await this.repoDir(workspaceId);
|
|
946
|
+
// Read each path's own-entries in parallel rather than serially (the batch
|
|
947
|
+
// path otherwise bottlenecks on per-file disk latency), memoized per
|
|
948
|
+
// workspace so repeat tree builds skip the disk entirely.
|
|
949
|
+
const owns = await Promise.all(relativePaths.map((p) => this.cachedOwnEntries(workspaceId, repoDir, p)));
|
|
950
|
+
const result = new Map();
|
|
951
|
+
relativePaths.forEach((p, i) => {
|
|
952
|
+
result.set(p, canReadResolved(model, userEmail, p, owns[i]));
|
|
953
|
+
});
|
|
954
|
+
return result;
|
|
955
|
+
}
|
|
956
|
+
async canWriteBatch(workspaceId, userEmail, relativePaths) {
|
|
957
|
+
const model = await this.loadModel(workspaceId);
|
|
958
|
+
const repoDir = await this.repoDir(workspaceId);
|
|
959
|
+
// Parallel own-entries reads, memoized — same shape as `canReadBatch`.
|
|
960
|
+
const owns = await Promise.all(relativePaths.map((p) => this.cachedOwnEntries(workspaceId, repoDir, p)));
|
|
961
|
+
const result = new Map();
|
|
962
|
+
relativePaths.forEach((p, i) => {
|
|
963
|
+
result.set(p, hasPermissionResolved(model, 'write', userEmail, p, owns[i]));
|
|
964
|
+
});
|
|
965
|
+
return result;
|
|
966
|
+
}
|
|
967
|
+
async canDownload(workspaceId, userEmail, relativePath) {
|
|
968
|
+
const model = await this.loadModel(workspaceId);
|
|
969
|
+
const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
|
|
970
|
+
return hasPermissionResolved(model, 'download', userEmail, relativePath, own);
|
|
971
|
+
}
|
|
972
|
+
async canOwner(workspaceId, userEmail, relativePath) {
|
|
973
|
+
const model = await this.loadModel(workspaceId);
|
|
974
|
+
const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
|
|
975
|
+
return hasPermissionResolved(model, 'owner', userEmail, relativePath, own);
|
|
976
|
+
}
|
|
977
|
+
async eligibleOwners(workspaceId, relativePath) {
|
|
978
|
+
const model = await this.loadModel(workspaceId);
|
|
979
|
+
const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
|
|
980
|
+
return eligibleHoldersResolved(model, 'owner', relativePath, own);
|
|
981
|
+
}
|
|
982
|
+
async eligibleWriters(workspaceId, relativePath) {
|
|
983
|
+
const model = await this.loadModel(workspaceId);
|
|
984
|
+
const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
|
|
985
|
+
return eligibleHoldersResolved(model, 'write', relativePath, own);
|
|
986
|
+
}
|
|
987
|
+
async eligibleReaders(workspaceId, relativePath) {
|
|
988
|
+
const model = await this.loadModel(workspaceId);
|
|
989
|
+
const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
|
|
990
|
+
// When `read: everyone` applies cleanly, the node is readable by all users
|
|
991
|
+
// and the role/user lists are meaningless. Otherwise return the explicit
|
|
992
|
+
// reader set; it may be empty for a default-denied path with no grants.
|
|
993
|
+
if (canEveryoneReadResolved(model, relativePath, own)) {
|
|
994
|
+
return { restricted: false, roles: [], users: [] };
|
|
995
|
+
}
|
|
996
|
+
const { roles, users } = eligibleHoldersResolved(model, 'read', relativePath, own);
|
|
997
|
+
return { restricted: true, roles, users };
|
|
998
|
+
}
|
|
999
|
+
async eligibleDownloaders(workspaceId, relativePath) {
|
|
1000
|
+
const model = await this.loadModel(workspaceId);
|
|
1001
|
+
const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
|
|
1002
|
+
return eligibleHoldersResolved(model, 'download', relativePath, own);
|
|
1003
|
+
}
|
|
1004
|
+
async eligibleWriterEmails(workspaceId, relativePath) {
|
|
1005
|
+
const model = await this.loadModel(workspaceId);
|
|
1006
|
+
const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
|
|
1007
|
+
return eligibleHolderEmailsResolved(model, 'write', relativePath, own);
|
|
1008
|
+
}
|
|
1009
|
+
async eligibleOwnerEmails(workspaceId, relativePath) {
|
|
1010
|
+
const model = await this.loadModel(workspaceId);
|
|
1011
|
+
const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
|
|
1012
|
+
return eligibleHolderEmailsResolved(model, 'owner', relativePath, own);
|
|
1013
|
+
}
|
|
1014
|
+
async grantSources(workspaceId, kind, relativePath, principal) {
|
|
1015
|
+
const model = await this.loadModel(workspaceId);
|
|
1016
|
+
// A file target consults its own frontmatter as the most-specific scope; a
|
|
1017
|
+
// folder target's most-specific scope is its own access.md (in the dir
|
|
1018
|
+
// chain), so it passes no fileOwn.
|
|
1019
|
+
const own = kind === 'file'
|
|
1020
|
+
? await this.readOwnEntries(await this.repoDir(workspaceId), relativePath)
|
|
1021
|
+
: null;
|
|
1022
|
+
const out = {};
|
|
1023
|
+
for (const verb of KNOWN_VERBS) {
|
|
1024
|
+
const sources = resolveGrantSourcesForVerb(model, verb, kind, relativePath, principal, own);
|
|
1025
|
+
if (sources.length > 0)
|
|
1026
|
+
out[verb] = sources;
|
|
1027
|
+
}
|
|
1028
|
+
return out;
|
|
1029
|
+
}
|
|
1030
|
+
async kbPrincipals(workspaceId) {
|
|
1031
|
+
let model;
|
|
1032
|
+
try {
|
|
1033
|
+
model = await this.loadModel(workspaceId);
|
|
1034
|
+
}
|
|
1035
|
+
catch {
|
|
1036
|
+
return { groups: [], people: [] };
|
|
1037
|
+
}
|
|
1038
|
+
// Groups = the built-in `everyone` role plus every declared role's display
|
|
1039
|
+
// name. `everyone` is surfaced so the share UI can grant public read; the
|
|
1040
|
+
// grant route gates it to the `read` verb only (write/owner/download
|
|
1041
|
+
// everyone stay a direct-access.md edit).
|
|
1042
|
+
const groups = [
|
|
1043
|
+
EVERYONE_DISPLAY,
|
|
1044
|
+
...[...model.roles.byCanonical.values()].map((r) => r.displayName),
|
|
1045
|
+
];
|
|
1046
|
+
// People = roles.yaml member emails (name-less) ∪ access.md `Name <email>`
|
|
1047
|
+
// grants (named). The login-only users table is unioned in by the caller.
|
|
1048
|
+
const byEmail = new Map(); // email -> display name ('' if unknown)
|
|
1049
|
+
for (const email of model.roles.byEmail.keys()) {
|
|
1050
|
+
if (!byEmail.has(email))
|
|
1051
|
+
byEmail.set(email, '');
|
|
1052
|
+
}
|
|
1053
|
+
for (const file of model.accessFilesByDir.values()) {
|
|
1054
|
+
for (const verb of KNOWN_VERBS) {
|
|
1055
|
+
for (const entry of file.entries[verb]) {
|
|
1056
|
+
if (entry.kind === 'user' && entry.displayName && !byEmail.get(entry.email)) {
|
|
1057
|
+
byEmail.set(entry.email, entry.displayName);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
const people = [...byEmail.entries()].map(([email, name]) => ({
|
|
1063
|
+
name: name || email.split('@')[0],
|
|
1064
|
+
email,
|
|
1065
|
+
}));
|
|
1066
|
+
return { groups, people };
|
|
1067
|
+
}
|
|
1068
|
+
async findEmailByHash(workspaceId, hash) {
|
|
1069
|
+
let model;
|
|
1070
|
+
try {
|
|
1071
|
+
model = await this.loadModel(workspaceId);
|
|
1072
|
+
}
|
|
1073
|
+
catch {
|
|
1074
|
+
return null;
|
|
1075
|
+
}
|
|
1076
|
+
// Build a name index from access.md user grants — those carry an
|
|
1077
|
+
// explicit `Name <email>` so we can show "Felix Kissel" instead of
|
|
1078
|
+
// "felix.kissel". roles.yaml only has emails, no names. Scan every
|
|
1079
|
+
// verb's entries so a user named only under `download:` still
|
|
1080
|
+
// contributes their display name.
|
|
1081
|
+
const namesByEmail = new Map();
|
|
1082
|
+
for (const file of model.accessFilesByDir.values()) {
|
|
1083
|
+
for (const verb of KNOWN_VERBS) {
|
|
1084
|
+
for (const entry of file.entries[verb]) {
|
|
1085
|
+
if (entry.kind === 'user' && entry.displayName && !namesByEmail.has(entry.email)) {
|
|
1086
|
+
namesByEmail.set(entry.email, entry.displayName);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
const candidates = new Set([
|
|
1092
|
+
...model.roles.byEmail.keys(),
|
|
1093
|
+
...namesByEmail.keys(),
|
|
1094
|
+
]);
|
|
1095
|
+
for (const email of candidates) {
|
|
1096
|
+
if (sha256Email(email) === hash) {
|
|
1097
|
+
const displayName = namesByEmail.get(email) ?? email.split('@')[0];
|
|
1098
|
+
return { email, displayName };
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
return null;
|
|
1102
|
+
}
|
|
1103
|
+
async canWriteAtRef(workspaceId, ref, userEmail, relativePath) {
|
|
1104
|
+
const loaded = await this.loadModelAtRef(workspaceId, ref);
|
|
1105
|
+
if (!loaded)
|
|
1106
|
+
return null;
|
|
1107
|
+
const repoDir = await this.repoDir(workspaceId);
|
|
1108
|
+
const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
|
|
1109
|
+
return hasPermissionResolved(loaded.model, 'write', userEmail, relativePath, own);
|
|
1110
|
+
}
|
|
1111
|
+
async canReadAtRef(workspaceId, ref, userEmail, relativePath) {
|
|
1112
|
+
const loaded = await this.loadModelAtRef(workspaceId, ref);
|
|
1113
|
+
if (!loaded)
|
|
1114
|
+
return null;
|
|
1115
|
+
const repoDir = await this.repoDir(workspaceId);
|
|
1116
|
+
const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
|
|
1117
|
+
return canReadResolved(loaded.model, userEmail, relativePath, own);
|
|
1118
|
+
}
|
|
1119
|
+
async canWriteBatchAtRef(workspaceId, ref, userEmail, relativePaths) {
|
|
1120
|
+
const loaded = await this.loadModelAtRef(workspaceId, ref);
|
|
1121
|
+
if (!loaded)
|
|
1122
|
+
return null;
|
|
1123
|
+
const repoDir = await this.repoDir(workspaceId);
|
|
1124
|
+
// One `git cat-file --batch` for the whole path set — a per-path `git
|
|
1125
|
+
// show` spawn made CR owner-routing take minutes on large change sets.
|
|
1126
|
+
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
|
|
1127
|
+
const result = new Map();
|
|
1128
|
+
for (const p of relativePaths) {
|
|
1129
|
+
result.set(p, hasPermissionResolved(loaded.model, 'write', userEmail, p, owns.get(p) ?? null));
|
|
1130
|
+
}
|
|
1131
|
+
return result;
|
|
1132
|
+
}
|
|
1133
|
+
async eligibleWritersAtRef(workspaceId, ref, relativePath) {
|
|
1134
|
+
const loaded = await this.loadModelAtRef(workspaceId, ref);
|
|
1135
|
+
if (!loaded)
|
|
1136
|
+
return null;
|
|
1137
|
+
const repoDir = await this.repoDir(workspaceId);
|
|
1138
|
+
const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
|
|
1139
|
+
return eligibleHoldersResolved(loaded.model, 'write', relativePath, own);
|
|
1140
|
+
}
|
|
1141
|
+
async eligibleWritersForPathsAtRef(workspaceId, ref, relativePaths) {
|
|
1142
|
+
const loaded = await this.loadModelAtRef(workspaceId, ref);
|
|
1143
|
+
if (!loaded)
|
|
1144
|
+
return null;
|
|
1145
|
+
const repoDir = await this.repoDir(workspaceId);
|
|
1146
|
+
const result = new Map();
|
|
1147
|
+
// One `git cat-file --batch` for the whole path set — see canWriteBatchAtRef.
|
|
1148
|
+
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
|
|
1149
|
+
for (const p of relativePaths) {
|
|
1150
|
+
const own = owns.get(p) ?? null;
|
|
1151
|
+
const display = eligibleHoldersResolved(loaded.model, 'write', p, own);
|
|
1152
|
+
const emails = new Set(eligibleHolderEmailsResolved(loaded.model, 'write', p, own).keys());
|
|
1153
|
+
const excludedEmails = ineligibleNamedEmailsResolved(loaded.model, 'write', p, own);
|
|
1154
|
+
result.set(p, { roles: display.roles, users: display.users, emails, excludedEmails });
|
|
1155
|
+
}
|
|
1156
|
+
return result;
|
|
1157
|
+
}
|
|
1158
|
+
// -------------------------------------------------------------------------
|
|
1159
|
+
// Model loading
|
|
1160
|
+
// -------------------------------------------------------------------------
|
|
1161
|
+
async loadModel(workspaceId) {
|
|
1162
|
+
const cached = this.cache.get(workspaceId);
|
|
1163
|
+
if (cached && Date.now() - cached.loadedAt < AccessControlService.CACHE_TTL_MS) {
|
|
1164
|
+
return cached.model;
|
|
1165
|
+
}
|
|
1166
|
+
const repoDir = await this.repoDir(workspaceId);
|
|
1167
|
+
let rolesYaml;
|
|
1168
|
+
try {
|
|
1169
|
+
rolesYaml = await fs.readFile(path.join(repoDir, 'roles.yaml'), 'utf-8');
|
|
1170
|
+
}
|
|
1171
|
+
catch {
|
|
1172
|
+
throw new AccessConfigError([`roles.yaml not found in ${this.kbDirName}/`]);
|
|
1173
|
+
}
|
|
1174
|
+
const rolesParsed = parseRolesYaml(rolesYaml);
|
|
1175
|
+
if (!rolesParsed.ok)
|
|
1176
|
+
throw new AccessConfigError(rolesParsed.errors);
|
|
1177
|
+
const accessFiles = new Map();
|
|
1178
|
+
// Walk the entire repo for `access.md` files. The access tree is
|
|
1179
|
+
// structure-agnostic — any `access.md` at any depth (including repo
|
|
1180
|
+
// root) participates, no matter the folder layout. The recursive
|
|
1181
|
+
// walker skips VCS metadata + `node_modules`; everything else is fair
|
|
1182
|
+
// game. Missing root `access.md` is OK at runtime — default-deny
|
|
1183
|
+
// applies (the validator surfaces it as a warning separately).
|
|
1184
|
+
//
|
|
1185
|
+
// Per-file failures (malformed YAML, bad role refs, unknown verbs)
|
|
1186
|
+
// are logged + the offending file is dropped from the model — they
|
|
1187
|
+
// do NOT throw `AccessConfigError`. A typo in one nested access.md
|
|
1188
|
+
// must not 500 the entire editor; admins can still write `access.md`
|
|
1189
|
+
// / `roles.yaml` because `hasPermissionResolved` admin-rescues those
|
|
1190
|
+
// paths, so the bad config remains fixable from inside the app.
|
|
1191
|
+
await this.collectAccessFiles(repoDir, '', accessFiles);
|
|
1192
|
+
// Validate role refs against roles.yaml. `everyone` is a built-in role and
|
|
1193
|
+
// is valid without a roles.yaml entry. Unknown refs are dropped from the
|
|
1194
|
+
// parsed entry list (along with a warn log) so the rest of the file still
|
|
1195
|
+
// applies.
|
|
1196
|
+
for (const [, file] of accessFiles) {
|
|
1197
|
+
for (const verb of KNOWN_VERBS) {
|
|
1198
|
+
file.entries[verb] = file.entries[verb].filter((entry) => {
|
|
1199
|
+
if (entry.kind === 'role' && !roleKnown(rolesParsed.index, entry.role)) {
|
|
1200
|
+
console.warn(`[access] ${file.path}: '${verb}' references unknown role '${entry.displayRole}' — entry ignored`);
|
|
1201
|
+
return false;
|
|
1202
|
+
}
|
|
1203
|
+
return true;
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
const model = {
|
|
1208
|
+
roles: rolesParsed.index,
|
|
1209
|
+
accessFilesByDir: accessFiles,
|
|
1210
|
+
};
|
|
1211
|
+
this.cache.set(workspaceId, { model, loadedAt: Date.now() });
|
|
1212
|
+
return model;
|
|
1213
|
+
}
|
|
1214
|
+
async collectAccessFiles(absDir, relDir, out) {
|
|
1215
|
+
let entries;
|
|
1216
|
+
try {
|
|
1217
|
+
entries = await fs.readdir(absDir, { withFileTypes: true });
|
|
1218
|
+
}
|
|
1219
|
+
catch {
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
for (const entry of entries) {
|
|
1223
|
+
// Skip VCS metadata + vendored deps. Hidden dirs (`.git`, `.vscode`,
|
|
1224
|
+
// etc.) and `node_modules` can't host KB rules and are often huge —
|
|
1225
|
+
// walking them would slow every cache miss without benefit.
|
|
1226
|
+
if (entry.name.startsWith('.'))
|
|
1227
|
+
continue;
|
|
1228
|
+
if (entry.name === 'node_modules')
|
|
1229
|
+
continue;
|
|
1230
|
+
const abs = path.join(absDir, entry.name);
|
|
1231
|
+
const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
1232
|
+
if (entry.isDirectory()) {
|
|
1233
|
+
await this.collectAccessFiles(abs, rel, out);
|
|
1234
|
+
}
|
|
1235
|
+
else if (entry.isFile() && entry.name === 'access.md') {
|
|
1236
|
+
const text = await fs.readFile(abs, 'utf-8');
|
|
1237
|
+
const parsed = parseAccessFile(text, rel);
|
|
1238
|
+
if (!parsed.ok) {
|
|
1239
|
+
// Treat as if the file didn't exist for resolution purposes.
|
|
1240
|
+
// Admin-rescue on access.md paths still lets an admin fix it.
|
|
1241
|
+
for (const e of parsed.errors)
|
|
1242
|
+
console.warn(`[access] ${e} — file ignored`);
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1245
|
+
for (const w of parsed.warnings)
|
|
1246
|
+
console.warn(`[access] ${w}`);
|
|
1247
|
+
if (out.has(parsed.file.dir)) {
|
|
1248
|
+
console.warn(`[access] ${rel}: duplicate access.md for directory '${parsed.file.dir}' — keeping the first one seen`);
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
out.set(parsed.file.dir, parsed.file);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
async repoDir(workspaceId) {
|
|
1256
|
+
const wsDir = await this.workspaceService.getWorkspacePath(workspaceId);
|
|
1257
|
+
return path.join(wsDir, this.kbDirName);
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Read the access verbs a node file declares in its own frontmatter from the
|
|
1261
|
+
* working tree. Returns null when the file has no per-file access config
|
|
1262
|
+
* (the common case). `access.md` and `roles.yaml` are skipped — `access.md`
|
|
1263
|
+
* frontmatter governs its directory (handled by the dir chain), not itself,
|
|
1264
|
+
* and `roles.yaml` is not a node.
|
|
1265
|
+
*/
|
|
1266
|
+
async readOwnEntries(repoDir, relativePath) {
|
|
1267
|
+
if (isAccessMdPath(relativePath) || relativePath === 'roles.yaml')
|
|
1268
|
+
return null;
|
|
1269
|
+
let text;
|
|
1270
|
+
try {
|
|
1271
|
+
text = await fs.readFile(path.join(repoDir, relativePath), 'utf-8');
|
|
1272
|
+
}
|
|
1273
|
+
catch {
|
|
1274
|
+
return null;
|
|
1275
|
+
}
|
|
1276
|
+
return parseOwnAccessEntries(text);
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Same as `readOwnEntries` but reads the file at a specific git ref. The ref
|
|
1280
|
+
* MUST be the same one the dir model was loaded at — otherwise a user could
|
|
1281
|
+
* grant themselves rights by editing a file's frontmatter in a branch the
|
|
1282
|
+
* gate isn't reading from.
|
|
1283
|
+
*/
|
|
1284
|
+
async readOwnEntriesAtRef(repoDir, ref, relativePath) {
|
|
1285
|
+
if (isAccessMdPath(relativePath) || relativePath === 'roles.yaml')
|
|
1286
|
+
return null;
|
|
1287
|
+
const text = await this.showAtRef(repoDir, ref, relativePath);
|
|
1288
|
+
if (text === null)
|
|
1289
|
+
return null;
|
|
1290
|
+
return parseOwnAccessEntries(text);
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Batched `readOwnEntriesAtRef`: ONE `git cat-file --batch` process reads
|
|
1294
|
+
* every path's blob at `ref`, instead of one `git show` SPAWN per path —
|
|
1295
|
+
* the difference between minutes and sub-second when a change request
|
|
1296
|
+
* touches hundreds of files. Paths missing at the ref, non-blob paths, and
|
|
1297
|
+
* `access.md`/`roles.yaml` (which carry no own-entries) resolve to null,
|
|
1298
|
+
* mirroring the single-path helper.
|
|
1299
|
+
*/
|
|
1300
|
+
async readOwnEntriesAtRefBatch(repoDir, ref, relativePaths) {
|
|
1301
|
+
const result = new Map();
|
|
1302
|
+
const wanted = [];
|
|
1303
|
+
for (const p of relativePaths) {
|
|
1304
|
+
if (isAccessMdPath(p) || p === 'roles.yaml')
|
|
1305
|
+
result.set(p, null);
|
|
1306
|
+
else if (!wanted.includes(p))
|
|
1307
|
+
wanted.push(p);
|
|
1308
|
+
}
|
|
1309
|
+
if (wanted.length === 0)
|
|
1310
|
+
return result;
|
|
1311
|
+
const texts = await catFileBatch(repoDir, wanted.map((p) => `${ref}:${p}`));
|
|
1312
|
+
wanted.forEach((p, i) => {
|
|
1313
|
+
const text = texts[i];
|
|
1314
|
+
result.set(p, text === null ? null : parseOwnAccessEntries(text));
|
|
1315
|
+
});
|
|
1316
|
+
return result;
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Resolve a candidate ref to one git accepts. Falls back to `origin/<ref>`
|
|
1320
|
+
* for short branch names, matching the heuristic in
|
|
1321
|
+
* `WorkspaceService.readFileAtRef`.
|
|
1322
|
+
*/
|
|
1323
|
+
refCandidates(ref) {
|
|
1324
|
+
if (ref.startsWith('origin/') || ref.startsWith('refs/'))
|
|
1325
|
+
return [ref];
|
|
1326
|
+
return [ref, `origin/${ref}`];
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Read a file's content at a specific ref via `git show <ref>:<path>`.
|
|
1330
|
+
* Returns null when the file is absent on that ref (or the ref doesn't
|
|
1331
|
+
* resolve).
|
|
1332
|
+
*/
|
|
1333
|
+
async showAtRef(repoDir, ref, relativePath) {
|
|
1334
|
+
try {
|
|
1335
|
+
const { stdout } = await execFileAsync('git', ['-C', repoDir, 'show', `${ref}:${relativePath}`], { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 });
|
|
1336
|
+
return stdout;
|
|
1337
|
+
}
|
|
1338
|
+
catch {
|
|
1339
|
+
return null;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* List every `access.md` path that exists at any depth as of `ref`.
|
|
1344
|
+
* The access tree is structure-agnostic: any `access.md` participates
|
|
1345
|
+
* regardless of where it sits. Uses `git ls-tree -r --name-only`.
|
|
1346
|
+
*/
|
|
1347
|
+
async listAccessFilesAtRef(repoDir, ref) {
|
|
1348
|
+
try {
|
|
1349
|
+
const { stdout } = await execFileAsync('git', ['-C', repoDir, 'ls-tree', '-r', '--name-only', ref], { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 });
|
|
1350
|
+
const out = [];
|
|
1351
|
+
for (const line of stdout.split('\n')) {
|
|
1352
|
+
const p = line.trim();
|
|
1353
|
+
if (!p)
|
|
1354
|
+
continue;
|
|
1355
|
+
if (p === 'access.md' || p.endsWith('/access.md'))
|
|
1356
|
+
out.push(p);
|
|
1357
|
+
}
|
|
1358
|
+
return out;
|
|
1359
|
+
}
|
|
1360
|
+
catch {
|
|
1361
|
+
return [];
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Build the access model from the access tree as it exists on a specific
|
|
1366
|
+
* ref. The ref is the authoritative source — typically `origin/<base>`
|
|
1367
|
+
* for PR-time decisions, so a malicious user can't grant themselves
|
|
1368
|
+
* approval rights by editing access.md in their working tree (or even on
|
|
1369
|
+
* an unmerged branch).
|
|
1370
|
+
*
|
|
1371
|
+
* Returns null when the ref doesn't carry usable access config (no
|
|
1372
|
+
* `roles.yaml`, or roles.yaml is malformed). Callers treat null as
|
|
1373
|
+
* "can't determine eligibility, deny." That includes the bootstrap case
|
|
1374
|
+
* where access.md hasn't been merged to the target branch yet — admins
|
|
1375
|
+
* must commit the initial config directly to the protected branch before
|
|
1376
|
+
* the gate becomes useful. NEVER fall back to the working tree here: a
|
|
1377
|
+
* working-tree fallback lets anyone with edit access to access.md grant
|
|
1378
|
+
* themselves approval rights.
|
|
1379
|
+
*/
|
|
1380
|
+
async loadModelAtRef(workspaceId, ref) {
|
|
1381
|
+
const repoDir = await this.repoDir(workspaceId);
|
|
1382
|
+
// Refresh remote-tracking refs so a PR branch we've never personally
|
|
1383
|
+
// checked out is resolvable. Best-effort — a fetch failure shouldn't
|
|
1384
|
+
// block the lookup if the ref already exists locally.
|
|
1385
|
+
await this.workspaceService.ensureRemotesFetched(workspaceId).catch(() => undefined);
|
|
1386
|
+
let rolesYaml = null;
|
|
1387
|
+
let resolvedRef = null;
|
|
1388
|
+
for (const candidate of this.refCandidates(ref)) {
|
|
1389
|
+
const text = await this.showAtRef(repoDir, candidate, 'roles.yaml');
|
|
1390
|
+
if (text !== null) {
|
|
1391
|
+
rolesYaml = text;
|
|
1392
|
+
resolvedRef = candidate;
|
|
1393
|
+
break;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
if (!rolesYaml || !resolvedRef)
|
|
1397
|
+
return null;
|
|
1398
|
+
const rolesParsed = parseRolesYaml(rolesYaml);
|
|
1399
|
+
if (!rolesParsed.ok)
|
|
1400
|
+
return null;
|
|
1401
|
+
const accessFiles = new Map();
|
|
1402
|
+
const accessPaths = await this.listAccessFilesAtRef(repoDir, resolvedRef);
|
|
1403
|
+
for (const p of accessPaths) {
|
|
1404
|
+
const text = await this.showAtRef(repoDir, resolvedRef, p);
|
|
1405
|
+
if (text === null)
|
|
1406
|
+
continue;
|
|
1407
|
+
const parsed = parseAccessFile(text, p);
|
|
1408
|
+
if (!parsed.ok)
|
|
1409
|
+
continue;
|
|
1410
|
+
// Last-writer-wins on dir collisions; the at-ref path should never
|
|
1411
|
+
// collide in a healthy tree because the working-tree validator forbids
|
|
1412
|
+
// duplicates, but be defensive.
|
|
1413
|
+
accessFiles.set(parsed.file.dir, parsed.file);
|
|
1414
|
+
}
|
|
1415
|
+
// Mirror `loadModel`: drop entries whose role ref is unknown to roles.yaml,
|
|
1416
|
+
// while preserving the built-in `everyone` role.
|
|
1417
|
+
for (const [, file] of accessFiles) {
|
|
1418
|
+
for (const verb of KNOWN_VERBS) {
|
|
1419
|
+
file.entries[verb] = file.entries[verb].filter((entry) => entry.kind !== 'role' || roleKnown(rolesParsed.index, entry.role));
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
return {
|
|
1423
|
+
model: {
|
|
1424
|
+
roles: rolesParsed.index,
|
|
1425
|
+
accessFilesByDir: accessFiles,
|
|
1426
|
+
},
|
|
1427
|
+
resolvedRef,
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
//# sourceMappingURL=access-control.service.js.map
|