@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,320 +1,320 @@
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 { WorkflowEventBus } from '../../workflow/event-bus.js';
8
- import type { AuthService } from '../../auth/auth.service.js';
9
- import { AccessConfigError } from '../../access/access-errors.js';
10
- import type { IAdminAccessService } from '../../admin/admin.interface.js';
11
- import { createWorkspaceRoutes } from '../workspace.routes.js';
12
- import type { ICreatorAccess } from '../../access/creator-access.js';
13
- import { FolderTooLargeError, type WorkspaceService } from '../workspace.service.js';
14
-
15
- const stubCreatorAccess: ICreatorAccess = {
16
- planForCreate: async () => null,
17
- grantInExtractedFile: async () => null,
18
- noteAccessFileWritten: () => {},
19
- };
20
-
21
- /**
22
- * Contract test for the `?download=1` flavours of the workspace routes:
23
- *
24
- * - GET /workspace/:id/file/raw?download=1 (single-file save)
25
- * - GET /workspace/:id/folder/zip?download=1 (folder → zip save)
26
- *
27
- * Both are gated on the dedicated `Download` role in roles.yaml. Admin
28
- * status is fully orthogonal — being in the `Admin` role does NOT
29
- * implicitly grant Download; users must be listed under the `Download`
30
- * role to receive bytes. The inline branch of /file/raw (no flag) stays
31
- * open to every authenticated user so the PdfRenderer and image renderers
32
- * keep working for everyone.
33
- */
34
-
35
- const USER_ID = 'user-1';
36
- const USER = { id: USER_ID, email: 'alice@example.com', name: 'Alice' };
37
- const WORKSPACE_ID = 'target-company-state';
38
- const FILE_BYTES = Buffer.from('hello world');
39
- const ZIP_BYTES = Buffer.from('PK\x03\x04 fake-zip-bytes');
40
-
41
- interface Harness {
42
- server: Server;
43
- baseUrl: string;
44
- canDownload: ReturnType<typeof vi.fn>;
45
- canWrite: ReturnType<typeof vi.fn>;
46
- createFolderZip: ReturnType<typeof vi.fn>;
47
- }
48
-
49
- async function makeHarness(opts: {
50
- canDownload: boolean;
51
- folderZip?: () => Promise<Buffer> | Buffer;
52
- /** When set, canDownload rejects with this error instead of returning a bool. */
53
- canDownloadError?: Error;
54
- }): Promise<Harness> {
55
- const canDownload = vi.fn(async () => {
56
- if (opts.canDownloadError) throw opts.canDownloadError;
57
- return opts.canDownload;
58
- });
59
- const canWrite = vi.fn();
60
- const accessControl: IAccessControl = {
61
- canWrite,
62
- canWriteBatch: vi.fn(),
63
- // The /file/raw route now read-gates before the download gate; these tests
64
- // exercise the download verb, so reads pass.
65
- canRead: vi.fn(async () => true),
66
- canReadBatch: vi.fn(async (_w: string, _e: string, paths: string[]) => new Map(paths.map((p) => [p, true]))),
67
- canDownload,
68
- eligibleWriters: vi.fn(),
69
- eligibleWriterEmails: vi.fn(),
70
- invalidate: vi.fn(),
71
- findEmailByHash: vi.fn(),
72
- canWriteAtRef: vi.fn(),
73
- canWriteBatchAtRef: vi.fn(),
74
- eligibleWritersAtRef: vi.fn(),
75
- eligibleWritersForPathsAtRef: vi.fn(),
76
- } as unknown as IAccessControl;
77
-
78
- const createFolderZip = vi.fn(async () => {
79
- if (opts.folderZip) return opts.folderZip();
80
- return ZIP_BYTES;
81
- });
82
-
83
- // Partial<WorkspaceService> + final cast keeps the contract typed at the
84
- // call site (createWorkspaceRoutes wants a full WorkspaceService) while
85
- // letting us stub only the two methods these tests exercise.
86
- const workspaceServiceMock: Partial<WorkspaceService> = {
87
- readFileBinary: vi.fn(async () => FILE_BYTES),
88
- createFolderZip,
89
- };
90
- const workspaceService = workspaceServiceMock as WorkspaceService;
91
-
92
- const authServiceMock: Partial<AuthService> = {
93
- getUserById: vi.fn(async () => USER),
94
- };
95
- const authService = authServiceMock as AuthService;
96
-
97
- const workflowService = {} as unknown as IWorkflowService;
98
- const eventBus = {} as unknown as WorkflowEventBus;
99
-
100
- const app = express();
101
- app.use(express.json());
102
- app.use('/api', (req, _res, next) => {
103
- (req as any).userId = USER_ID;
104
- next();
105
- });
106
- app.use('/api', createWorkspaceRoutes(
107
- workspaceService,
108
- authService,
109
- workflowService,
110
- eventBus,
111
- accessControl,
112
- 'knowledge-base',
113
- stubCreatorAccess,
114
- // Not exercised here — only `.bevelignore`'s tree visibility consults it.
115
- { isAdmin: async () => false } as unknown as IAdminAccessService,
116
- ));
117
-
118
- const server = await new Promise<Server>((resolve) => {
119
- const s = app.listen(0, () => resolve(s));
120
- });
121
- // `server.address()` returns `AddressInfo | string | null`. The string
122
- // branch is for Unix-domain sockets and the null branch for an
123
- // unbound listener — neither can happen here (we awaited `listen(0,
124
- // ...)` on a TCP port), so a narrow + throw is sufficient and avoids
125
- // the `as any` lint.
126
- const addr = server.address();
127
- if (addr === null || typeof addr === 'string') {
128
- throw new Error(`Unexpected server.address() shape: ${JSON.stringify(addr)}`);
129
- }
130
- const port = (addr as AddressInfo).port;
131
- return { server, baseUrl: `http://127.0.0.1:${port}`, canDownload, canWrite, createFolderZip };
132
- }
133
-
134
- async function closeServer(server: Server): Promise<void> {
135
- await new Promise<void>((resolve, reject) =>
136
- server.close((err) => (err ? reject(err) : resolve())),
137
- );
138
- }
139
-
140
- describe('GET /workspace/:id/file/raw — ?download=1 gated on Download role', () => {
141
- let h: Harness | null = null;
142
- afterEach(async () => { if (h) await closeServer(h.server); h = null; });
143
-
144
- it('user with Download role gets 200 + attachment disposition + bytes', async () => {
145
- h = await makeHarness({ canDownload: true });
146
- const res = await fetch(
147
- `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/file/raw?path=${encodeURIComponent('Knowledge/Foo.md')}&download=1`,
148
- );
149
- expect(res.status).toBe(200);
150
- const dispo = res.headers.get('content-disposition');
151
- expect(dispo).toBeTruthy();
152
- expect(dispo).toContain('attachment');
153
- expect(dispo).toContain(`filename*=UTF-8''${encodeURIComponent('Foo.md')}`);
154
- expect(res.headers.get('x-content-type-options')).toBe('nosniff');
155
- const buf = Buffer.from(await res.arrayBuffer());
156
- expect(buf.equals(FILE_BYTES)).toBe(true);
157
- // canDownload is path-scoped now; the route strips the kbDirName prefix
158
- // before consulting the access service (mirrors useFileAccess on the
159
- // frontend), so the resolver sees the repo-relative path.
160
- expect(h.canDownload).toHaveBeenCalledWith(WORKSPACE_ID, USER.email, 'Knowledge/Foo.md');
161
- // canWrite must NOT be consulted by the download gate — the two verbs
162
- // are independent.
163
- expect(h.canWrite).not.toHaveBeenCalled();
164
- });
165
-
166
- it('user without Download role gets 403 with no body bytes', async () => {
167
- h = await makeHarness({ canDownload: false });
168
- const res = await fetch(
169
- `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/file/raw?path=${encodeURIComponent('Knowledge/Foo.md')}&download=1`,
170
- );
171
- expect(res.status).toBe(403);
172
- const body = (await res.json()) as { error: string };
173
- expect(body.error).toBe('Download permission required');
174
- expect(res.headers.get('content-disposition')).toBeNull();
175
- });
176
-
177
- it('inline view (no ?download flag) returns 200 for any authed user without consulting access-control', async () => {
178
- // Even a user with no download permission must be able to view PDFs /
179
- // images inline — that's the PdfRenderer happy path. The route must not
180
- // call canDownload at all on this branch.
181
- h = await makeHarness({ canDownload: false });
182
- const res = await fetch(
183
- `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/file/raw?path=${encodeURIComponent('Knowledge/Foo.md')}`,
184
- );
185
- expect(res.status).toBe(200);
186
- expect(res.headers.get('content-disposition')).toBeNull();
187
- expect(h.canDownload).not.toHaveBeenCalled();
188
- });
189
-
190
- it('returns 500 with AccessConfigError payload when roles.yaml / access.md fails to load', async () => {
191
- // Regression guard: if `canDownload` throws inside requireDownloadPermission
192
- // (e.g. roles.yaml is missing or the access tree is unrecoverably
193
- // malformed) and we don't catch it, the async rejection bubbles past
194
- // Express and the request hangs (or 500s without the helpful
195
- // config-error payload). The route should surface the
196
- // WorkflowDomainError shape — same JSON the per-path /access endpoint
197
- // returns when it hits the same exception — so the operator can see
198
- // which access.md file is broken.
199
- const configErr = new AccessConfigError([
200
- "Knowledge/Sales/access.md: unknown role 'Product Manager'",
201
- ]);
202
- h = await makeHarness({ canDownload: true, canDownloadError: configErr });
203
- const res = await fetch(
204
- `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/file/raw?path=${encodeURIComponent('Knowledge/Foo.md')}&download=1`,
205
- );
206
- expect(res.status).toBe(500);
207
- const body = (await res.json()) as { error: string; accessConfigErrors?: string[] };
208
- expect(body.error).toContain('Access-control config is invalid');
209
- expect(body.accessConfigErrors?.[0]).toContain("unknown role 'Product Manager'");
210
- });
211
-
212
- it('forces SVG to octet-stream when downloading (anti-XSS), preserves image/svg+xml inline', async () => {
213
- h = await makeHarness({ canDownload: true });
214
- const dl = await fetch(
215
- `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/file/raw?path=${encodeURIComponent('icon.svg')}&download=1`,
216
- );
217
- expect(dl.headers.get('content-type')).toBe('application/octet-stream');
218
-
219
- await closeServer(h.server);
220
- h = await makeHarness({ canDownload: false });
221
- const inline = await fetch(
222
- `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/file/raw?path=${encodeURIComponent('icon.svg')}`,
223
- );
224
- expect(inline.headers.get('content-type')).toBe('image/svg+xml');
225
- // Inline keeps the real type so the image renderer's blob still paints,
226
- // and is sandboxed so the same URL opened as a tab cannot run its own
227
- // <script> against this origin.
228
- expect(inline.headers.get('content-security-policy')).toBe('sandbox');
229
- });
230
-
231
- // Everything else served inline is passive, and a blanket sandbox on, say, a
232
- // PDF would cost the viewer its own controls for nothing.
233
- it('sandboxes only SVG — other inline files carry no CSP', async () => {
234
- h = await makeHarness({ canDownload: true });
235
- const res = await fetch(
236
- `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/file/raw?path=${encodeURIComponent('Knowledge/Foo.md')}`,
237
- );
238
- expect(res.headers.get('content-security-policy')).toBeNull();
239
- });
240
- });
241
-
242
- describe('GET /workspace/:id/folder/zip — gated on Download role', () => {
243
- let h: Harness | null = null;
244
- afterEach(async () => { if (h) await closeServer(h.server); h = null; });
245
-
246
- it('user with Download role gets a 200 zip with attachment disposition', async () => {
247
- h = await makeHarness({ canDownload: true });
248
- const res = await fetch(
249
- `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/folder/zip?path=${encodeURIComponent('Knowledge/Sales')}&download=1`,
250
- );
251
- expect(res.status).toBe(200);
252
- expect(res.headers.get('content-type')).toBe('application/zip');
253
- expect(res.headers.get('x-content-type-options')).toBe('nosniff');
254
- const dispo = res.headers.get('content-disposition');
255
- expect(dispo).toBeTruthy();
256
- expect(dispo).toContain('attachment');
257
- expect(dispo).toContain(`filename*=UTF-8''${encodeURIComponent('Sales.zip')}`);
258
- const buf = Buffer.from(await res.arrayBuffer());
259
- expect(buf.equals(ZIP_BYTES)).toBe(true);
260
- expect(h.canDownload).toHaveBeenCalledWith(WORKSPACE_ID, USER.email, 'Knowledge/Sales');
261
- expect(h.createFolderZip).toHaveBeenCalledWith(WORKSPACE_ID, 'Knowledge/Sales');
262
- });
263
-
264
- it('user without Download role gets 403 and no zip is built', async () => {
265
- h = await makeHarness({ canDownload: false });
266
- const res = await fetch(
267
- `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/folder/zip?path=${encodeURIComponent('Knowledge/Sales')}&download=1`,
268
- );
269
- expect(res.status).toBe(403);
270
- const body = (await res.json()) as { error: string };
271
- expect(body.error).toBe('Download permission required');
272
- expect(h.createFolderZip).not.toHaveBeenCalled();
273
- });
274
-
275
- it('requires ?download=1 — a stray inline call is rejected with 400', async () => {
276
- // Folder zip is download-only; refuse the inline shape so the URL contract
277
- // matches the file route and there's no ambiguity about what should happen.
278
- h = await makeHarness({ canDownload: true });
279
- const res = await fetch(
280
- `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/folder/zip?path=${encodeURIComponent('Knowledge/Sales')}`,
281
- );
282
- expect(res.status).toBe(400);
283
- expect(h.createFolderZip).not.toHaveBeenCalled();
284
- });
285
-
286
- it('maps FolderTooLargeError to 413 with the limit-bearing message', async () => {
287
- h = await makeHarness({
288
- canDownload: true,
289
- folderZip: async () => { throw new FolderTooLargeError(500 * 1024 * 1024); },
290
- });
291
- const res = await fetch(
292
- `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/folder/zip?path=${encodeURIComponent('Big')}&download=1`,
293
- );
294
- expect(res.status).toBe(413);
295
- const body = (await res.json()) as { error: string };
296
- expect(body.error).toContain('524288000');
297
- });
298
-
299
- it('maps Path traversal detected to 403', async () => {
300
- h = await makeHarness({
301
- canDownload: true,
302
- folderZip: async () => { throw new Error('Path traversal detected'); },
303
- });
304
- const res = await fetch(
305
- `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/folder/zip?path=${encodeURIComponent('../escape')}&download=1`,
306
- );
307
- expect(res.status).toBe(403);
308
- });
309
-
310
- it('maps Not a directory to 400 (user pointed at a file)', async () => {
311
- h = await makeHarness({
312
- canDownload: true,
313
- folderZip: async () => { throw new Error('Not a directory'); },
314
- });
315
- const res = await fetch(
316
- `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/folder/zip?path=${encodeURIComponent('Knowledge/Foo.md')}&download=1`,
317
- );
318
- expect(res.status).toBe(400);
319
- });
320
- });
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 { WorkflowEventBus } from '../../workflow/event-bus.js';
8
+ import type { AuthService } from '../../auth/auth.service.js';
9
+ import { AccessConfigError } from '../../access/access-errors.js';
10
+ import type { IAdminAccessService } from '../../admin/admin.interface.js';
11
+ import { createWorkspaceRoutes } from '../workspace.routes.js';
12
+ import type { ICreatorAccess } from '../../access/creator-access.js';
13
+ import { FolderTooLargeError, type WorkspaceService } from '../workspace.service.js';
14
+
15
+ const stubCreatorAccess: ICreatorAccess = {
16
+ planForCreate: async () => null,
17
+ grantInExtractedFile: async () => null,
18
+ noteAccessFileWritten: () => {},
19
+ };
20
+
21
+ /**
22
+ * Contract test for the `?download=1` flavours of the workspace routes:
23
+ *
24
+ * - GET /workspace/:id/file/raw?download=1 (single-file save)
25
+ * - GET /workspace/:id/folder/zip?download=1 (folder → zip save)
26
+ *
27
+ * Both are gated on the dedicated `Download` role in roles.yaml. Admin
28
+ * status is fully orthogonal — being in the `Admin` role does NOT
29
+ * implicitly grant Download; users must be listed under the `Download`
30
+ * role to receive bytes. The inline branch of /file/raw (no flag) stays
31
+ * open to every authenticated user so the PdfRenderer and image renderers
32
+ * keep working for everyone.
33
+ */
34
+
35
+ const USER_ID = 'user-1';
36
+ const USER = { id: USER_ID, email: 'alice@example.com', name: 'Alice' };
37
+ const WORKSPACE_ID = 'target-company-state';
38
+ const FILE_BYTES = Buffer.from('hello world');
39
+ const ZIP_BYTES = Buffer.from('PK\x03\x04 fake-zip-bytes');
40
+
41
+ interface Harness {
42
+ server: Server;
43
+ baseUrl: string;
44
+ canDownload: ReturnType<typeof vi.fn>;
45
+ canWrite: ReturnType<typeof vi.fn>;
46
+ createFolderZip: ReturnType<typeof vi.fn>;
47
+ }
48
+
49
+ async function makeHarness(opts: {
50
+ canDownload: boolean;
51
+ folderZip?: () => Promise<Buffer> | Buffer;
52
+ /** When set, canDownload rejects with this error instead of returning a bool. */
53
+ canDownloadError?: Error;
54
+ }): Promise<Harness> {
55
+ const canDownload = vi.fn(async () => {
56
+ if (opts.canDownloadError) throw opts.canDownloadError;
57
+ return opts.canDownload;
58
+ });
59
+ const canWrite = vi.fn();
60
+ const accessControl: IAccessControl = {
61
+ canWrite,
62
+ canWriteBatch: vi.fn(),
63
+ // The /file/raw route now read-gates before the download gate; these tests
64
+ // exercise the download verb, so reads pass.
65
+ canRead: vi.fn(async () => true),
66
+ canReadBatch: vi.fn(async (_w: string, _e: string, paths: string[]) => new Map(paths.map((p) => [p, true]))),
67
+ canDownload,
68
+ eligibleWriters: vi.fn(),
69
+ eligibleWriterEmails: vi.fn(),
70
+ invalidate: vi.fn(),
71
+ findEmailByHash: vi.fn(),
72
+ canWriteAtRef: vi.fn(),
73
+ canWriteBatchAtRef: vi.fn(),
74
+ eligibleWritersAtRef: vi.fn(),
75
+ eligibleWritersForPathsAtRef: vi.fn(),
76
+ } as unknown as IAccessControl;
77
+
78
+ const createFolderZip = vi.fn(async () => {
79
+ if (opts.folderZip) return opts.folderZip();
80
+ return ZIP_BYTES;
81
+ });
82
+
83
+ // Partial<WorkspaceService> + final cast keeps the contract typed at the
84
+ // call site (createWorkspaceRoutes wants a full WorkspaceService) while
85
+ // letting us stub only the two methods these tests exercise.
86
+ const workspaceServiceMock: Partial<WorkspaceService> = {
87
+ readFileBinary: vi.fn(async () => FILE_BYTES),
88
+ createFolderZip,
89
+ };
90
+ const workspaceService = workspaceServiceMock as WorkspaceService;
91
+
92
+ const authServiceMock: Partial<AuthService> = {
93
+ getUserById: vi.fn(async () => USER),
94
+ };
95
+ const authService = authServiceMock as AuthService;
96
+
97
+ const workflowService = {} as unknown as IWorkflowService;
98
+ const eventBus = {} as unknown as WorkflowEventBus;
99
+
100
+ const app = express();
101
+ app.use(express.json());
102
+ app.use('/api', (req, _res, next) => {
103
+ (req as any).userId = USER_ID;
104
+ next();
105
+ });
106
+ app.use('/api', createWorkspaceRoutes(
107
+ workspaceService,
108
+ authService,
109
+ workflowService,
110
+ eventBus,
111
+ accessControl,
112
+ 'knowledge-base',
113
+ stubCreatorAccess,
114
+ // Not exercised here — only `.bevelignore`'s tree visibility consults it.
115
+ { isAdmin: async () => false } as unknown as IAdminAccessService,
116
+ ));
117
+
118
+ const server = await new Promise<Server>((resolve) => {
119
+ const s = app.listen(0, () => resolve(s));
120
+ });
121
+ // `server.address()` returns `AddressInfo | string | null`. The string
122
+ // branch is for Unix-domain sockets and the null branch for an
123
+ // unbound listener — neither can happen here (we awaited `listen(0,
124
+ // ...)` on a TCP port), so a narrow + throw is sufficient and avoids
125
+ // the `as any` lint.
126
+ const addr = server.address();
127
+ if (addr === null || typeof addr === 'string') {
128
+ throw new Error(`Unexpected server.address() shape: ${JSON.stringify(addr)}`);
129
+ }
130
+ const port = (addr as AddressInfo).port;
131
+ return { server, baseUrl: `http://127.0.0.1:${port}`, canDownload, canWrite, createFolderZip };
132
+ }
133
+
134
+ async function closeServer(server: Server): Promise<void> {
135
+ await new Promise<void>((resolve, reject) =>
136
+ server.close((err) => (err ? reject(err) : resolve())),
137
+ );
138
+ }
139
+
140
+ describe('GET /workspace/:id/file/raw — ?download=1 gated on Download role', () => {
141
+ let h: Harness | null = null;
142
+ afterEach(async () => { if (h) await closeServer(h.server); h = null; });
143
+
144
+ it('user with Download role gets 200 + attachment disposition + bytes', async () => {
145
+ h = await makeHarness({ canDownload: true });
146
+ const res = await fetch(
147
+ `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/file/raw?path=${encodeURIComponent('Knowledge/Foo.md')}&download=1`,
148
+ );
149
+ expect(res.status).toBe(200);
150
+ const dispo = res.headers.get('content-disposition');
151
+ expect(dispo).toBeTruthy();
152
+ expect(dispo).toContain('attachment');
153
+ expect(dispo).toContain(`filename*=UTF-8''${encodeURIComponent('Foo.md')}`);
154
+ expect(res.headers.get('x-content-type-options')).toBe('nosniff');
155
+ const buf = Buffer.from(await res.arrayBuffer());
156
+ expect(buf.equals(FILE_BYTES)).toBe(true);
157
+ // canDownload is path-scoped now; the route strips the kbDirName prefix
158
+ // before consulting the access service (mirrors useFileAccess on the
159
+ // frontend), so the resolver sees the repo-relative path.
160
+ expect(h.canDownload).toHaveBeenCalledWith(WORKSPACE_ID, USER.email, 'Knowledge/Foo.md');
161
+ // canWrite must NOT be consulted by the download gate — the two verbs
162
+ // are independent.
163
+ expect(h.canWrite).not.toHaveBeenCalled();
164
+ });
165
+
166
+ it('user without Download role gets 403 with no body bytes', async () => {
167
+ h = await makeHarness({ canDownload: false });
168
+ const res = await fetch(
169
+ `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/file/raw?path=${encodeURIComponent('Knowledge/Foo.md')}&download=1`,
170
+ );
171
+ expect(res.status).toBe(403);
172
+ const body = (await res.json()) as { error: string };
173
+ expect(body.error).toBe('Download permission required');
174
+ expect(res.headers.get('content-disposition')).toBeNull();
175
+ });
176
+
177
+ it('inline view (no ?download flag) returns 200 for any authed user without consulting access-control', async () => {
178
+ // Even a user with no download permission must be able to view PDFs /
179
+ // images inline — that's the PdfRenderer happy path. The route must not
180
+ // call canDownload at all on this branch.
181
+ h = await makeHarness({ canDownload: false });
182
+ const res = await fetch(
183
+ `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/file/raw?path=${encodeURIComponent('Knowledge/Foo.md')}`,
184
+ );
185
+ expect(res.status).toBe(200);
186
+ expect(res.headers.get('content-disposition')).toBeNull();
187
+ expect(h.canDownload).not.toHaveBeenCalled();
188
+ });
189
+
190
+ it('returns 500 with AccessConfigError payload when roles.yaml / access.md fails to load', async () => {
191
+ // Regression guard: if `canDownload` throws inside requireDownloadPermission
192
+ // (e.g. roles.yaml is missing or the access tree is unrecoverably
193
+ // malformed) and we don't catch it, the async rejection bubbles past
194
+ // Express and the request hangs (or 500s without the helpful
195
+ // config-error payload). The route should surface the
196
+ // WorkflowDomainError shape — same JSON the per-path /access endpoint
197
+ // returns when it hits the same exception — so the operator can see
198
+ // which access.md file is broken.
199
+ const configErr = new AccessConfigError([
200
+ "Knowledge/Sales/access.md: unknown role 'Product Manager'",
201
+ ]);
202
+ h = await makeHarness({ canDownload: true, canDownloadError: configErr });
203
+ const res = await fetch(
204
+ `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/file/raw?path=${encodeURIComponent('Knowledge/Foo.md')}&download=1`,
205
+ );
206
+ expect(res.status).toBe(500);
207
+ const body = (await res.json()) as { error: string; accessConfigErrors?: string[] };
208
+ expect(body.error).toContain('Access-control config is invalid');
209
+ expect(body.accessConfigErrors?.[0]).toContain("unknown role 'Product Manager'");
210
+ });
211
+
212
+ it('forces SVG to octet-stream when downloading (anti-XSS), preserves image/svg+xml inline', async () => {
213
+ h = await makeHarness({ canDownload: true });
214
+ const dl = await fetch(
215
+ `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/file/raw?path=${encodeURIComponent('icon.svg')}&download=1`,
216
+ );
217
+ expect(dl.headers.get('content-type')).toBe('application/octet-stream');
218
+
219
+ await closeServer(h.server);
220
+ h = await makeHarness({ canDownload: false });
221
+ const inline = await fetch(
222
+ `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/file/raw?path=${encodeURIComponent('icon.svg')}`,
223
+ );
224
+ expect(inline.headers.get('content-type')).toBe('image/svg+xml');
225
+ // Inline keeps the real type so the image renderer's blob still paints,
226
+ // and is sandboxed so the same URL opened as a tab cannot run its own
227
+ // <script> against this origin.
228
+ expect(inline.headers.get('content-security-policy')).toBe('sandbox');
229
+ });
230
+
231
+ // Everything else served inline is passive, and a blanket sandbox on, say, a
232
+ // PDF would cost the viewer its own controls for nothing.
233
+ it('sandboxes only SVG — other inline files carry no CSP', async () => {
234
+ h = await makeHarness({ canDownload: true });
235
+ const res = await fetch(
236
+ `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/file/raw?path=${encodeURIComponent('Knowledge/Foo.md')}`,
237
+ );
238
+ expect(res.headers.get('content-security-policy')).toBeNull();
239
+ });
240
+ });
241
+
242
+ describe('GET /workspace/:id/folder/zip — gated on Download role', () => {
243
+ let h: Harness | null = null;
244
+ afterEach(async () => { if (h) await closeServer(h.server); h = null; });
245
+
246
+ it('user with Download role gets a 200 zip with attachment disposition', async () => {
247
+ h = await makeHarness({ canDownload: true });
248
+ const res = await fetch(
249
+ `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/folder/zip?path=${encodeURIComponent('Knowledge/Sales')}&download=1`,
250
+ );
251
+ expect(res.status).toBe(200);
252
+ expect(res.headers.get('content-type')).toBe('application/zip');
253
+ expect(res.headers.get('x-content-type-options')).toBe('nosniff');
254
+ const dispo = res.headers.get('content-disposition');
255
+ expect(dispo).toBeTruthy();
256
+ expect(dispo).toContain('attachment');
257
+ expect(dispo).toContain(`filename*=UTF-8''${encodeURIComponent('Sales.zip')}`);
258
+ const buf = Buffer.from(await res.arrayBuffer());
259
+ expect(buf.equals(ZIP_BYTES)).toBe(true);
260
+ expect(h.canDownload).toHaveBeenCalledWith(WORKSPACE_ID, USER.email, 'Knowledge/Sales');
261
+ expect(h.createFolderZip).toHaveBeenCalledWith(WORKSPACE_ID, 'Knowledge/Sales');
262
+ });
263
+
264
+ it('user without Download role gets 403 and no zip is built', async () => {
265
+ h = await makeHarness({ canDownload: false });
266
+ const res = await fetch(
267
+ `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/folder/zip?path=${encodeURIComponent('Knowledge/Sales')}&download=1`,
268
+ );
269
+ expect(res.status).toBe(403);
270
+ const body = (await res.json()) as { error: string };
271
+ expect(body.error).toBe('Download permission required');
272
+ expect(h.createFolderZip).not.toHaveBeenCalled();
273
+ });
274
+
275
+ it('requires ?download=1 — a stray inline call is rejected with 400', async () => {
276
+ // Folder zip is download-only; refuse the inline shape so the URL contract
277
+ // matches the file route and there's no ambiguity about what should happen.
278
+ h = await makeHarness({ canDownload: true });
279
+ const res = await fetch(
280
+ `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/folder/zip?path=${encodeURIComponent('Knowledge/Sales')}`,
281
+ );
282
+ expect(res.status).toBe(400);
283
+ expect(h.createFolderZip).not.toHaveBeenCalled();
284
+ });
285
+
286
+ it('maps FolderTooLargeError to 413 with the limit-bearing message', async () => {
287
+ h = await makeHarness({
288
+ canDownload: true,
289
+ folderZip: async () => { throw new FolderTooLargeError(500 * 1024 * 1024); },
290
+ });
291
+ const res = await fetch(
292
+ `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/folder/zip?path=${encodeURIComponent('Big')}&download=1`,
293
+ );
294
+ expect(res.status).toBe(413);
295
+ const body = (await res.json()) as { error: string };
296
+ expect(body.error).toContain('524288000');
297
+ });
298
+
299
+ it('maps Path traversal detected to 403', async () => {
300
+ h = await makeHarness({
301
+ canDownload: true,
302
+ folderZip: async () => { throw new Error('Path traversal detected'); },
303
+ });
304
+ const res = await fetch(
305
+ `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/folder/zip?path=${encodeURIComponent('../escape')}&download=1`,
306
+ );
307
+ expect(res.status).toBe(403);
308
+ });
309
+
310
+ it('maps Not a directory to 400 (user pointed at a file)', async () => {
311
+ h = await makeHarness({
312
+ canDownload: true,
313
+ folderZip: async () => { throw new Error('Not a directory'); },
314
+ });
315
+ const res = await fetch(
316
+ `${h.baseUrl}/api/workspace/${WORKSPACE_ID}/folder/zip?path=${encodeURIComponent('Knowledge/Foo.md')}&download=1`,
317
+ );
318
+ expect(res.status).toBe(400);
319
+ });
320
+ });