@bevel-software/platform-core-backend 0.7.4 → 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 (246) 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 +38 -21
  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/workflow/pending-commits.service.d.ts +11 -0
  104. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  105. package/dist/modules/workflow/pending-commits.service.js +37 -9
  106. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  107. package/dist/modules/workflow/pending-commits.worker.d.ts +17 -4
  108. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
  109. package/dist/modules/workflow/pending-commits.worker.js +63 -9
  110. package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
  111. package/dist/modules/workflow/workflow.service.d.ts +1 -0
  112. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  113. package/dist/modules/workflow/workflow.service.js +6 -1
  114. package/dist/modules/workflow/workflow.service.js.map +1 -1
  115. package/dist/modules/workspace/kb-seed.service.d.ts +2 -2
  116. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  117. package/dist/modules/workspace/kb-seed.service.js +43 -10
  118. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  119. package/dist/modules/workspace/plugins-migration.d.ts +50 -0
  120. package/dist/modules/workspace/plugins-migration.d.ts.map +1 -0
  121. package/dist/modules/workspace/plugins-migration.js +379 -0
  122. package/dist/modules/workspace/plugins-migration.js.map +1 -0
  123. package/dist/modules/workspace/workspace.routes.js +3 -3
  124. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  125. package/dist/modules/workspace/workspace.service.d.ts +26 -0
  126. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  127. package/dist/modules/workspace/workspace.service.js +83 -12
  128. package/dist/modules/workspace/workspace.service.js.map +1 -1
  129. package/dist/shared/kb-layout.test.js +3 -3
  130. package/dist/shared/kb-layout.test.js.map +1 -1
  131. package/dist/shared/utcp-namespace.d.ts +6 -27
  132. package/dist/shared/utcp-namespace.d.ts.map +1 -1
  133. package/dist/shared/utcp-namespace.js +6 -63
  134. package/dist/shared/utcp-namespace.js.map +1 -1
  135. package/dist/shared/variable-refs.d.ts +42 -0
  136. package/dist/shared/variable-refs.d.ts.map +1 -0
  137. package/dist/shared/variable-refs.js +60 -0
  138. package/dist/shared/variable-refs.js.map +1 -0
  139. package/kb-template/.bevelignore +1 -1
  140. package/kb-template/AGENTS.md +88 -35
  141. package/kb-template/KnowledgeBase/How to get started.md +10 -10
  142. package/kb-template/access.md +36 -36
  143. package/migrations/meta/0000_snapshot.json +1479 -1479
  144. package/package.json +5 -4
  145. package/src/assets.ts +25 -25
  146. package/src/core/core-ports.ts +106 -106
  147. package/src/core/create-core-server.ts +55 -9
  148. package/src/core/create-core-services.ts +48 -24
  149. package/src/index.ts +69 -69
  150. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -98
  151. package/src/modules/access/__tests__/access-declarations.test.ts +28 -28
  152. package/src/modules/access/__tests__/access-md-format.test.ts +18 -18
  153. package/src/modules/access/__tests__/access-mutation.service.test.ts +5 -5
  154. package/src/modules/access/__tests__/access-splice.test.ts +2 -2
  155. package/src/modules/access/__tests__/access.routes.overrides.test.ts +16 -16
  156. package/src/modules/access/__tests__/grant-sources.test.ts +12 -12
  157. package/src/modules/access/__tests__/roles-admin.service.test.ts +13 -1
  158. package/src/modules/access/access-control.interface.ts +8 -7
  159. package/src/modules/access/access-control.service.ts +7 -7
  160. package/src/modules/access/access-declarations.ts +5 -5
  161. package/src/modules/access/access-mutation.service.ts +3 -3
  162. package/src/modules/access/access-splice.ts +4 -4
  163. package/src/modules/access/access.routes.ts +20 -20
  164. package/src/modules/access/creator-access.ts +5 -5
  165. package/src/modules/access/roles-admin.service.ts +13 -2
  166. package/src/modules/admin/admin-access.routes.ts +29 -29
  167. package/src/modules/auth/__tests__/auth.routes.test.ts +91 -91
  168. package/src/modules/auth/__tests__/rate-limit.test.ts +36 -36
  169. package/src/modules/auth/rate-limit.ts +45 -45
  170. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +67 -0
  171. package/src/modules/code-mode/code-mode-names.ts +10 -36
  172. package/src/modules/code-mode/code-mode.tool.ts +27 -7
  173. package/src/modules/database/connection.ts +15 -15
  174. package/src/modules/database/schema.ts +11 -11
  175. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -150
  176. package/src/modules/mcp/mcp.service.ts +57 -435
  177. package/src/modules/{groups → plugins}/__tests__/join-proposals.test.ts +1 -1
  178. package/src/modules/{groups → plugins}/__tests__/join-requests.service.test.ts +7 -7
  179. package/src/modules/{groups/__tests__/group-index.service.test.ts → plugins/__tests__/plugin-index.service.test.ts} +41 -41
  180. package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +312 -0
  181. package/src/modules/{groups/__tests__/groups.routes.test.ts → plugins/__tests__/plugins.routes.test.ts} +100 -100
  182. package/src/modules/plugins/index.ts +17 -0
  183. package/src/modules/{groups → plugins}/join-proposals.ts +2 -2
  184. package/src/modules/{groups → plugins}/join-requests.service.ts +8 -8
  185. package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts} +139 -69
  186. package/src/modules/{groups/groups.contract.ts → plugins/plugins.contract.ts} +26 -26
  187. package/src/modules/{groups/groups.routes.ts → plugins/plugins.routes.ts} +102 -102
  188. package/src/modules/{groups/groups.service.ts → plugins/plugins.service.ts} +43 -43
  189. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -143
  190. package/src/modules/skills/__tests__/pending-skills.service.test.ts +14 -14
  191. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  192. package/src/modules/skills/pending-skills.service.ts +7 -7
  193. package/src/modules/skills/skills.contract.ts +4 -4
  194. package/src/modules/skills/skills.service.ts +5 -5
  195. package/src/modules/tool-auth/llm-usage-meter.ts +19 -19
  196. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +198 -0
  197. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +346 -0
  198. package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +101 -0
  199. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +3 -3
  200. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  201. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +27 -27
  202. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
  203. package/src/modules/tool-manuals/mcp-json-discovery.ts +328 -0
  204. package/src/modules/tool-manuals/mcp-server-edit.service.ts +434 -0
  205. package/src/modules/tool-manuals/tool-manuals.contract.ts +35 -12
  206. package/src/modules/tool-manuals/tool-manuals.routes.ts +222 -1
  207. package/src/modules/tool-manuals/tool-manuals.service.ts +82 -42
  208. package/src/modules/tool-manuals/tool-manuals.tools.ts +6 -3
  209. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +118 -0
  210. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
  211. package/src/modules/workflow/git/__tests__/branch-name.test.ts +3 -3
  212. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +3 -3
  213. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +13 -8
  214. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
  215. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
  216. package/src/modules/workflow/git/git.service.ts +2 -2
  217. package/src/modules/workflow/pending-commits.service.ts +48 -12
  218. package/src/modules/workflow/pending-commits.worker.ts +64 -8
  219. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +1 -1
  220. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
  221. package/src/modules/workflow/workflow-hooks.ts +101 -101
  222. package/src/modules/workflow/workflow.service.ts +13 -2
  223. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +81 -13
  224. package/src/modules/workspace/__tests__/plugins-migration.test.ts +427 -0
  225. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -237
  226. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -236
  227. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -179
  228. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -320
  229. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -337
  230. package/src/modules/workspace/__tests__/workspace.service.test.ts +116 -0
  231. package/src/modules/workspace/bevel-ignore.ts +66 -66
  232. package/src/modules/workspace/kb-seed.service.ts +38 -9
  233. package/src/modules/workspace/plugins-migration.ts +479 -0
  234. package/src/modules/workspace/session-sink.ts +25 -25
  235. package/src/modules/workspace/workspace.routes.ts +3 -3
  236. package/src/modules/workspace/workspace.service.ts +85 -14
  237. package/src/modules/workspace/workspace.tools.ts +922 -922
  238. package/src/shared/__tests__/join-request.test.ts +13 -13
  239. package/src/shared/__tests__/kb-layout.plugin.test.ts +45 -0
  240. package/src/shared/kb-layout.test.ts +3 -3
  241. package/src/shared/utcp-namespace.ts +10 -68
  242. package/src/shared/variable-refs.ts +64 -0
  243. package/src/modules/groups/__tests__/group-provision.service.test.ts +0 -247
  244. package/src/modules/groups/index.ts +0 -17
  245. package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
  246. /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
+ });