@bevel-software/platform-core-backend 0.7.5 → 0.8.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.
Files changed (230) hide show
  1. package/LICENSE +202 -202
  2. package/THIRD-PARTY-NOTICES.md +428 -454
  3. package/dist/core/core-ports.d.ts +1 -1
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +51 -7
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +5 -3
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +30 -18
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/modules/access/access-control.interface.d.ts +8 -7
  12. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  13. package/dist/modules/access/access-control.service.d.ts +1 -1
  14. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  15. package/dist/modules/access/access-control.service.js +6 -6
  16. package/dist/modules/access/access-control.service.js.map +1 -1
  17. package/dist/modules/access/access-declarations.d.ts +5 -5
  18. package/dist/modules/access/access-declarations.js +3 -3
  19. package/dist/modules/access/access-mutation.service.d.ts +3 -3
  20. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  21. package/dist/modules/access/access-mutation.service.js +3 -3
  22. package/dist/modules/access/access-mutation.service.js.map +1 -1
  23. package/dist/modules/access/access-splice.js +4 -4
  24. package/dist/modules/access/access-splice.js.map +1 -1
  25. package/dist/modules/access/access.routes.js +19 -19
  26. package/dist/modules/access/access.routes.js.map +1 -1
  27. package/dist/modules/access/creator-access.d.ts +2 -2
  28. package/dist/modules/access/creator-access.js +5 -5
  29. package/dist/modules/access/creator-access.js.map +1 -1
  30. package/dist/modules/access/roles-admin.service.d.ts +18 -4
  31. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  32. package/dist/modules/access/roles-admin.service.js +14 -4
  33. package/dist/modules/access/roles-admin.service.js.map +1 -1
  34. package/dist/modules/code-mode/code-mode-names.d.ts +5 -13
  35. package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -1
  36. package/dist/modules/code-mode/code-mode-names.js +5 -27
  37. package/dist/modules/code-mode/code-mode-names.js.map +1 -1
  38. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  39. package/dist/modules/code-mode/code-mode.tool.js +29 -7
  40. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  41. package/dist/modules/mcp/mcp.service.d.ts +11 -52
  42. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  43. package/dist/modules/mcp/mcp.service.js +33 -395
  44. package/dist/modules/mcp/mcp.service.js.map +1 -1
  45. package/dist/modules/plugins/index.d.ts +7 -0
  46. package/dist/modules/plugins/index.d.ts.map +1 -0
  47. package/dist/modules/plugins/index.js +6 -0
  48. package/dist/modules/plugins/index.js.map +1 -0
  49. package/dist/modules/plugins/join-proposals.d.ts +53 -0
  50. package/dist/modules/plugins/join-proposals.d.ts.map +1 -0
  51. package/dist/modules/plugins/join-proposals.js +67 -0
  52. package/dist/modules/plugins/join-proposals.js.map +1 -0
  53. package/dist/modules/plugins/join-requests.service.d.ts +81 -0
  54. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -0
  55. package/dist/modules/plugins/join-requests.service.js +135 -0
  56. package/dist/modules/plugins/join-requests.service.js.map +1 -0
  57. package/dist/modules/plugins/plugin-provision.service.d.ts +134 -0
  58. package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -0
  59. package/dist/modules/plugins/plugin-provision.service.js +344 -0
  60. package/dist/modules/plugins/plugin-provision.service.js.map +1 -0
  61. package/dist/modules/plugins/plugins.contract.d.ts +106 -0
  62. package/dist/modules/plugins/plugins.contract.d.ts.map +1 -0
  63. package/dist/modules/plugins/plugins.contract.js +36 -0
  64. package/dist/modules/plugins/plugins.contract.js.map +1 -0
  65. package/dist/modules/plugins/plugins.routes.d.ts +42 -0
  66. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -0
  67. package/dist/modules/plugins/plugins.routes.js +379 -0
  68. package/dist/modules/plugins/plugins.routes.js.map +1 -0
  69. package/dist/modules/plugins/plugins.service.d.ts +60 -0
  70. package/dist/modules/plugins/plugins.service.d.ts.map +1 -0
  71. package/dist/modules/plugins/plugins.service.js +172 -0
  72. package/dist/modules/plugins/plugins.service.js.map +1 -0
  73. package/dist/modules/skills/pending-skills.service.d.ts +2 -2
  74. package/dist/modules/skills/pending-skills.service.js +7 -7
  75. package/dist/modules/skills/pending-skills.service.js.map +1 -1
  76. package/dist/modules/skills/skills.contract.d.ts +4 -4
  77. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  78. package/dist/modules/skills/skills.contract.js +1 -1
  79. package/dist/modules/skills/skills.service.js +5 -5
  80. package/dist/modules/skills/skills.service.js.map +1 -1
  81. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +65 -0
  82. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -0
  83. package/dist/modules/tool-manuals/mcp-json-discovery.js +276 -0
  84. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -0
  85. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +92 -0
  86. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -0
  87. package/dist/modules/tool-manuals/mcp-server-edit.service.js +328 -0
  88. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -0
  89. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +38 -12
  90. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  91. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  92. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +13 -2
  93. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  94. package/dist/modules/tool-manuals/tool-manuals.routes.js +233 -2
  95. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  96. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  97. package/dist/modules/tool-manuals/tool-manuals.service.js +74 -37
  98. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  99. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -3
  100. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  101. package/dist/modules/workflow/git/git.service.js +2 -2
  102. package/dist/modules/workflow/git/git.service.js.map +1 -1
  103. package/dist/modules/workspace/kb-seed.service.d.ts +2 -2
  104. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  105. package/dist/modules/workspace/kb-seed.service.js +43 -10
  106. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  107. package/dist/modules/workspace/plugins-migration.d.ts +50 -0
  108. package/dist/modules/workspace/plugins-migration.d.ts.map +1 -0
  109. package/dist/modules/workspace/plugins-migration.js +379 -0
  110. package/dist/modules/workspace/plugins-migration.js.map +1 -0
  111. package/dist/modules/workspace/workspace.routes.js +3 -3
  112. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  113. package/dist/modules/workspace/workspace.service.d.ts +26 -0
  114. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  115. package/dist/modules/workspace/workspace.service.js +83 -12
  116. package/dist/modules/workspace/workspace.service.js.map +1 -1
  117. package/dist/shared/kb-layout.test.js +3 -3
  118. package/dist/shared/kb-layout.test.js.map +1 -1
  119. package/dist/shared/utcp-namespace.d.ts +6 -27
  120. package/dist/shared/utcp-namespace.d.ts.map +1 -1
  121. package/dist/shared/utcp-namespace.js +6 -63
  122. package/dist/shared/utcp-namespace.js.map +1 -1
  123. package/dist/shared/variable-refs.d.ts +42 -0
  124. package/dist/shared/variable-refs.d.ts.map +1 -0
  125. package/dist/shared/variable-refs.js +60 -0
  126. package/dist/shared/variable-refs.js.map +1 -0
  127. package/kb-template/.bevelignore +1 -1
  128. package/kb-template/AGENTS.md +88 -35
  129. package/kb-template/KnowledgeBase/How to get started.md +10 -10
  130. package/kb-template/access.md +36 -36
  131. package/migrations/meta/0000_snapshot.json +1479 -1479
  132. package/package.json +5 -4
  133. package/src/assets.ts +25 -25
  134. package/src/core/core-ports.ts +106 -106
  135. package/src/core/create-core-server.ts +55 -9
  136. package/src/core/create-core-services.ts +40 -20
  137. package/src/index.ts +69 -69
  138. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -98
  139. package/src/modules/access/__tests__/access-declarations.test.ts +28 -28
  140. package/src/modules/access/__tests__/access-md-format.test.ts +18 -18
  141. package/src/modules/access/__tests__/access-mutation.service.test.ts +5 -5
  142. package/src/modules/access/__tests__/access-splice.test.ts +2 -2
  143. package/src/modules/access/__tests__/access.routes.overrides.test.ts +16 -16
  144. package/src/modules/access/__tests__/grant-sources.test.ts +12 -12
  145. package/src/modules/access/__tests__/roles-admin.service.test.ts +13 -1
  146. package/src/modules/access/access-control.interface.ts +8 -7
  147. package/src/modules/access/access-control.service.ts +7 -7
  148. package/src/modules/access/access-declarations.ts +5 -5
  149. package/src/modules/access/access-mutation.service.ts +3 -3
  150. package/src/modules/access/access-splice.ts +4 -4
  151. package/src/modules/access/access.routes.ts +20 -20
  152. package/src/modules/access/creator-access.ts +5 -5
  153. package/src/modules/access/roles-admin.service.ts +13 -2
  154. package/src/modules/admin/admin-access.routes.ts +29 -29
  155. package/src/modules/auth/__tests__/auth.routes.test.ts +91 -91
  156. package/src/modules/auth/__tests__/rate-limit.test.ts +36 -36
  157. package/src/modules/auth/rate-limit.ts +45 -45
  158. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +67 -0
  159. package/src/modules/code-mode/code-mode-names.ts +10 -36
  160. package/src/modules/code-mode/code-mode.tool.ts +27 -7
  161. package/src/modules/database/connection.ts +15 -15
  162. package/src/modules/database/schema.ts +11 -11
  163. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -150
  164. package/src/modules/mcp/mcp.service.ts +57 -435
  165. package/src/modules/{groups → plugins}/__tests__/join-proposals.test.ts +1 -1
  166. package/src/modules/{groups → plugins}/__tests__/join-requests.service.test.ts +7 -7
  167. package/src/modules/{groups/__tests__/group-index.service.test.ts → plugins/__tests__/plugin-index.service.test.ts} +41 -41
  168. package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +312 -0
  169. package/src/modules/{groups/__tests__/groups.routes.test.ts → plugins/__tests__/plugins.routes.test.ts} +100 -100
  170. package/src/modules/plugins/index.ts +17 -0
  171. package/src/modules/{groups → plugins}/join-proposals.ts +2 -2
  172. package/src/modules/{groups → plugins}/join-requests.service.ts +8 -8
  173. package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts} +139 -69
  174. package/src/modules/{groups/groups.contract.ts → plugins/plugins.contract.ts} +26 -26
  175. package/src/modules/{groups/groups.routes.ts → plugins/plugins.routes.ts} +102 -102
  176. package/src/modules/{groups/groups.service.ts → plugins/plugins.service.ts} +43 -43
  177. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -143
  178. package/src/modules/skills/__tests__/pending-skills.service.test.ts +14 -14
  179. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  180. package/src/modules/skills/pending-skills.service.ts +7 -7
  181. package/src/modules/skills/skills.contract.ts +4 -4
  182. package/src/modules/skills/skills.service.ts +5 -5
  183. package/src/modules/tool-auth/llm-usage-meter.ts +19 -19
  184. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +198 -0
  185. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +346 -0
  186. package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +101 -0
  187. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +3 -3
  188. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  189. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +27 -27
  190. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
  191. package/src/modules/tool-manuals/mcp-json-discovery.ts +328 -0
  192. package/src/modules/tool-manuals/mcp-server-edit.service.ts +434 -0
  193. package/src/modules/tool-manuals/tool-manuals.contract.ts +35 -12
  194. package/src/modules/tool-manuals/tool-manuals.routes.ts +222 -1
  195. package/src/modules/tool-manuals/tool-manuals.service.ts +82 -42
  196. package/src/modules/tool-manuals/tool-manuals.tools.ts +6 -3
  197. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
  198. package/src/modules/workflow/git/__tests__/branch-name.test.ts +3 -3
  199. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +3 -3
  200. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +13 -8
  201. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
  202. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
  203. package/src/modules/workflow/git/git.service.ts +2 -2
  204. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +1 -1
  205. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
  206. package/src/modules/workflow/workflow-hooks.ts +101 -101
  207. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +81 -13
  208. package/src/modules/workspace/__tests__/plugins-migration.test.ts +427 -0
  209. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -237
  210. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -236
  211. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -179
  212. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -320
  213. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -337
  214. package/src/modules/workspace/__tests__/workspace.service.test.ts +116 -0
  215. package/src/modules/workspace/bevel-ignore.ts +66 -66
  216. package/src/modules/workspace/kb-seed.service.ts +38 -9
  217. package/src/modules/workspace/plugins-migration.ts +479 -0
  218. package/src/modules/workspace/session-sink.ts +25 -25
  219. package/src/modules/workspace/workspace.routes.ts +3 -3
  220. package/src/modules/workspace/workspace.service.ts +85 -14
  221. package/src/modules/workspace/workspace.tools.ts +922 -922
  222. package/src/shared/__tests__/join-request.test.ts +13 -13
  223. package/src/shared/__tests__/kb-layout.plugin.test.ts +45 -0
  224. package/src/shared/kb-layout.test.ts +3 -3
  225. package/src/shared/utcp-namespace.ts +10 -68
  226. package/src/shared/variable-refs.ts +64 -0
  227. package/src/modules/groups/__tests__/group-provision.service.test.ts +0 -247
  228. package/src/modules/groups/index.ts +0 -17
  229. package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
  230. /package/kb-template/{Groups → Plugins}/.gitkeep +0 -0
@@ -1,236 +1,236 @@
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 { IWorkflowService } from '@bevel-software/platform-shared';
6
- import type { IAccessControl } from '../../access/access-control.interface.js';
7
- import type { ICreatorAccess } from '../../access/creator-access.js';
8
- import type { WorkflowEventBus } from '../../workflow/event-bus.js';
9
- import type { AuthService } from '../../auth/auth.service.js';
10
- import type { IAdminAccessService } from '../../admin/admin.interface.js';
11
- import { createWorkspaceRoutes } from '../workspace.routes.js';
12
- import type { WorkspaceService } from '../workspace.service.js';
13
-
14
- /**
15
- * Contract test for the creator read-grant hooks on the creation routes:
16
- * PUT /file (frontmatter transform + subtree seed), POST /directory (subtree
17
- * seed before the .gitkeep cycle), POST /unzip (in-place grant per extracted
18
- * file). The planner itself is unit-tested in creator-access.test.ts — here
19
- * it's mocked to return canned plans and the assertion is on what the route
20
- * writes, locks, and invalidates.
21
- */
22
-
23
- const USER_ID = 'user-1';
24
- const USER = { id: USER_ID, email: 'alice@example.com', name: 'Alice' };
25
- const WS = 'feat-branch';
26
- const KB = 'knowledge-base';
27
-
28
- interface Harness {
29
- server: Server;
30
- baseUrl: string;
31
- writes: Array<{ path: string; content: string }>;
32
- lockedPaths: string[];
33
- writeFileMock: ReturnType<typeof vi.fn>;
34
- creatorAccess: {
35
- planForCreate: ReturnType<typeof vi.fn>;
36
- grantInExtractedFile: ReturnType<typeof vi.fn>;
37
- noteAccessFileWritten: ReturnType<typeof vi.fn>;
38
- };
39
- }
40
-
41
- async function makeHarness(opts: { extracted?: string[] } = {}): Promise<Harness> {
42
- const writes: Array<{ path: string; content: string }> = [];
43
- const lockedPaths: string[] = [];
44
-
45
- const writeFileMock = vi.fn(async (_id: string, p: string, content: string) => {
46
- writes.push({ path: p, content });
47
- });
48
- const workspaceService = {
49
- writeFile: writeFileMock,
50
- writeFileBinary: vi.fn(async (_id: string, p: string, data: Uint8Array) => {
51
- writes.push({ path: p, content: Buffer.from(data).toString('utf8') });
52
- }),
53
- createDirectory: vi.fn(async () => undefined),
54
- unzipFile: vi.fn(async () => ({ extracted: opts.extracted ?? [] })),
55
- } as unknown as WorkspaceService;
56
-
57
- const workflowService = {
58
- getLock: vi.fn(async () => null),
59
- acquireLock: vi.fn(async (_w: string, _b: string, p: string) => {
60
- lockedPaths.push(p);
61
- return { acquired: true, lock: { holderUserId: USER_ID, holderName: 'Alice' } };
62
- }),
63
- releaseLock: vi.fn(async () => null),
64
- releaseLockNoCommit: vi.fn(async () => undefined),
65
- } as unknown as IWorkflowService;
66
-
67
- const creatorAccess = {
68
- planForCreate: vi.fn(async () => null),
69
- grantInExtractedFile: vi.fn(async () => null),
70
- noteAccessFileWritten: vi.fn(),
71
- };
72
-
73
- const authService = { getUserById: vi.fn(async () => USER) } as unknown as AuthService;
74
- const eventBus = { emit: vi.fn() } as unknown as WorkflowEventBus;
75
-
76
- const app = express();
77
- app.use(express.json());
78
- app.use('/api', (req, _res, next) => {
79
- (req as unknown as { userId: string }).userId = USER_ID;
80
- next();
81
- });
82
- app.use(
83
- '/api',
84
- createWorkspaceRoutes(
85
- workspaceService,
86
- authService,
87
- workflowService,
88
- eventBus,
89
- {} as unknown as IAccessControl,
90
- KB,
91
- creatorAccess as unknown as ICreatorAccess,
92
- // Not exercised here — only `.bevelignore`'s tree visibility consults it.
93
- { isAdmin: async () => false } as unknown as IAdminAccessService,
94
- ),
95
- );
96
- const server = await new Promise<Server>((resolve) => {
97
- const s = app.listen(0, () => resolve(s));
98
- });
99
- const addr = server.address() as AddressInfo;
100
- return {
101
- server,
102
- baseUrl: `http://127.0.0.1:${addr.port}`,
103
- writes,
104
- lockedPaths,
105
- writeFileMock,
106
- creatorAccess,
107
- };
108
- }
109
-
110
- function close(s: Server): Promise<void> {
111
- return new Promise((resolve, reject) => s.close((e) => (e ? reject(e) : resolve())));
112
- }
113
-
114
- describe('creator read-grant hooks on the creation routes', () => {
115
- let h: Harness | null = null;
116
- afterEach(async () => {
117
- if (h) await close(h.server);
118
- h = null;
119
- });
120
-
121
- it('PUT /file with a frontmatter plan writes the transformed content in ONE write', async () => {
122
- h = await makeHarness();
123
- h.creatorAccess.planForCreate.mockResolvedValue({
124
- kind: 'frontmatter',
125
- apply: (c: string) => `---\nread: Alice <alice@example.com>\n---\n${c}`,
126
- });
127
- const res = await fetch(
128
- `${h.baseUrl}/api/workspace/${WS}/file?path=${encodeURIComponent(`${KB}/KnowledgeBase/new.md`)}`,
129
- { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ content: '# New\n' }) },
130
- );
131
- expect(res.status).toBe(200);
132
- expect(h.creatorAccess.planForCreate).toHaveBeenCalledWith(
133
- WS, USER, `${KB}/KnowledgeBase/new.md`, 'file',
134
- );
135
- expect(h.writes).toEqual([
136
- { path: `${KB}/KnowledgeBase/new.md`, content: '---\nread: Alice <alice@example.com>\n---\n# New\n' },
137
- ]);
138
- });
139
-
140
- it('PUT /file with a seed plan writes the access.md under its own lock BEFORE the file', async () => {
141
- h = await makeHarness();
142
- const seedPath = `${KB}/KnowledgeBase/Mine/access.md`;
143
- h.creatorAccess.planForCreate.mockResolvedValue({
144
- kind: 'seed-access-md',
145
- wsRelPath: seedPath,
146
- apply: (current: string) => current + '---\nread:\n - Alice <alice@example.com>\n---\n',
147
- });
148
- const filePath = `${KB}/KnowledgeBase/Mine/doc.md`;
149
- const res = await fetch(
150
- `${h.baseUrl}/api/workspace/${WS}/file?path=${encodeURIComponent(filePath)}`,
151
- { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ content: 'body' }) },
152
- );
153
- expect(res.status).toBe(200);
154
- expect(h.writes.map((w) => w.path)).toEqual([seedPath, filePath]);
155
- // The file content is untouched — the grant lives in the seeded access.md.
156
- expect(h.writes[1].content).toBe('body');
157
- expect(h.lockedPaths).toEqual([seedPath, filePath]);
158
- expect(h.creatorAccess.noteAccessFileWritten).toHaveBeenCalledWith(WS);
159
- });
160
-
161
- it('a failing seed never fails the creation itself', async () => {
162
- h = await makeHarness();
163
- h.creatorAccess.planForCreate.mockResolvedValue({
164
- kind: 'seed-access-md',
165
- wsRelPath: `${KB}/KnowledgeBase/Mine/access.md`,
166
- apply: () => 'seed',
167
- });
168
- // Only the seed write blows up; the file write succeeds.
169
- h.writeFileMock.mockImplementation(async (_id: string, p: string, content: string) => {
170
- if (p.endsWith('/access.md')) throw new Error('boom');
171
- h!.writes.push({ path: p, content });
172
- });
173
- const res = await fetch(
174
- `${h.baseUrl}/api/workspace/${WS}/file?path=${encodeURIComponent(`${KB}/KnowledgeBase/Mine/doc.md`)}`,
175
- { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ content: 'body' }) },
176
- );
177
- expect(res.status).toBe(200);
178
- expect(h.writes).toEqual([{ path: `${KB}/KnowledgeBase/Mine/doc.md`, content: 'body' }]);
179
- });
180
-
181
- it('POST /directory seeds the new folder access.md before the .gitkeep cycle', async () => {
182
- h = await makeHarness();
183
- const seedPath = `${KB}/KnowledgeBase/Projects/access.md`;
184
- h.creatorAccess.planForCreate.mockResolvedValue({
185
- kind: 'seed-access-md',
186
- wsRelPath: seedPath,
187
- apply: () => '---\nread:\n - Alice <alice@example.com>\n---\n',
188
- });
189
- const res = await fetch(`${h.baseUrl}/api/workspace/${WS}/directory`, {
190
- method: 'POST',
191
- headers: { 'content-type': 'application/json' },
192
- body: JSON.stringify({ path: `${KB}/KnowledgeBase/Projects` }),
193
- });
194
- expect(res.status).toBe(200);
195
- expect(h.creatorAccess.planForCreate).toHaveBeenCalledWith(
196
- WS, USER, `${KB}/KnowledgeBase/Projects`, 'dir',
197
- );
198
- // Seed written (and locked) before the .gitkeep lock cycle.
199
- expect(h.writes.map((w) => w.path)).toEqual([seedPath]);
200
- expect(h.lockedPaths[0]).toBe(seedPath);
201
- expect(h.lockedPaths[1]).toBe(`${KB}/KnowledgeBase/Projects/.gitkeep`);
202
- });
203
-
204
- it('POST /unzip splices the creator grant into extracted files that need one', async () => {
205
- const extracted = [`${KB}/KnowledgeBase/a.md`, `${KB}/KnowledgeBase/b.md`];
206
- h = await makeHarness({ extracted });
207
- h.creatorAccess.grantInExtractedFile.mockImplementation(
208
- async (_w: string, _u: unknown, p: string) =>
209
- p.endsWith('a.md') ? 'GRANTED CONTENT' : null,
210
- );
211
- const res = await fetch(`${h.baseUrl}/api/workspace/${WS}/unzip`, {
212
- method: 'POST',
213
- headers: { 'content-type': 'application/json' },
214
- body: JSON.stringify({ path: `${KB}/KnowledgeBase/drop.zip` }),
215
- });
216
- expect(res.status).toBe(200);
217
- // a.md got rewritten with the granted content; b.md untouched.
218
- expect(h.writes).toEqual([{ path: `${KB}/KnowledgeBase/a.md`, content: 'GRANTED CONTENT' }]);
219
- });
220
-
221
- it('POST /upload transforms an uploaded .md through the frontmatter plan', async () => {
222
- h = await makeHarness();
223
- h.creatorAccess.planForCreate.mockResolvedValue({
224
- kind: 'frontmatter',
225
- apply: (c: string) => `---\nread: Alice <alice@example.com>\n---\n${c}`,
226
- });
227
- const res = await fetch(
228
- `${h.baseUrl}/api/workspace/${WS}/upload?path=${encodeURIComponent(`${KB}/KnowledgeBase/up.md`)}`,
229
- { method: 'POST', headers: { 'content-type': 'application/octet-stream' }, body: '# Uploaded\n' },
230
- );
231
- expect(res.status).toBe(200);
232
- expect(h.writes).toEqual([
233
- { path: `${KB}/KnowledgeBase/up.md`, content: '---\nread: Alice <alice@example.com>\n---\n# Uploaded\n' },
234
- ]);
235
- });
236
- });
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 { IWorkflowService } from '@bevel-software/platform-shared';
6
+ import type { IAccessControl } from '../../access/access-control.interface.js';
7
+ import type { ICreatorAccess } from '../../access/creator-access.js';
8
+ import type { WorkflowEventBus } from '../../workflow/event-bus.js';
9
+ import type { AuthService } from '../../auth/auth.service.js';
10
+ import type { IAdminAccessService } from '../../admin/admin.interface.js';
11
+ import { createWorkspaceRoutes } from '../workspace.routes.js';
12
+ import type { WorkspaceService } from '../workspace.service.js';
13
+
14
+ /**
15
+ * Contract test for the creator read-grant hooks on the creation routes:
16
+ * PUT /file (frontmatter transform + subtree seed), POST /directory (subtree
17
+ * seed before the .gitkeep cycle), POST /unzip (in-place grant per extracted
18
+ * file). The planner itself is unit-tested in creator-access.test.ts — here
19
+ * it's mocked to return canned plans and the assertion is on what the route
20
+ * writes, locks, and invalidates.
21
+ */
22
+
23
+ const USER_ID = 'user-1';
24
+ const USER = { id: USER_ID, email: 'alice@example.com', name: 'Alice' };
25
+ const WS = 'feat-branch';
26
+ const KB = 'knowledge-base';
27
+
28
+ interface Harness {
29
+ server: Server;
30
+ baseUrl: string;
31
+ writes: Array<{ path: string; content: string }>;
32
+ lockedPaths: string[];
33
+ writeFileMock: ReturnType<typeof vi.fn>;
34
+ creatorAccess: {
35
+ planForCreate: ReturnType<typeof vi.fn>;
36
+ grantInExtractedFile: ReturnType<typeof vi.fn>;
37
+ noteAccessFileWritten: ReturnType<typeof vi.fn>;
38
+ };
39
+ }
40
+
41
+ async function makeHarness(opts: { extracted?: string[] } = {}): Promise<Harness> {
42
+ const writes: Array<{ path: string; content: string }> = [];
43
+ const lockedPaths: string[] = [];
44
+
45
+ const writeFileMock = vi.fn(async (_id: string, p: string, content: string) => {
46
+ writes.push({ path: p, content });
47
+ });
48
+ const workspaceService = {
49
+ writeFile: writeFileMock,
50
+ writeFileBinary: vi.fn(async (_id: string, p: string, data: Uint8Array) => {
51
+ writes.push({ path: p, content: Buffer.from(data).toString('utf8') });
52
+ }),
53
+ createDirectory: vi.fn(async () => undefined),
54
+ unzipFile: vi.fn(async () => ({ extracted: opts.extracted ?? [] })),
55
+ } as unknown as WorkspaceService;
56
+
57
+ const workflowService = {
58
+ getLock: vi.fn(async () => null),
59
+ acquireLock: vi.fn(async (_w: string, _b: string, p: string) => {
60
+ lockedPaths.push(p);
61
+ return { acquired: true, lock: { holderUserId: USER_ID, holderName: 'Alice' } };
62
+ }),
63
+ releaseLock: vi.fn(async () => null),
64
+ releaseLockNoCommit: vi.fn(async () => undefined),
65
+ } as unknown as IWorkflowService;
66
+
67
+ const creatorAccess = {
68
+ planForCreate: vi.fn(async () => null),
69
+ grantInExtractedFile: vi.fn(async () => null),
70
+ noteAccessFileWritten: vi.fn(),
71
+ };
72
+
73
+ const authService = { getUserById: vi.fn(async () => USER) } as unknown as AuthService;
74
+ const eventBus = { emit: vi.fn() } as unknown as WorkflowEventBus;
75
+
76
+ const app = express();
77
+ app.use(express.json());
78
+ app.use('/api', (req, _res, next) => {
79
+ (req as unknown as { userId: string }).userId = USER_ID;
80
+ next();
81
+ });
82
+ app.use(
83
+ '/api',
84
+ createWorkspaceRoutes(
85
+ workspaceService,
86
+ authService,
87
+ workflowService,
88
+ eventBus,
89
+ {} as unknown as IAccessControl,
90
+ KB,
91
+ creatorAccess as unknown as ICreatorAccess,
92
+ // Not exercised here — only `.bevelignore`'s tree visibility consults it.
93
+ { isAdmin: async () => false } as unknown as IAdminAccessService,
94
+ ),
95
+ );
96
+ const server = await new Promise<Server>((resolve) => {
97
+ const s = app.listen(0, () => resolve(s));
98
+ });
99
+ const addr = server.address() as AddressInfo;
100
+ return {
101
+ server,
102
+ baseUrl: `http://127.0.0.1:${addr.port}`,
103
+ writes,
104
+ lockedPaths,
105
+ writeFileMock,
106
+ creatorAccess,
107
+ };
108
+ }
109
+
110
+ function close(s: Server): Promise<void> {
111
+ return new Promise((resolve, reject) => s.close((e) => (e ? reject(e) : resolve())));
112
+ }
113
+
114
+ describe('creator read-grant hooks on the creation routes', () => {
115
+ let h: Harness | null = null;
116
+ afterEach(async () => {
117
+ if (h) await close(h.server);
118
+ h = null;
119
+ });
120
+
121
+ it('PUT /file with a frontmatter plan writes the transformed content in ONE write', async () => {
122
+ h = await makeHarness();
123
+ h.creatorAccess.planForCreate.mockResolvedValue({
124
+ kind: 'frontmatter',
125
+ apply: (c: string) => `---\nread: Alice <alice@example.com>\n---\n${c}`,
126
+ });
127
+ const res = await fetch(
128
+ `${h.baseUrl}/api/workspace/${WS}/file?path=${encodeURIComponent(`${KB}/KnowledgeBase/new.md`)}`,
129
+ { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ content: '# New\n' }) },
130
+ );
131
+ expect(res.status).toBe(200);
132
+ expect(h.creatorAccess.planForCreate).toHaveBeenCalledWith(
133
+ WS, USER, `${KB}/KnowledgeBase/new.md`, 'file',
134
+ );
135
+ expect(h.writes).toEqual([
136
+ { path: `${KB}/KnowledgeBase/new.md`, content: '---\nread: Alice <alice@example.com>\n---\n# New\n' },
137
+ ]);
138
+ });
139
+
140
+ it('PUT /file with a seed plan writes the access.md under its own lock BEFORE the file', async () => {
141
+ h = await makeHarness();
142
+ const seedPath = `${KB}/KnowledgeBase/Mine/access.md`;
143
+ h.creatorAccess.planForCreate.mockResolvedValue({
144
+ kind: 'seed-access-md',
145
+ wsRelPath: seedPath,
146
+ apply: (current: string) => current + '---\nread:\n - Alice <alice@example.com>\n---\n',
147
+ });
148
+ const filePath = `${KB}/KnowledgeBase/Mine/doc.md`;
149
+ const res = await fetch(
150
+ `${h.baseUrl}/api/workspace/${WS}/file?path=${encodeURIComponent(filePath)}`,
151
+ { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ content: 'body' }) },
152
+ );
153
+ expect(res.status).toBe(200);
154
+ expect(h.writes.map((w) => w.path)).toEqual([seedPath, filePath]);
155
+ // The file content is untouched — the grant lives in the seeded access.md.
156
+ expect(h.writes[1].content).toBe('body');
157
+ expect(h.lockedPaths).toEqual([seedPath, filePath]);
158
+ expect(h.creatorAccess.noteAccessFileWritten).toHaveBeenCalledWith(WS);
159
+ });
160
+
161
+ it('a failing seed never fails the creation itself', async () => {
162
+ h = await makeHarness();
163
+ h.creatorAccess.planForCreate.mockResolvedValue({
164
+ kind: 'seed-access-md',
165
+ wsRelPath: `${KB}/KnowledgeBase/Mine/access.md`,
166
+ apply: () => 'seed',
167
+ });
168
+ // Only the seed write blows up; the file write succeeds.
169
+ h.writeFileMock.mockImplementation(async (_id: string, p: string, content: string) => {
170
+ if (p.endsWith('/access.md')) throw new Error('boom');
171
+ h!.writes.push({ path: p, content });
172
+ });
173
+ const res = await fetch(
174
+ `${h.baseUrl}/api/workspace/${WS}/file?path=${encodeURIComponent(`${KB}/KnowledgeBase/Mine/doc.md`)}`,
175
+ { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ content: 'body' }) },
176
+ );
177
+ expect(res.status).toBe(200);
178
+ expect(h.writes).toEqual([{ path: `${KB}/KnowledgeBase/Mine/doc.md`, content: 'body' }]);
179
+ });
180
+
181
+ it('POST /directory seeds the new folder access.md before the .gitkeep cycle', async () => {
182
+ h = await makeHarness();
183
+ const seedPath = `${KB}/KnowledgeBase/Projects/access.md`;
184
+ h.creatorAccess.planForCreate.mockResolvedValue({
185
+ kind: 'seed-access-md',
186
+ wsRelPath: seedPath,
187
+ apply: () => '---\nread:\n - Alice <alice@example.com>\n---\n',
188
+ });
189
+ const res = await fetch(`${h.baseUrl}/api/workspace/${WS}/directory`, {
190
+ method: 'POST',
191
+ headers: { 'content-type': 'application/json' },
192
+ body: JSON.stringify({ path: `${KB}/KnowledgeBase/Projects` }),
193
+ });
194
+ expect(res.status).toBe(200);
195
+ expect(h.creatorAccess.planForCreate).toHaveBeenCalledWith(
196
+ WS, USER, `${KB}/KnowledgeBase/Projects`, 'dir',
197
+ );
198
+ // Seed written (and locked) before the .gitkeep lock cycle.
199
+ expect(h.writes.map((w) => w.path)).toEqual([seedPath]);
200
+ expect(h.lockedPaths[0]).toBe(seedPath);
201
+ expect(h.lockedPaths[1]).toBe(`${KB}/KnowledgeBase/Projects/.gitkeep`);
202
+ });
203
+
204
+ it('POST /unzip splices the creator grant into extracted files that need one', async () => {
205
+ const extracted = [`${KB}/KnowledgeBase/a.md`, `${KB}/KnowledgeBase/b.md`];
206
+ h = await makeHarness({ extracted });
207
+ h.creatorAccess.grantInExtractedFile.mockImplementation(
208
+ async (_w: string, _u: unknown, p: string) =>
209
+ p.endsWith('a.md') ? 'GRANTED CONTENT' : null,
210
+ );
211
+ const res = await fetch(`${h.baseUrl}/api/workspace/${WS}/unzip`, {
212
+ method: 'POST',
213
+ headers: { 'content-type': 'application/json' },
214
+ body: JSON.stringify({ path: `${KB}/KnowledgeBase/drop.zip` }),
215
+ });
216
+ expect(res.status).toBe(200);
217
+ // a.md got rewritten with the granted content; b.md untouched.
218
+ expect(h.writes).toEqual([{ path: `${KB}/KnowledgeBase/a.md`, content: 'GRANTED CONTENT' }]);
219
+ });
220
+
221
+ it('POST /upload transforms an uploaded .md through the frontmatter plan', async () => {
222
+ h = await makeHarness();
223
+ h.creatorAccess.planForCreate.mockResolvedValue({
224
+ kind: 'frontmatter',
225
+ apply: (c: string) => `---\nread: Alice <alice@example.com>\n---\n${c}`,
226
+ });
227
+ const res = await fetch(
228
+ `${h.baseUrl}/api/workspace/${WS}/upload?path=${encodeURIComponent(`${KB}/KnowledgeBase/up.md`)}`,
229
+ { method: 'POST', headers: { 'content-type': 'application/octet-stream' }, body: '# Uploaded\n' },
230
+ );
231
+ expect(res.status).toBe(200);
232
+ expect(h.writes).toEqual([
233
+ { path: `${KB}/KnowledgeBase/up.md`, content: '---\nread: Alice <alice@example.com>\n---\n# Uploaded\n' },
234
+ ]);
235
+ });
236
+ });