@bevel-software/platform-core-backend 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-guide/access-control.md +49 -4
- package/agent-guide/html-views.md +76 -2
- package/dist/core/catalog-revision.js +3 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +2 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +7 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +16 -3
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +1 -5
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access-model/index.d.ts +14 -0
- package/dist/modules/access-model/index.d.ts.map +1 -0
- package/dist/modules/access-model/index.js +13 -0
- package/dist/modules/access-model/index.js.map +1 -0
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
- package/dist/modules/audit/agent-audit.service.js +66 -6
- package/dist/modules/audit/agent-audit.service.js.map +1 -1
- package/dist/modules/audit/audit.contract.d.ts +52 -8
- package/dist/modules/audit/audit.contract.d.ts.map +1 -1
- package/dist/modules/audit/audit.contract.js +37 -1
- package/dist/modules/audit/audit.contract.js.map +1 -1
- package/dist/modules/audit/audit.routes.d.ts +1 -1
- package/dist/modules/audit/audit.routes.d.ts.map +1 -1
- package/dist/modules/audit/audit.routes.js +15 -13
- package/dist/modules/audit/audit.routes.js.map +1 -1
- package/dist/modules/audit/request-audit.d.ts +21 -10
- package/dist/modules/audit/request-audit.d.ts.map +1 -1
- package/dist/modules/audit/request-audit.js +26 -10
- package/dist/modules/audit/request-audit.js.map +1 -1
- package/dist/modules/auth/account-hooks.d.ts +4 -6
- package/dist/modules/auth/account-hooks.d.ts.map +1 -1
- package/dist/modules/auth/account-hooks.js +7 -17
- package/dist/modules/auth/account-hooks.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +9 -8
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +19 -18
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +43 -4
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +31 -7
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/kb-fs/index.d.ts +1 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -1
- package/dist/modules/kb-fs/index.js +1 -0
- package/dist/modules/kb-fs/index.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +3 -12
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/mutex.d.ts +11 -0
- package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
- package/dist/modules/kb-fs/mutex.js +14 -0
- package/dist/modules/kb-fs/mutex.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +5 -0
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +67 -0
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +107 -27
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +257 -50
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +28 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +166 -39
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
- package/dist/modules/mcp/request-context.d.ts +25 -0
- package/dist/modules/mcp/request-context.d.ts.map +1 -0
- package/dist/modules/mcp/request-context.js +11 -0
- package/dist/modules/mcp/request-context.js.map +1 -0
- package/dist/modules/mcp/shared-definitions.d.ts +56 -0
- package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
- package/dist/modules/mcp/shared-definitions.js +117 -0
- package/dist/modules/mcp/shared-definitions.js.map +1 -0
- package/dist/modules/mcp/surface-cache.d.ts +70 -0
- package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
- package/dist/modules/mcp/surface-cache.js +104 -0
- package/dist/modules/mcp/surface-cache.js.map +1 -0
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +33 -12
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.d.ts +8 -0
- package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +10 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.js +2 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +458 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/change-request-hooks.d.ts +78 -0
- package/dist/modules/workflow/change-request-hooks.d.ts.map +1 -0
- package/dist/modules/workflow/change-request-hooks.js +15 -0
- package/dist/modules/workflow/change-request-hooks.js.map +1 -0
- package/dist/modules/workflow/git/git.service.d.ts +113 -4
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +470 -106
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +10 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +9 -0
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/read-on-branch.d.ts +35 -0
- package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
- package/dist/modules/workflow/read-on-branch.js +58 -0
- package/dist/modules/workflow/read-on-branch.js.map +1 -0
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +22 -41
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +124 -24
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +326 -48
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/index.d.ts +2 -0
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +4 -0
- package/dist/modules/workspace/index.js.map +1 -1
- package/dist/shared/git.contract.d.ts +7 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js +9 -0
- package/dist/shared/git.contract.js.map +1 -1
- package/dist/shared/listener-seam.d.ts +34 -0
- package/dist/shared/listener-seam.d.ts.map +1 -0
- package/dist/shared/listener-seam.js +42 -0
- package/dist/shared/listener-seam.js.map +1 -0
- package/dist/shared/no-link-below.d.ts +10 -0
- package/dist/shared/no-link-below.d.ts.map +1 -0
- package/dist/shared/no-link-below.js +30 -0
- package/dist/shared/no-link-below.js.map +1 -0
- package/migrations/0018_agent_events_page.sql +11 -0
- package/migrations/0019_oauth_token_rotated_at.sql +7 -0
- package/migrations/meta/0018_snapshot.json +2429 -0
- package/migrations/meta/0019_snapshot.json +2435 -0
- package/migrations/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/core/catalog-revision.ts +3 -3
- package/src/core/create-core-server.ts +2 -1
- package/src/core/create-core-services.ts +21 -2
- package/src/index.ts +7 -0
- package/src/modules/access/access-control.service.ts +1 -6
- package/src/modules/access-model/index.ts +13 -0
- package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
- package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
- package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
- package/src/modules/audit/agent-audit.service.ts +66 -6
- package/src/modules/audit/audit.contract.ts +62 -10
- package/src/modules/audit/audit.routes.ts +14 -12
- package/src/modules/audit/request-audit.ts +28 -13
- package/src/modules/auth/__tests__/account-deactivation.test.ts +38 -2
- package/src/modules/auth/account-hooks.ts +13 -21
- package/src/modules/auth/auth.service.ts +19 -18
- package/src/modules/database/core-schema.ts +31 -7
- package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
- package/src/modules/kb-fs/index.ts +1 -0
- package/src/modules/kb-fs/locking-filesystem.ts +3 -12
- package/src/modules/kb-fs/mutex.ts +16 -0
- package/src/modules/mcp/__tests__/bevel-oauth-provider.pg.test.ts +279 -0
- package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +190 -15
- package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
- package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
- package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
- package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
- package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
- package/src/modules/mcp/mcp.routes.ts +68 -0
- package/src/modules/mcp/mcp.service.ts +315 -51
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +214 -48
- package/src/modules/mcp/request-context.ts +34 -0
- package/src/modules/mcp/shared-definitions.ts +121 -0
- package/src/modules/mcp/surface-cache.ts +154 -0
- package/src/modules/tool-helpers/tool-context.ts +37 -18
- package/src/modules/tool-helpers/tool.contract.ts +8 -0
- package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +1 -1
- package/src/modules/workflow/__tests__/change-request-hooks.test.ts +62 -0
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
- package/src/modules/workflow/__tests__/workflow.routes.open-change-request.test.ts +79 -0
- package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +9 -1
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +508 -5
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +474 -4
- package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
- package/src/modules/workflow/agent-tools/change-request-summary.ts +10 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +468 -1
- package/src/modules/workflow/change-request-hooks.ts +96 -0
- package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
- package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
- package/src/modules/workflow/git/git.service.ts +537 -106
- package/src/modules/workflow/git/pull-request.service.ts +10 -0
- package/src/modules/workflow/read-on-branch.ts +79 -0
- package/src/modules/workflow/workflow.routes.ts +21 -39
- package/src/modules/workflow/workflow.service.ts +390 -55
- package/src/modules/workspace/index.ts +11 -0
- package/src/shared/__tests__/no-link-below.test.ts +45 -0
- package/src/shared/git.contract.ts +10 -0
- package/src/shared/listener-seam.ts +52 -0
- package/src/shared/no-link-below.ts +29 -0
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { LocalFilesystem } from '@mastra/core/workspace';
|
|
4
4
|
import type { IWorkflowService } from '@bevel-software/platform-shared';
|
|
5
5
|
import { LockingFilesystem } from '../kb-fs/locking-filesystem.js';
|
|
6
|
+
import type { WriteValidator } from '../kb-fs/index.js';
|
|
6
7
|
import { ReadOnlyFilesystem } from '../kb-fs/read-only-filesystem.js';
|
|
7
8
|
import {
|
|
8
9
|
makeAgentRolesYamlWriteValidator,
|
|
@@ -76,6 +77,36 @@ export function createToolContextResolver(deps: ToolContextDeps): ResolveToolCon
|
|
|
76
77
|
if (!user) throw new ToolError('Your account is no longer available.', 401);
|
|
77
78
|
|
|
78
79
|
const { loadActiveGroups } = deps;
|
|
80
|
+
const validatorCache = new Map<string, WriteValidator>();
|
|
81
|
+
/**
|
|
82
|
+
* The agent's pre-disk `roles.yaml` gate for `branch`'s workspace
|
|
83
|
+
* (cached): refuses a write that would leave the file unparseable (an
|
|
84
|
+
* app-wide admin lockout), that creates a role, or that adds a
|
|
85
|
+
* `- group:` entry naming no group — agents manage membership; roles are
|
|
86
|
+
* pre-set. ONE gate for every agent write to the workspace: the file
|
|
87
|
+
* tools take it before a byte lands, and `update_branch` takes it for a
|
|
88
|
+
* resolution's content before the merge.
|
|
89
|
+
*/
|
|
90
|
+
const getWriteValidator = async (branch: string): Promise<WriteValidator> => {
|
|
91
|
+
assertBranchProvided(branch);
|
|
92
|
+
const cached = validatorCache.get(branch);
|
|
93
|
+
if (cached) return cached;
|
|
94
|
+
const ws = await deps.workspaceService.getOrCreateForUser(user, branch);
|
|
95
|
+
const basePath = await deps.workspaceService.getWorkspacePath(ws.id);
|
|
96
|
+
const validator = makeAgentRolesYamlWriteValidator(
|
|
97
|
+
deps.kbDirName,
|
|
98
|
+
() => readTextIfExists(path.join(basePath, deps.kbDirName, ROLES_YAML_BASENAME)),
|
|
99
|
+
loadActiveGroups &&
|
|
100
|
+
(async () => {
|
|
101
|
+
// A source that exists but cannot be read leaves group entries
|
|
102
|
+
// unchecked rather than refusing every one.
|
|
103
|
+
const active = await loadActiveGroups((file) => readTextIfExists(path.join(basePath, deps.kbDirName, file)));
|
|
104
|
+
return active.health.ok ? active : null;
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
validatorCache.set(branch, validator);
|
|
108
|
+
return validator;
|
|
109
|
+
};
|
|
79
110
|
const fsCache = new Map<string, LocalFilesystem>();
|
|
80
111
|
const getFilesystem = async (branch: string): Promise<LocalFilesystem> => {
|
|
81
112
|
// THE choke point: every knowledge-base tool resolves its workspace
|
|
@@ -94,6 +125,7 @@ export function createToolContextResolver(deps: ToolContextDeps): ResolveToolCon
|
|
|
94
125
|
assertBranchProvided(branch);
|
|
95
126
|
const cached = fsCache.get(branch);
|
|
96
127
|
if (cached) return cached;
|
|
128
|
+
// (The write validator below is built through `getWriteValidator`.)
|
|
97
129
|
const ws = await deps.workspaceService.getOrCreateForUser(user, branch);
|
|
98
130
|
const workspaceId = ws.id;
|
|
99
131
|
const basePath = await deps.workspaceService.getWorkspacePath(workspaceId);
|
|
@@ -107,24 +139,10 @@ export function createToolContextResolver(deps: ToolContextDeps): ResolveToolCon
|
|
|
107
139
|
branch: branchForWorkspaceId(workspaceId),
|
|
108
140
|
user,
|
|
109
141
|
kbDirName: deps.kbDirName,
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
// Agents manage membership; roles are pre-set.
|
|
115
|
-
validateWrite: makeAgentRolesYamlWriteValidator(
|
|
116
|
-
deps.kbDirName,
|
|
117
|
-
() => readTextIfExists(path.join(basePath, deps.kbDirName, ROLES_YAML_BASENAME)),
|
|
118
|
-
loadActiveGroups &&
|
|
119
|
-
(async () => {
|
|
120
|
-
// A source that exists but cannot be read leaves group
|
|
121
|
-
// entries unchecked rather than refusing every one.
|
|
122
|
-
const active = await loadActiveGroups((file) =>
|
|
123
|
-
readTextIfExists(path.join(basePath, deps.kbDirName, file)),
|
|
124
|
-
);
|
|
125
|
-
return active.health.ok ? active : null;
|
|
126
|
-
}),
|
|
127
|
-
),
|
|
142
|
+
// The agent's pre-disk roles.yaml gate (`getWriteValidator`):
|
|
143
|
+
// the file tools and `update_branch`'s resolutions take the
|
|
144
|
+
// same one.
|
|
145
|
+
validateWrite: await getWriteValidator(branch),
|
|
128
146
|
creatorAccess: deps.creatorAccess,
|
|
129
147
|
},
|
|
130
148
|
)
|
|
@@ -155,6 +173,7 @@ export function createToolContextResolver(deps: ToolContextDeps): ResolveToolCon
|
|
|
155
173
|
workflowService: deps.workflowService,
|
|
156
174
|
events: deps.events,
|
|
157
175
|
getFilesystem,
|
|
176
|
+
getWriteValidator,
|
|
158
177
|
};
|
|
159
178
|
};
|
|
160
179
|
}
|
|
@@ -2,6 +2,7 @@ import type { AuthUser, IWorkflowService } from '@bevel-software/platform-shared
|
|
|
2
2
|
import type { LocalFilesystem } from '@mastra/core/workspace';
|
|
3
3
|
import type { WorkspaceService } from '../workspace/workspace.service.js';
|
|
4
4
|
import type { WorkflowEventBus } from '../workflow/event-bus.js';
|
|
5
|
+
import type { WriteValidator } from '../kb-fs/index.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The hosting/execution contract a module works against when it lets `toolHelpers`
|
|
@@ -63,6 +64,13 @@ export interface ToolContext {
|
|
|
63
64
|
events: WorkflowEventBus;
|
|
64
65
|
/** Build the lock-aware filesystem for `branch`'s workspace (cached). The tool supplies `branch` from its own input. */
|
|
65
66
|
getFilesystem(branch: string): Promise<LocalFilesystem>;
|
|
67
|
+
/**
|
|
68
|
+
* The agent's pre-disk write gate for `branch`'s workspace (cached) — the
|
|
69
|
+
* one the filesystem above runs before a byte lands (`roles.yaml`: must
|
|
70
|
+
* parse, no new role, no unknown group). A tool that writes content by
|
|
71
|
+
* another road than that filesystem (a merge resolution) takes it too.
|
|
72
|
+
*/
|
|
73
|
+
getWriteValidator(branch: string): Promise<WriteValidator>;
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
/**
|
|
@@ -59,7 +59,7 @@ const CORE_SERVER_SOURCE = new URL('../../../core/create-core-server.ts', import
|
|
|
59
59
|
const MODULES: ReadonlyArray<{ name: string; register: (registry: ToolRegistry) => void }> = [
|
|
60
60
|
{
|
|
61
61
|
name: 'registerWorkflowTools',
|
|
62
|
-
register: (registry) => registerWorkflowTools(registry, express.Router(), pass, handler, testKbContext(), async () => true),
|
|
62
|
+
register: (registry) => registerWorkflowTools(registry, express.Router(), pass, handler, testKbContext(), async () => true, { canRead: async () => true, canReadAtRef: async () => true, canReadBatch: async () => new Map(), canReadBatchAtRef: async () => new Map(), canWrite: async () => true } as never, { recoveryBotEmail: 'recovery@x', hooks: { runAgentRead: async () => undefined }, notes: { onChange: () => undefined } } as never),
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
name: 'registerChangeRequestReadTools',
|
|
@@ -117,7 +117,7 @@ async function hexisTools(skills: Parameters<typeof registerSkillsTools>[4] = em
|
|
|
117
117
|
undefined,
|
|
118
118
|
new WorkerPatternMatcher(),
|
|
119
119
|
);
|
|
120
|
-
registerWorkflowTools(registry, router, toolAuth, toolHandler, kb, async () => true);
|
|
120
|
+
registerWorkflowTools(registry, router, toolAuth, toolHandler, kb, async () => true, { canRead: async () => true, canReadAtRef: async () => true, canReadBatch: async () => new Map(), canReadBatchAtRef: async () => new Map(), canWrite: async () => true } as never, { recoveryBotEmail: 'recovery@x', hooks: { runAgentRead: async () => undefined }, notes: { onChange: () => undefined } } as never);
|
|
121
121
|
registerPluginsTools(registry, kb);
|
|
122
122
|
registerSkillsTools(registry, router, toolAuth, toolHandler, skills);
|
|
123
123
|
registerToolManualsTools(registry, router, toolAuth, toolHandler, emptyManuals, {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { ChangeRequestHooks, type ChangeRequestOpenedEvent } from '../change-request-hooks.js';
|
|
3
|
+
|
|
4
|
+
const EVENT: ChangeRequestOpenedEvent = {
|
|
5
|
+
number: 12,
|
|
6
|
+
title: 'Tidy the glossary',
|
|
7
|
+
sourceBranch: 'ali/x',
|
|
8
|
+
targetBranch: 'main',
|
|
9
|
+
openedBy: { id: 'u-ali', email: 'ali@acme.test', name: 'Ali' },
|
|
10
|
+
approvers: [{ id: 'u-dana', email: 'dana@acme.test', name: 'Dana' }],
|
|
11
|
+
unresolvedApprovers: 0,
|
|
12
|
+
pushPending: false,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe('ChangeRequestHooks: a change request was opened', () => {
|
|
16
|
+
it('notifies nobody when nothing is registered, and says so', async () => {
|
|
17
|
+
expect(await new ChangeRequestHooks().runChangeRequestOpened(EVENT)).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('runs every listener in registration order, with the event, and collects the channels that reached the approvers', async () => {
|
|
21
|
+
const hooks = new ChangeRequestHooks();
|
|
22
|
+
const seen: string[] = [];
|
|
23
|
+
hooks.onChangeRequestOpened(async (event) => {
|
|
24
|
+
seen.push(`mail:#${event.number}:${event.approvers.map((a) => a.email).join(',')}`);
|
|
25
|
+
return 'email';
|
|
26
|
+
});
|
|
27
|
+
hooks.onChangeRequestOpened(async () => {
|
|
28
|
+
seen.push('chat');
|
|
29
|
+
return null;
|
|
30
|
+
});
|
|
31
|
+
hooks.onChangeRequestOpened(async () => {
|
|
32
|
+
seen.push('sms');
|
|
33
|
+
return 'sms';
|
|
34
|
+
});
|
|
35
|
+
expect(await hooks.runChangeRequestOpened(EVENT)).toEqual(['email', 'sms']);
|
|
36
|
+
expect(seen).toEqual(['mail:#12:dana@acme.test', 'chat', 'sms']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('a listener that throws counts as not having notified, and stops nothing', async () => {
|
|
40
|
+
const hooks = new ChangeRequestHooks();
|
|
41
|
+
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
42
|
+
try {
|
|
43
|
+
hooks.onChangeRequestOpened(async () => {
|
|
44
|
+
throw new Error('smtp down');
|
|
45
|
+
});
|
|
46
|
+
hooks.onChangeRequestOpened(async () => 'email');
|
|
47
|
+
expect(await hooks.runChangeRequestOpened(EVENT)).toEqual(['email']);
|
|
48
|
+
// Logged, so a failing mailer is diagnosable from the server's log.
|
|
49
|
+
expect(error).toHaveBeenCalledTimes(1);
|
|
50
|
+
expect(String(error.mock.calls[0]![0])).toContain('a change-request-opened listener failed');
|
|
51
|
+
} finally {
|
|
52
|
+
error.mockRestore();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('an empty channel name is no channel: it counts as not having notified', async () => {
|
|
57
|
+
const hooks = new ChangeRequestHooks();
|
|
58
|
+
hooks.onChangeRequestOpened(async () => '');
|
|
59
|
+
hooks.onChangeRequestOpened(async () => 'email');
|
|
60
|
+
expect(await hooks.runChangeRequestOpened(EVENT)).toEqual(['email']);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -41,7 +41,12 @@ async function makeHarness(): Promise<Harness> {
|
|
|
41
41
|
const accessControl = { canRead, canReadAtRef } as unknown as IAccessControl;
|
|
42
42
|
const workflow = {
|
|
43
43
|
listChangesForFile: vi.fn(async () => []),
|
|
44
|
-
compareFile: vi.fn(async () =>
|
|
44
|
+
compareFile: vi.fn(async (_w: string, _p: string, from: string, to: string, authorize?: (s: { from: string[]; to: string[] }) => Promise<void>) => {
|
|
45
|
+
// Each branch resolves to its served commit and, for `b`, a second one (a
|
|
46
|
+
// local head past the published tip): the verdict is asked at both.
|
|
47
|
+
await authorize?.({ from: [`sha-of-${from}`], to: [`sha-of-${to}`, `other-sha-of-${to}`] });
|
|
48
|
+
return { diff: '', from: `sha-of-${from}`, to: `sha-of-${to}` };
|
|
49
|
+
}),
|
|
45
50
|
showFileAtChange: vi.fn(async () => ''),
|
|
46
51
|
fileAtChange: vi.fn(async () => ({ baseline: null, current: 'x' })),
|
|
47
52
|
};
|
|
@@ -113,23 +118,29 @@ describe('history routes enforce the read model', () => {
|
|
|
113
118
|
expect(h.workflow.listChangesForFile).not.toHaveBeenCalled();
|
|
114
119
|
});
|
|
115
120
|
|
|
116
|
-
it('compare-file authorizes at
|
|
117
|
-
// The
|
|
118
|
-
//
|
|
121
|
+
it('compare-file authorizes at the two commits the diff was served from, never at a ref', async () => {
|
|
122
|
+
// The diff is made first, pinned to the commit each side was read at,
|
|
123
|
+
// and authorized at those before any of it is answered: a branch ref a
|
|
124
|
+
// fetch moves meanwhile cannot have swapped in content the verdict never
|
|
125
|
+
// saw.
|
|
119
126
|
h = await makeHarness();
|
|
120
127
|
const res = await get(`/compare-file?from=a&to=b&path=${OPEN}`);
|
|
121
128
|
expect(res.status).toBe(200);
|
|
122
129
|
const refs = h.canReadAtRef.mock.calls.map((c) => c[1]).sort();
|
|
123
|
-
expect(refs).toEqual(['
|
|
130
|
+
expect(refs).toEqual(['other-sha-of-b', 'sha-of-a', 'sha-of-b']);
|
|
124
131
|
|
|
125
|
-
// Any
|
|
126
|
-
|
|
127
|
-
|
|
132
|
+
// Any commit a side resolves to denying the read refuses the diff — the
|
|
133
|
+
// served one or the other (a grant at the served commit never outruns a
|
|
134
|
+
// denial at the branch's other tip) — disclosing none of it.
|
|
135
|
+
for (const denying of ['sha-of-a', 'sha-of-b', 'other-sha-of-b']) {
|
|
136
|
+
h.canReadAtRef.mockImplementation(async (_w: string, ref: string) => (ref === denying ? false : true));
|
|
137
|
+
const denied = await get(`/compare-file?from=a&to=b&path=${OPEN}`);
|
|
138
|
+
expect(denied.status, denying).toBe(403);
|
|
139
|
+
expect(await denied.json()).not.toHaveProperty('diff');
|
|
140
|
+
}
|
|
128
141
|
|
|
129
|
-
// A
|
|
130
|
-
h.canReadAtRef.mockImplementation(async (_w: string, ref: string) =>
|
|
131
|
-
ref.endsWith('b') ? null : true,
|
|
132
|
-
);
|
|
142
|
+
// A commit whose rules cannot be read grants nothing: refused.
|
|
143
|
+
h.canReadAtRef.mockImplementation(async (_w: string, ref: string) => (ref === 'sha-of-b' ? null : true));
|
|
133
144
|
expect((await get(`/compare-file?from=a&to=b&path=${OPEN}`)).status).toBe(403);
|
|
134
145
|
});
|
|
135
146
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Server } from 'node:http';
|
|
2
|
+
import type { AddressInfo } from 'node:net';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
5
|
+
import type { AuthUser, IWorkflowService } from '@bevel-software/platform-shared';
|
|
6
|
+
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
7
|
+
import type { AuthService } from '../../auth/auth.service.js';
|
|
8
|
+
import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
9
|
+
import { WorkflowEventBus } from '../event-bus.js';
|
|
10
|
+
import { createWorkflowRoutes } from '../workflow.routes.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `POST /api/workflow/change-requests` answers the opened request as the
|
|
14
|
+
* service hands it over, `notified` included: the channels the deployment's
|
|
15
|
+
* change-request listeners said reached the approvers, so the dialog can say
|
|
16
|
+
* "the reviewers were emailed" — or hand over the link when nobody was.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const ALICE: AuthUser = { id: '11111111-1111-4111-8111-111111111111', email: 'alice@example.com', name: 'Alice' };
|
|
20
|
+
|
|
21
|
+
async function serve(workflow: Partial<IWorkflowService>): Promise<{ server: Server; baseUrl: string }> {
|
|
22
|
+
const authService = { getUserById: vi.fn(async () => ALICE) } as unknown as AuthService;
|
|
23
|
+
const workspaceService = {
|
|
24
|
+
getOrCreateForBranch: vi.fn(async (branch: string) => ({ id: encodeURIComponent(branch) })),
|
|
25
|
+
} as unknown as WorkspaceService;
|
|
26
|
+
const app = express();
|
|
27
|
+
app.use(express.json());
|
|
28
|
+
app.use('/api', (req, _res, next) => {
|
|
29
|
+
(req as unknown as { userId: string }).userId = ALICE.id;
|
|
30
|
+
next();
|
|
31
|
+
});
|
|
32
|
+
app.use(
|
|
33
|
+
'/api',
|
|
34
|
+
createWorkflowRoutes(
|
|
35
|
+
workflow as IWorkflowService,
|
|
36
|
+
workspaceService,
|
|
37
|
+
authService,
|
|
38
|
+
new WorkflowEventBus(),
|
|
39
|
+
{} as unknown as IAccessControl,
|
|
40
|
+
'knowledge-base',
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
const server = await new Promise<Server>((resolve) => {
|
|
44
|
+
const s = app.listen(0, () => resolve(s));
|
|
45
|
+
});
|
|
46
|
+
return { server, baseUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}` };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('POST /workflow/change-requests answers who was notified', () => {
|
|
50
|
+
let server: Server | undefined;
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
if (server) await new Promise<void>((resolve, reject) => server!.close((e) => (e ? reject(e) : resolve())));
|
|
53
|
+
server = undefined;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it.each([
|
|
57
|
+
['the channels that reached the approvers', ['email']],
|
|
58
|
+
['nobody, truthfully', []],
|
|
59
|
+
])('carries `notified`: %s', async (_what, notified) => {
|
|
60
|
+
const openChangeRequest = vi.fn(async () => ({ number: 12, branch: 'alice/deal', base: 'main', notified }) as never);
|
|
61
|
+
let baseUrl: string;
|
|
62
|
+
({ server, baseUrl } = await serve({ openChangeRequest }));
|
|
63
|
+
|
|
64
|
+
const res = await fetch(`${baseUrl}/api/workflow/change-requests`, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'content-type': 'application/json' },
|
|
67
|
+
body: JSON.stringify({ sourceBranch: 'alice/deal', targetBranch: 'main', title: 'Deal' }),
|
|
68
|
+
});
|
|
69
|
+
expect(res.status).toBe(200);
|
|
70
|
+
expect(await res.json()).toEqual({ number: 12, branch: 'alice/deal', base: 'main', notified });
|
|
71
|
+
// The open runs in the SOURCE branch's workspace, by the caller.
|
|
72
|
+
expect(openChangeRequest).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(openChangeRequest).toHaveBeenCalledWith(
|
|
74
|
+
encodeURIComponent('alice/deal'),
|
|
75
|
+
ALICE,
|
|
76
|
+
{ sourceBranch: 'alice/deal', targetBranch: 'main', title: 'Deal', description: undefined },
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -115,6 +115,7 @@ interface Harness {
|
|
|
115
115
|
deleted: string[];
|
|
116
116
|
/** What the service announced to connected tabs. */
|
|
117
117
|
emitted: { kind: string; number?: number }[];
|
|
118
|
+
changeRequestHooks: { onChangeRequestOpened: ReturnType<typeof vi.fn>; runChangeRequestOpened: ReturnType<typeof vi.fn> };
|
|
118
119
|
hasAnyActive: MockInstance<FileLockService['hasAnyActive']>;
|
|
119
120
|
}
|
|
120
121
|
|
|
@@ -142,6 +143,7 @@ function harness(opts: {
|
|
|
142
143
|
}): Harness {
|
|
143
144
|
const deleted: string[] = [];
|
|
144
145
|
const emitted: { kind: string; number?: number }[] = [];
|
|
146
|
+
const changeRequestHooks = { onChangeRequestOpened: vi.fn(), runChangeRequestOpened: vi.fn(async () => []) };
|
|
145
147
|
const rows = opts.rows ?? [];
|
|
146
148
|
const branches = { ...(opts.branches ?? {}) };
|
|
147
149
|
const git = {
|
|
@@ -212,8 +214,11 @@ function harness(opts: {
|
|
|
212
214
|
testKbContext(),
|
|
213
215
|
openChangeGate(),
|
|
214
216
|
{ emit: (e: { kind: string; number?: number }) => void emitted.push(e) } as unknown as WorkflowEventBus,
|
|
217
|
+
undefined,
|
|
218
|
+
undefined,
|
|
219
|
+
changeRequestHooks,
|
|
215
220
|
);
|
|
216
|
-
return { svc, git, fetch, deleted, emitted, hasAnyActive };
|
|
221
|
+
return { svc, git, fetch, deleted, emitted, hasAnyActive, changeRequestHooks };
|
|
217
222
|
}
|
|
218
223
|
|
|
219
224
|
const merged = (number: number, source: string, sha: string | null): CrRow => ({
|
|
@@ -557,6 +562,9 @@ describe('a change request that proposes nothing never blocks a deletion', () =>
|
|
|
557
562
|
expect.objectContaining({ number: 21, source: 'ali/sync', target: DEFAULT }),
|
|
558
563
|
expect.objectContaining({ number: 22, source: 'ali/feature', target: 'ali/sync' }),
|
|
559
564
|
]);
|
|
565
|
+
// A reopened request was opened once already: the deployment's
|
|
566
|
+
// change-request-opened listeners are not told again.
|
|
567
|
+
expect(h.changeRequestHooks.runChangeRequestOpened).not.toHaveBeenCalled();
|
|
560
568
|
// The app's path, the same.
|
|
561
569
|
reopens.length = 0;
|
|
562
570
|
await expect(h.svc.deleteBranch(workspaceIdForBranch(DEFAULT), 'ali/sync', ADMIN)).rejects.toThrow(/stale info/);
|