@bevel-software/platform-core-backend 0.10.0 → 0.11.1

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 (188) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +12 -1
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +22 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +34 -1
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +4 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/modules/access/access-control.interface.d.ts +54 -28
  13. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  14. package/dist/modules/access/access-control.service.d.ts +129 -14
  15. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  16. package/dist/modules/access/access-control.service.js +435 -66
  17. package/dist/modules/access/access-control.service.js.map +1 -1
  18. package/dist/modules/access/access-declarations.d.ts.map +1 -1
  19. package/dist/modules/access/access-declarations.js +5 -3
  20. package/dist/modules/access/access-declarations.js.map +1 -1
  21. package/dist/modules/access/access-mutation.service.d.ts +39 -6
  22. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  23. package/dist/modules/access/access-mutation.service.js +78 -18
  24. package/dist/modules/access/access-mutation.service.js.map +1 -1
  25. package/dist/modules/access/access-splice.d.ts +31 -4
  26. package/dist/modules/access/access-splice.d.ts.map +1 -1
  27. package/dist/modules/access/access-splice.js +40 -16
  28. package/dist/modules/access/access-splice.js.map +1 -1
  29. package/dist/modules/access/access.routes.d.ts.map +1 -1
  30. package/dist/modules/access/access.routes.js +204 -82
  31. package/dist/modules/access/access.routes.js.map +1 -1
  32. package/dist/modules/access/admin-locked-commit.d.ts +134 -0
  33. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
  34. package/dist/modules/access/admin-locked-commit.js +277 -0
  35. package/dist/modules/access/admin-locked-commit.js.map +1 -0
  36. package/dist/modules/access/admin-route-helpers.d.ts +32 -0
  37. package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
  38. package/dist/modules/access/admin-route-helpers.js +44 -0
  39. package/dist/modules/access/admin-route-helpers.js.map +1 -0
  40. package/dist/modules/access/capability-registry.d.ts +41 -0
  41. package/dist/modules/access/capability-registry.d.ts.map +1 -0
  42. package/dist/modules/access/capability-registry.js +46 -0
  43. package/dist/modules/access/capability-registry.js.map +1 -0
  44. package/dist/modules/access/directory-sync-bot.d.ts +13 -0
  45. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
  46. package/dist/modules/access/directory-sync-bot.js +64 -0
  47. package/dist/modules/access/directory-sync-bot.js.map +1 -0
  48. package/dist/modules/access/group-files.d.ts +83 -0
  49. package/dist/modules/access/group-files.d.ts.map +1 -0
  50. package/dist/modules/access/group-files.js +167 -0
  51. package/dist/modules/access/group-files.js.map +1 -0
  52. package/dist/modules/access/groups-admin.routes.d.ts +19 -0
  53. package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
  54. package/dist/modules/access/groups-admin.routes.js +98 -0
  55. package/dist/modules/access/groups-admin.routes.js.map +1 -0
  56. package/dist/modules/access/groups-admin.service.d.ts +166 -0
  57. package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
  58. package/dist/modules/access/groups-admin.service.js +442 -0
  59. package/dist/modules/access/groups-admin.service.js.map +1 -0
  60. package/dist/modules/access/groups-edit.d.ts +58 -0
  61. package/dist/modules/access/groups-edit.d.ts.map +1 -0
  62. package/dist/modules/access/groups-edit.js +162 -0
  63. package/dist/modules/access/groups-edit.js.map +1 -0
  64. package/dist/modules/access/reference-scan.d.ts +141 -0
  65. package/dist/modules/access/reference-scan.d.ts.map +1 -0
  66. package/dist/modules/access/reference-scan.js +440 -0
  67. package/dist/modules/access/reference-scan.js.map +1 -0
  68. package/dist/modules/access/roles-admin.service.d.ts +88 -119
  69. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  70. package/dist/modules/access/roles-admin.service.js +230 -384
  71. package/dist/modules/access/roles-admin.service.js.map +1 -1
  72. package/dist/modules/access/roles-edit.d.ts +51 -25
  73. package/dist/modules/access/roles-edit.d.ts.map +1 -1
  74. package/dist/modules/access/roles-edit.js +133 -59
  75. package/dist/modules/access/roles-edit.js.map +1 -1
  76. package/dist/modules/access/synced-groups-committer.d.ts +28 -0
  77. package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
  78. package/dist/modules/access/synced-groups-committer.js +139 -0
  79. package/dist/modules/access/synced-groups-committer.js.map +1 -0
  80. package/dist/modules/access/synced-groups-writer.d.ts +78 -0
  81. package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
  82. package/dist/modules/access/synced-groups-writer.js +219 -0
  83. package/dist/modules/access/synced-groups-writer.js.map +1 -0
  84. package/dist/modules/database/core-schema.d.ts +17 -0
  85. package/dist/modules/database/core-schema.d.ts.map +1 -1
  86. package/dist/modules/database/core-schema.js +9 -0
  87. package/dist/modules/database/core-schema.js.map +1 -1
  88. package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
  89. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  90. package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
  91. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  92. package/dist/modules/mcp/mcp.routes.d.ts +9 -3
  93. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  94. package/dist/modules/mcp/mcp.routes.js +126 -2
  95. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  96. package/dist/modules/mcp/mcp.service.d.ts +14 -0
  97. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  98. package/dist/modules/mcp/mcp.service.js +6 -1
  99. package/dist/modules/mcp/mcp.service.js.map +1 -1
  100. package/dist/modules/workflow/file-lock.service.d.ts +11 -1
  101. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  102. package/dist/modules/workflow/file-lock.service.js +15 -1
  103. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  104. package/dist/modules/workflow/git/git.service.d.ts +34 -11
  105. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  106. package/dist/modules/workflow/git/git.service.js +165 -37
  107. package/dist/modules/workflow/git/git.service.js.map +1 -1
  108. package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
  109. package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
  110. package/dist/modules/workflow/locking-filesystem.js +181 -28
  111. package/dist/modules/workflow/locking-filesystem.js.map +1 -1
  112. package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
  113. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  114. package/dist/modules/workflow/pending-commits.service.js +19 -1
  115. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  116. package/dist/modules/workflow/workflow.service.d.ts +17 -2
  117. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  118. package/dist/modules/workflow/workflow.service.js +113 -19
  119. package/dist/modules/workflow/workflow.service.js.map +1 -1
  120. package/kb-template/AGENTS.md +4 -1
  121. package/migrations/0004_file_lock_mode.sql +2 -0
  122. package/migrations/meta/0004_snapshot.json +1564 -0
  123. package/migrations/meta/_journal.json +7 -0
  124. package/package.json +3 -3
  125. package/src/core/create-core-server.ts +13 -0
  126. package/src/core/create-core-services.ts +68 -0
  127. package/src/index.ts +10 -0
  128. package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
  129. package/src/modules/access/__tests__/access-groups.test.ts +427 -0
  130. package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
  131. package/src/modules/access/__tests__/access-splice.test.ts +65 -0
  132. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
  133. package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
  134. package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
  135. package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
  136. package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
  137. package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
  138. package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
  139. package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
  140. package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
  141. package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
  142. package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
  143. package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
  144. package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
  145. package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
  146. package/src/modules/access/access-control.interface.ts +66 -32
  147. package/src/modules/access/access-control.service.ts +536 -73
  148. package/src/modules/access/access-declarations.ts +5 -2
  149. package/src/modules/access/access-mutation.service.ts +88 -14
  150. package/src/modules/access/access-splice.ts +55 -17
  151. package/src/modules/access/access.routes.ts +227 -93
  152. package/src/modules/access/admin-locked-commit.ts +331 -0
  153. package/src/modules/access/admin-route-helpers.ts +55 -0
  154. package/src/modules/access/capability-registry.ts +74 -0
  155. package/src/modules/access/directory-sync-bot.ts +76 -0
  156. package/src/modules/access/group-files.ts +212 -0
  157. package/src/modules/access/groups-admin.routes.ts +113 -0
  158. package/src/modules/access/groups-admin.service.ts +551 -0
  159. package/src/modules/access/groups-edit.ts +187 -0
  160. package/src/modules/access/reference-scan.ts +513 -0
  161. package/src/modules/access/roles-admin.service.ts +290 -418
  162. package/src/modules/access/roles-edit.ts +134 -61
  163. package/src/modules/access/synced-groups-committer.ts +177 -0
  164. package/src/modules/access/synced-groups-writer.ts +303 -0
  165. package/src/modules/database/core-schema.ts +9 -0
  166. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
  167. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
  168. package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
  169. package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
  170. package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
  171. package/src/modules/mcp/mcp.routes.ts +137 -2
  172. package/src/modules/mcp/mcp.service.ts +6 -1
  173. package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
  174. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
  175. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
  176. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
  177. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
  178. package/src/modules/workflow/file-lock.service.ts +15 -0
  179. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
  180. package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
  181. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
  182. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
  183. package/src/modules/workflow/git/git.service.ts +174 -35
  184. package/src/modules/workflow/locking-filesystem.ts +188 -26
  185. package/src/modules/workflow/pending-commits.service.ts +27 -1
  186. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
  187. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
  188. package/src/modules/workflow/workflow.service.ts +140 -20
@@ -0,0 +1,221 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ import { AdminLockedCommits } from '../admin-locked-commit.js';
4
+ import { PushNeedsAgentResolutionError, WorkflowDomainError } from '../../workflow/workflow.errors.js';
5
+ import type { AuthUser, IWorkspaceService, IWorkflowService } from '@bevel-software/platform-shared';
6
+
7
+ const KB = 'knowledge-base';
8
+ const BRANCH = 'main';
9
+ const WS = 'ws-locked';
10
+ const ACTOR: AuthUser = { id: 'u-1', email: 'admin@x.io', name: 'Admin' } as AuthUser;
11
+
12
+ /**
13
+ * Lock-release semantics of `withFileLocks` (cubic P2: a releaseLock whose
14
+ * commit ENQUEUE fails after a successful batch commit stranded every held
15
+ * lock until TTL). Success path must release via releaseLockNoCommit (no
16
+ * enqueue involved); the push-retry path must keep releaseLock (the enqueued
17
+ * commit IS the retry); and a single failed release must not strand the rest.
18
+ */
19
+
20
+ function makeHarness(opts: {
21
+ commitError?: Error;
22
+ releaseLockError?: Error;
23
+ }) {
24
+ const files = new Map<string, string>();
25
+ const locks = new Map<string, AuthUser>();
26
+ const releaseLockCalls: string[] = [];
27
+ const releaseNoCommitCalls: string[] = [];
28
+
29
+ const workspaceService = {
30
+ getWorkspacePath: async () => '/tmp/nowhere',
31
+ readFile: async (_id: string, wsRel: string) => {
32
+ const v = files.get(wsRel);
33
+ if (v === undefined) {
34
+ const err = new Error(`ENOENT ${wsRel}`) as NodeJS.ErrnoException;
35
+ err.code = 'ENOENT';
36
+ throw err;
37
+ }
38
+ return v;
39
+ },
40
+ writeFile: async (_id: string, wsRel: string, content: string) => void files.set(wsRel, content),
41
+ deleteFile: async (_id: string, wsRel: string) => void files.delete(wsRel),
42
+ } as unknown as IWorkspaceService;
43
+
44
+ const workflowService = {
45
+ acquireLock: vi.fn(async (_w: string, _b: string, p: string, user: AuthUser) => {
46
+ if (locks.has(p)) return { acquired: false, lock: { holderUserId: 'x', holderName: 'X' } };
47
+ locks.set(p, user);
48
+ return { acquired: true, lock: { holderUserId: user.id, holderName: user.name } };
49
+ }),
50
+ releaseLock: vi.fn(async (_w: string, _b: string, p: string) => {
51
+ releaseLockCalls.push(p);
52
+ if (opts.releaseLockError) throw opts.releaseLockError; // enqueue failed → row NOT dropped
53
+ locks.delete(p);
54
+ }),
55
+ releaseLockNoCommit: vi.fn(async (_w: string, _b: string, p: string) => {
56
+ releaseNoCommitCalls.push(p);
57
+ locks.delete(p);
58
+ }),
59
+ commitChanges: vi.fn(async () => {
60
+ if (opts.commitError) throw opts.commitError;
61
+ return {};
62
+ }),
63
+ } as unknown as IWorkflowService;
64
+
65
+ const locked = new AdminLockedCommits({
66
+ workspaceService,
67
+ workflowService,
68
+ kbDirName: KB,
69
+ defaultBranchOf: () => BRANCH,
70
+ makeError: (message, status, payload) => new WorkflowDomainError(message, status, payload),
71
+ logTag: 'test-admin',
72
+ contendedSubject: 'Things',
73
+ });
74
+
75
+ return { locked, locks, files, releaseLockCalls, releaseNoCommitCalls };
76
+ }
77
+
78
+ describe('AdminLockedCommits.withFileLocks — release semantics', () => {
79
+ it('SUCCESS: batch-committed paths release via releaseLockNoCommit — a broken enqueue cannot strand them', async () => {
80
+ // releaseLock models "the pending-commit ENQUEUE fails": it throws and the
81
+ // lock row stays held. If the success path still used it, both locks would
82
+ // strand until TTL. With the fix they must all be free afterwards.
83
+ const h = makeHarness({ releaseLockError: new Error('enqueue exploded: sqlite disk I/O error') });
84
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
85
+ try {
86
+ await h.locked.withFileLocks(WS, ACTOR, ['groups.yaml', 'roles.yaml'], async () => {
87
+ await h.locked.writeAndCommitLocked(
88
+ WS,
89
+ ACTOR,
90
+ [
91
+ { repoRel: 'groups.yaml', content: 'groups: {}\n', original: null },
92
+ { repoRel: 'roles.yaml', content: 'roles: {}\n', original: 'roles:\n' },
93
+ ],
94
+ 'test commit',
95
+ );
96
+ });
97
+ expect(h.locks.size).toBe(0); // ZERO held locks
98
+ expect(h.releaseNoCommitCalls.sort()).toEqual([`${KB}/groups.yaml`, `${KB}/roles.yaml`]);
99
+ expect(h.releaseLockCalls).toEqual([]); // the queue was never touched
100
+ } finally {
101
+ warn.mockRestore();
102
+ }
103
+ });
104
+
105
+ it('POST-COMMIT FAILURE: fn throwing after the batch landed still releases committed paths without the queue', async () => {
106
+ // The commit+push succeeded; a later step inside fn (roster read, event
107
+ // emission) blew up. The failure says nothing about the tree — the
108
+ // committed paths are clean, and enqueueing a release commit for them
109
+ // risks stranding a known-clean lock on a broken queue.
110
+ const h = makeHarness({ releaseLockError: new Error('enqueue exploded') });
111
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
112
+ try {
113
+ await expect(
114
+ h.locked.withFileLocks(WS, ACTOR, ['groups.yaml', 'roles.yaml'], async () => {
115
+ await h.locked.writeAndCommitLocked(
116
+ WS,
117
+ ACTOR,
118
+ [
119
+ { repoRel: 'groups.yaml', content: 'groups: {}\n', original: null },
120
+ { repoRel: 'roles.yaml', content: 'roles: {}\n', original: 'roles:\n' },
121
+ ],
122
+ 'test commit',
123
+ );
124
+ throw new Error('post-commit boom');
125
+ }),
126
+ ).rejects.toThrow(/post-commit boom/);
127
+ expect(h.locks.size).toBe(0); // ZERO held locks
128
+ expect(h.releaseNoCommitCalls.sort()).toEqual([`${KB}/groups.yaml`, `${KB}/roles.yaml`]);
129
+ expect(h.releaseLockCalls).toEqual([]); // the queue was never touched
130
+ } finally {
131
+ warn.mockRestore();
132
+ }
133
+ });
134
+
135
+ it('SUCCESS: a held path the batch never committed keeps commit-on-release (releaseLock)', async () => {
136
+ // A prior holder's queued work could still be dirty on such a path — a
137
+ // discard would destroy it, so the untouched lock releases the old way.
138
+ const h = makeHarness({});
139
+ await h.locked.withFileLocks(WS, ACTOR, ['groups.yaml', 'roles.yaml'], async () => {
140
+ await h.locked.writeAndCommitLocked(
141
+ WS,
142
+ ACTOR,
143
+ [{ repoRel: 'roles.yaml', content: 'roles: {}\n', original: 'roles:\n' }],
144
+ 'test commit',
145
+ );
146
+ });
147
+ expect(h.locks.size).toBe(0);
148
+ expect(h.releaseNoCommitCalls).toEqual([`${KB}/roles.yaml`]);
149
+ expect(h.releaseLockCalls).toEqual([`${KB}/groups.yaml`]);
150
+ });
151
+
152
+ it('PUSH-RETRY: PushNeedsAgentResolutionError keeps releaseLock (the enqueued commit retries the push)', async () => {
153
+ const h = makeHarness({
154
+ commitError: new PushNeedsAgentResolutionError(BRANCH, `${KB}/roles.yaml`, 'push refused', 'no retry'),
155
+ });
156
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
157
+ try {
158
+ await expect(
159
+ h.locked.withFileLocks(WS, ACTOR, ['roles.yaml'], async () => {
160
+ await h.locked.writeAndCommitLocked(
161
+ WS,
162
+ ACTOR,
163
+ [{ repoRel: 'roles.yaml', content: 'roles: {}\n', original: 'roles:\n' }],
164
+ 'test commit',
165
+ );
166
+ }),
167
+ ).rejects.toBeInstanceOf(PushNeedsAgentResolutionError);
168
+ expect(h.releaseLockCalls).toEqual([`${KB}/roles.yaml`]); // releaseLock, NOT noCommit
169
+ expect(h.releaseNoCommitCalls).toEqual([]);
170
+ expect(h.locks.size).toBe(0);
171
+ } finally {
172
+ warn.mockRestore();
173
+ }
174
+ });
175
+
176
+ it('PUSH-RETRY: one failed release logs and continues — the REST of the locks are still released', async () => {
177
+ const h = makeHarness({
178
+ commitError: new PushNeedsAgentResolutionError(BRANCH, `${KB}/roles.yaml`, 'push refused', 'no retry'),
179
+ releaseLockError: new Error('enqueue exploded'),
180
+ });
181
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
182
+ try {
183
+ await expect(
184
+ h.locked.withFileLocks(WS, ACTOR, ['a.yaml', 'b.yaml'], async () => {
185
+ await h.locked.writeAndCommitLocked(
186
+ WS,
187
+ ACTOR,
188
+ [
189
+ { repoRel: 'a.yaml', content: 'a\n', original: null },
190
+ { repoRel: 'b.yaml', content: 'b\n', original: null },
191
+ ],
192
+ 'test commit',
193
+ );
194
+ }),
195
+ ).rejects.toBeInstanceOf(PushNeedsAgentResolutionError);
196
+ // BOTH releases were attempted despite the first one throwing.
197
+ expect(h.releaseLockCalls.sort()).toEqual([`${KB}/a.yaml`, `${KB}/b.yaml`]);
198
+ expect(warn).toHaveBeenCalled();
199
+ } finally {
200
+ warn.mockRestore();
201
+ }
202
+ });
203
+
204
+ it('FAILURE with restore: commit-on-release is kept (restored bytes are clean; queued work preserved)', async () => {
205
+ const h = makeHarness({ commitError: new Error('commit exploded') });
206
+ await expect(
207
+ h.locked.withFileLocks(WS, ACTOR, ['roles.yaml'], async () => {
208
+ await h.locked.writeAndCommitLocked(
209
+ WS,
210
+ ACTOR,
211
+ [{ repoRel: 'roles.yaml', content: 'roles: {}\n', original: 'roles:\n' }],
212
+ 'test commit',
213
+ );
214
+ }),
215
+ ).rejects.toThrow('commit exploded');
216
+ expect(h.releaseLockCalls).toEqual([`${KB}/roles.yaml`]);
217
+ expect(h.releaseNoCommitCalls).toEqual([]);
218
+ expect(h.locks.size).toBe(0);
219
+ expect(h.files.get(`${KB}/roles.yaml`)).toBe('roles:\n'); // restored
220
+ });
221
+ });
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+
3
+ import { toHttpError, requireNonEmptyString } from '../admin-route-helpers.js';
4
+ import { WorkflowDomainError } from '../../workflow/workflow.errors.js';
5
+ import { AccessMutationError } from '../access-mutation.service.js';
6
+
7
+ describe('toHttpError — the shared access-family error shape', () => {
8
+ afterEach(() => vi.restoreAllMocks());
9
+
10
+ it('a sub-500 domain error keeps its status, message, and payload', () => {
11
+ const err = new WorkflowDomainError('groups are being edited', 409, { kind: 'idp-mode' });
12
+ expect(toHttpError(err, 'test')).toEqual({
13
+ status: 409,
14
+ body: { error: 'groups are being edited', kind: 'idp-mode' },
15
+ });
16
+ });
17
+
18
+ it('a 500-status DOMAIN error never leaks its message — generic body, full server-side log', () => {
19
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
20
+ // AccessMutationService wraps unexpected raw errors (fs/db failures) in a
21
+ // 500 AccessMutationError with the RAW message — that must not reach the
22
+ // client.
23
+ const err = new AccessMutationError("ENOENT: open '/srv/kb/secret-folder/access.md'", 500);
24
+ const { status, body } = toHttpError(err, 'test');
25
+ expect(status).toBe(500);
26
+ expect(body).toEqual({ error: 'Internal error.' });
27
+ expect(JSON.stringify(body)).not.toContain('secret-folder');
28
+ expect(spy).toHaveBeenCalled(); // the details land server-side
29
+ });
30
+
31
+ it('a raw (non-domain) error is a generic 500', () => {
32
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
33
+ expect(toHttpError(new Error('db connection string postgres://u:p@host'), 'test')).toEqual({
34
+ status: 500,
35
+ body: { error: 'Internal error.' },
36
+ });
37
+ });
38
+
39
+ it("a payload key named 'error' cannot overwrite the client-facing message", () => {
40
+ const err = new WorkflowDomainError('the real message', 422, { error: 'spoofed', kind: 'x' });
41
+ const { body } = toHttpError(err, 'test');
42
+ expect(body.error).toBe('the real message');
43
+ expect(body.kind).toBe('x');
44
+ });
45
+ });
46
+
47
+ describe('requireNonEmptyString', () => {
48
+ it('accepts a plain string and rejects non-strings with a 400 domain error', () => {
49
+ expect(requireNonEmptyString('ok', 'field')).toBe('ok');
50
+ for (const bad of [42, null, undefined, ['a'], { a: 1 }, ' ']) {
51
+ let caught: unknown;
52
+ try {
53
+ requireNonEmptyString(bad, 'field');
54
+ } catch (err) {
55
+ caught = err;
56
+ }
57
+ expect(caught).toBeInstanceOf(WorkflowDomainError);
58
+ expect((caught as WorkflowDomainError).status).toBe(400);
59
+ }
60
+ });
61
+ });
@@ -0,0 +1,106 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ import type { Database } from '../../database/connection.js';
4
+ import {
5
+ DIRECTORY_SYNC_BOT_EMAIL,
6
+ DIRECTORY_SYNC_BOT_NAME,
7
+ ensureDirectorySyncBot,
8
+ } from '../directory-sync-bot.js';
9
+
10
+ interface Row {
11
+ id: string;
12
+ email: string;
13
+ name: string;
14
+ avatarUrl: string | null;
15
+ passwordHash: string | null;
16
+ }
17
+
18
+ /** Minimal fake of the two drizzle chains `ensureDirectorySyncBot` runs. */
19
+ function fakeDb(opts: { existing: Row | null }): Database {
20
+ const created: Row = {
21
+ id: 'bot-row',
22
+ email: DIRECTORY_SYNC_BOT_EMAIL,
23
+ name: DIRECTORY_SYNC_BOT_NAME,
24
+ avatarUrl: null,
25
+ passwordHash: null,
26
+ };
27
+ return {
28
+ insert: () => ({
29
+ values: () => ({
30
+ onConflictDoNothing: () => ({
31
+ // Conflict (row exists) → empty; otherwise the fresh bot row.
32
+ returning: async () => (opts.existing ? [] : [created]),
33
+ }),
34
+ }),
35
+ }),
36
+ select: () => ({
37
+ from: () => ({
38
+ where: () => ({
39
+ limit: async () => (opts.existing ? [opts.existing] : []),
40
+ }),
41
+ }),
42
+ }),
43
+ } as unknown as Database;
44
+ }
45
+
46
+ describe('ensureDirectorySyncBot — the bot identity is exclusive', () => {
47
+ it('creates the bot row when the email is free', async () => {
48
+ const bot = await ensureDirectorySyncBot(fakeDb({ existing: null }));
49
+ expect(bot).toMatchObject({ email: DIRECTORY_SYNC_BOT_EMAIL, name: DIRECTORY_SYNC_BOT_NAME });
50
+ });
51
+
52
+ it('reuses an existing row only when it IS the bot', async () => {
53
+ const bot = await ensureDirectorySyncBot(
54
+ fakeDb({
55
+ existing: {
56
+ id: 'prev-bot',
57
+ email: DIRECTORY_SYNC_BOT_EMAIL,
58
+ name: DIRECTORY_SYNC_BOT_NAME,
59
+ avatarUrl: null,
60
+ passwordHash: null,
61
+ },
62
+ }),
63
+ );
64
+ expect(bot.id).toBe('prev-bot');
65
+ });
66
+
67
+ it('REFUSES to bind the materializer to a pre-existing human account under the bot email', async () => {
68
+ // The machine-owned write rule on synced-groups.yaml authorizes by this
69
+ // email alone — silently adopting a human's row would attribute every
70
+ // sync commit to them and hand their session the bot's write authority.
71
+ await expect(
72
+ ensureDirectorySyncBot(
73
+ fakeDb({
74
+ existing: {
75
+ id: 'human-1',
76
+ email: DIRECTORY_SYNC_BOT_EMAIL,
77
+ name: 'Mallory Human',
78
+ avatarUrl: null,
79
+ passwordHash: null,
80
+ },
81
+ }),
82
+ ),
83
+ ).rejects.toThrow(/already belongs to an existing account/);
84
+ });
85
+
86
+ it('REFUSES a password-capable row even when it mimics the bot display name', async () => {
87
+ // A display name is not an identity marker — anyone can carry the bot's
88
+ // name. A password hash IS hard evidence: the bot never signs in and is
89
+ // never provisioned credentials, so a login-capable row under the bot
90
+ // email is a person's account (or a credential a person could use) and
91
+ // must never be adopted as the materializer's identity.
92
+ await expect(
93
+ ensureDirectorySyncBot(
94
+ fakeDb({
95
+ existing: {
96
+ id: 'human-2',
97
+ email: DIRECTORY_SYNC_BOT_EMAIL,
98
+ name: DIRECTORY_SYNC_BOT_NAME,
99
+ avatarUrl: null,
100
+ passwordHash: 'scrypt:deadbeef',
101
+ },
102
+ }),
103
+ ),
104
+ ).rejects.toThrow(/can sign in with a password/);
105
+ });
106
+ });
@@ -202,3 +202,70 @@ describe('AccessControlService.grantSources', () => {
202
202
  expect(src.download).toEqual([{ kind: 'direct' }]);
203
203
  });
204
204
  });
205
+
206
+ describe('grantSources — principal kind under group shadowing', () => {
207
+ let root: string;
208
+ let repo: string;
209
+ let svc: AccessControlService;
210
+ const workspaceId = 'ws-gs-shadow';
211
+
212
+ beforeEach(async () => {
213
+ root = await mkTmpRoot();
214
+ const workspaceDir = path.join(root, workspaceId);
215
+ repo = path.join(workspaceDir, PROCESS_MAP_DIR);
216
+ await fs.mkdir(repo, { recursive: true });
217
+ await writeFile(repo, 'roles.yaml', ROLES_YAML);
218
+ // A GROUP named Engineer shadows the Engineer ROLE: the bare token is the
219
+ // group's, `role/engineer` is the role's.
220
+ await writeFile(repo, 'groups.yaml', 'groups:\n Engineer:\n - pat@x.io\n');
221
+ svc = new AccessControlService(stubWorkspaceService(workspaceId, workspaceDir), PROCESS_MAP_DIR);
222
+ });
223
+
224
+ afterEach(async () => {
225
+ await fs.rm(root, { recursive: true, force: true });
226
+ });
227
+
228
+ it("a shadowed BARE grant belongs to the GROUP — it must not hide the role's ancestor grant", async () => {
229
+ // The cubic case: bare (group) grant on the target, role/ grant on the
230
+ // ancestor. Attributing the bare token to the role would report the role
231
+ // as `direct` here — hiding the real role/<Name> ancestor grant and
232
+ // making its revoke a false no-op.
233
+ await writeFile(repo, 'access.md', '---\nwrite:\n - role/Engineer\n---\n');
234
+ await writeFile(repo, 'Knowledge/access.md', '---\nwrite:\n - Engineer\n---\n');
235
+
236
+ const roleSrc = await svc.grantSources(workspaceId, 'folder', 'Knowledge', {
237
+ kind: 'role',
238
+ role: 'role/Engineer',
239
+ });
240
+ // The ROLE's only entry is the ancestor role/ grant.
241
+ expect(roleSrc.write).toEqual([{ kind: 'ancestor', path: 'access.md' }]);
242
+
243
+ const groupSrc = await svc.grantSources(workspaceId, 'folder', 'Knowledge', {
244
+ kind: 'role',
245
+ role: 'Engineer', // bare = whoever owns the bare key = the group
246
+ });
247
+ // The GROUP's only entry is the direct bare grant.
248
+ expect(groupSrc.write).toEqual([{ kind: 'direct' }]);
249
+ });
250
+
251
+ it("a shadowed role/ grant is never attributed to the GROUP principal", async () => {
252
+ await writeFile(repo, 'access.md', '---\nwrite:\n - Admin\n---\n');
253
+ await writeFile(repo, 'Knowledge/access.md', '---\nwrite:\n - role/Engineer\n---\n');
254
+ const groupSrc = await svc.grantSources(workspaceId, 'folder', 'Knowledge', {
255
+ kind: 'role',
256
+ role: 'Engineer',
257
+ });
258
+ expect(groupSrc).toEqual({}); // the group holds nothing here
259
+ });
260
+
261
+ it('UNSHADOWED both spellings still count as the role (legacy bare grants stay removable)', async () => {
262
+ await fs.rm(path.join(repo, 'groups.yaml'));
263
+ await writeFile(repo, 'access.md', '---\nwrite:\n - role/Engineer\n---\n');
264
+ await writeFile(repo, 'Knowledge/access.md', '---\nwrite:\n - Engineer\n---\n');
265
+ const src = await svc.grantSources(workspaceId, 'folder', 'Knowledge', {
266
+ kind: 'role',
267
+ role: 'Engineer',
268
+ });
269
+ expect(src.write).toEqual([{ kind: 'direct' }, { kind: 'ancestor', path: 'access.md' }]);
270
+ });
271
+ });