@bevel-software/platform-core-backend 0.9.1 → 0.11.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 (212) hide show
  1. package/THIRD-PARTY-NOTICES.md +2 -2
  2. package/dist/core/create-core-server.d.ts.map +1 -1
  3. package/dist/core/create-core-server.js +16 -1
  4. package/dist/core/create-core-server.js.map +1 -1
  5. package/dist/core/create-core-services.d.ts +25 -0
  6. package/dist/core/create-core-services.d.ts.map +1 -1
  7. package/dist/core/create-core-services.js +45 -1
  8. package/dist/core/create-core-services.js.map +1 -1
  9. package/dist/core-config.d.ts +19 -1
  10. package/dist/core-config.d.ts.map +1 -1
  11. package/dist/core-config.js +44 -4
  12. package/dist/core-config.js.map +1 -1
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +4 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/modules/access/access-control.interface.d.ts +54 -28
  18. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  19. package/dist/modules/access/access-control.service.d.ts +129 -14
  20. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  21. package/dist/modules/access/access-control.service.js +435 -66
  22. package/dist/modules/access/access-control.service.js.map +1 -1
  23. package/dist/modules/access/access-declarations.d.ts.map +1 -1
  24. package/dist/modules/access/access-declarations.js +5 -3
  25. package/dist/modules/access/access-declarations.js.map +1 -1
  26. package/dist/modules/access/access-mutation.service.d.ts +39 -6
  27. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-mutation.service.js +78 -18
  29. package/dist/modules/access/access-mutation.service.js.map +1 -1
  30. package/dist/modules/access/access-splice.d.ts +31 -4
  31. package/dist/modules/access/access-splice.d.ts.map +1 -1
  32. package/dist/modules/access/access-splice.js +40 -16
  33. package/dist/modules/access/access-splice.js.map +1 -1
  34. package/dist/modules/access/access.routes.d.ts.map +1 -1
  35. package/dist/modules/access/access.routes.js +204 -82
  36. package/dist/modules/access/access.routes.js.map +1 -1
  37. package/dist/modules/access/admin-locked-commit.d.ts +134 -0
  38. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
  39. package/dist/modules/access/admin-locked-commit.js +277 -0
  40. package/dist/modules/access/admin-locked-commit.js.map +1 -0
  41. package/dist/modules/access/admin-route-helpers.d.ts +32 -0
  42. package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
  43. package/dist/modules/access/admin-route-helpers.js +44 -0
  44. package/dist/modules/access/admin-route-helpers.js.map +1 -0
  45. package/dist/modules/access/capability-registry.d.ts +41 -0
  46. package/dist/modules/access/capability-registry.d.ts.map +1 -0
  47. package/dist/modules/access/capability-registry.js +46 -0
  48. package/dist/modules/access/capability-registry.js.map +1 -0
  49. package/dist/modules/access/directory-sync-bot.d.ts +13 -0
  50. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
  51. package/dist/modules/access/directory-sync-bot.js +64 -0
  52. package/dist/modules/access/directory-sync-bot.js.map +1 -0
  53. package/dist/modules/access/group-files.d.ts +83 -0
  54. package/dist/modules/access/group-files.d.ts.map +1 -0
  55. package/dist/modules/access/group-files.js +167 -0
  56. package/dist/modules/access/group-files.js.map +1 -0
  57. package/dist/modules/access/groups-admin.routes.d.ts +19 -0
  58. package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
  59. package/dist/modules/access/groups-admin.routes.js +98 -0
  60. package/dist/modules/access/groups-admin.routes.js.map +1 -0
  61. package/dist/modules/access/groups-admin.service.d.ts +166 -0
  62. package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
  63. package/dist/modules/access/groups-admin.service.js +442 -0
  64. package/dist/modules/access/groups-admin.service.js.map +1 -0
  65. package/dist/modules/access/groups-edit.d.ts +58 -0
  66. package/dist/modules/access/groups-edit.d.ts.map +1 -0
  67. package/dist/modules/access/groups-edit.js +162 -0
  68. package/dist/modules/access/groups-edit.js.map +1 -0
  69. package/dist/modules/access/reference-scan.d.ts +141 -0
  70. package/dist/modules/access/reference-scan.d.ts.map +1 -0
  71. package/dist/modules/access/reference-scan.js +440 -0
  72. package/dist/modules/access/reference-scan.js.map +1 -0
  73. package/dist/modules/access/roles-admin.service.d.ts +88 -119
  74. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  75. package/dist/modules/access/roles-admin.service.js +230 -384
  76. package/dist/modules/access/roles-admin.service.js.map +1 -1
  77. package/dist/modules/access/roles-edit.d.ts +51 -25
  78. package/dist/modules/access/roles-edit.d.ts.map +1 -1
  79. package/dist/modules/access/roles-edit.js +133 -59
  80. package/dist/modules/access/roles-edit.js.map +1 -1
  81. package/dist/modules/access/synced-groups-committer.d.ts +28 -0
  82. package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
  83. package/dist/modules/access/synced-groups-committer.js +139 -0
  84. package/dist/modules/access/synced-groups-committer.js.map +1 -0
  85. package/dist/modules/access/synced-groups-writer.d.ts +78 -0
  86. package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
  87. package/dist/modules/access/synced-groups-writer.js +219 -0
  88. package/dist/modules/access/synced-groups-writer.js.map +1 -0
  89. package/dist/modules/database/core-schema.d.ts +17 -0
  90. package/dist/modules/database/core-schema.d.ts.map +1 -1
  91. package/dist/modules/database/core-schema.js +9 -0
  92. package/dist/modules/database/core-schema.js.map +1 -1
  93. package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
  94. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  95. package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
  96. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  97. package/dist/modules/mcp/mcp.routes.d.ts +9 -3
  98. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  99. package/dist/modules/mcp/mcp.routes.js +126 -2
  100. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  101. package/dist/modules/mcp/mcp.service.d.ts +14 -0
  102. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  103. package/dist/modules/mcp/mcp.service.js +6 -1
  104. package/dist/modules/mcp/mcp.service.js.map +1 -1
  105. package/dist/modules/update-check/update-check.routes.d.ts +13 -0
  106. package/dist/modules/update-check/update-check.routes.d.ts.map +1 -0
  107. package/dist/modules/update-check/update-check.routes.js +21 -0
  108. package/dist/modules/update-check/update-check.routes.js.map +1 -0
  109. package/dist/modules/update-check/update-check.service.d.ts +57 -0
  110. package/dist/modules/update-check/update-check.service.d.ts.map +1 -0
  111. package/dist/modules/update-check/update-check.service.js +109 -0
  112. package/dist/modules/update-check/update-check.service.js.map +1 -0
  113. package/dist/modules/workflow/file-lock.service.d.ts +11 -1
  114. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  115. package/dist/modules/workflow/file-lock.service.js +15 -1
  116. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  117. package/dist/modules/workflow/git/git.service.d.ts +34 -11
  118. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  119. package/dist/modules/workflow/git/git.service.js +173 -37
  120. package/dist/modules/workflow/git/git.service.js.map +1 -1
  121. package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
  122. package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
  123. package/dist/modules/workflow/locking-filesystem.js +181 -28
  124. package/dist/modules/workflow/locking-filesystem.js.map +1 -1
  125. package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
  126. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  127. package/dist/modules/workflow/pending-commits.service.js +19 -1
  128. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  129. package/dist/modules/workflow/workflow.service.d.ts +17 -2
  130. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  131. package/dist/modules/workflow/workflow.service.js +113 -19
  132. package/dist/modules/workflow/workflow.service.js.map +1 -1
  133. package/dist/version.d.ts +14 -0
  134. package/dist/version.d.ts.map +1 -1
  135. package/dist/version.js +25 -0
  136. package/dist/version.js.map +1 -1
  137. package/kb-template/AGENTS.md +4 -1
  138. package/migrations/0004_file_lock_mode.sql +2 -0
  139. package/migrations/meta/0004_snapshot.json +1564 -0
  140. package/migrations/meta/_journal.json +7 -0
  141. package/package.json +4 -4
  142. package/src/__tests__/core-config.domain.test.ts +92 -0
  143. package/src/core/create-core-server.ts +21 -0
  144. package/src/core/create-core-services.ts +82 -0
  145. package/src/core-config.ts +48 -4
  146. package/src/index.ts +10 -0
  147. package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
  148. package/src/modules/access/__tests__/access-groups.test.ts +427 -0
  149. package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
  150. package/src/modules/access/__tests__/access-splice.test.ts +65 -0
  151. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
  152. package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
  153. package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
  154. package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
  155. package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
  156. package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
  157. package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
  158. package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
  159. package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
  160. package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
  161. package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
  162. package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
  163. package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
  164. package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
  165. package/src/modules/access/access-control.interface.ts +66 -32
  166. package/src/modules/access/access-control.service.ts +536 -73
  167. package/src/modules/access/access-declarations.ts +5 -2
  168. package/src/modules/access/access-mutation.service.ts +88 -14
  169. package/src/modules/access/access-splice.ts +55 -17
  170. package/src/modules/access/access.routes.ts +227 -93
  171. package/src/modules/access/admin-locked-commit.ts +331 -0
  172. package/src/modules/access/admin-route-helpers.ts +55 -0
  173. package/src/modules/access/capability-registry.ts +74 -0
  174. package/src/modules/access/directory-sync-bot.ts +76 -0
  175. package/src/modules/access/group-files.ts +212 -0
  176. package/src/modules/access/groups-admin.routes.ts +113 -0
  177. package/src/modules/access/groups-admin.service.ts +551 -0
  178. package/src/modules/access/groups-edit.ts +187 -0
  179. package/src/modules/access/reference-scan.ts +513 -0
  180. package/src/modules/access/roles-admin.service.ts +290 -418
  181. package/src/modules/access/roles-edit.ts +134 -61
  182. package/src/modules/access/synced-groups-committer.ts +177 -0
  183. package/src/modules/access/synced-groups-writer.ts +303 -0
  184. package/src/modules/database/core-schema.ts +9 -0
  185. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
  186. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
  187. package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
  188. package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
  189. package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
  190. package/src/modules/mcp/mcp.routes.ts +137 -2
  191. package/src/modules/mcp/mcp.service.ts +6 -1
  192. package/src/modules/update-check/__tests__/update-check.routes.test.ts +69 -0
  193. package/src/modules/update-check/__tests__/update-check.service.test.ts +170 -0
  194. package/src/modules/update-check/update-check.routes.ts +28 -0
  195. package/src/modules/update-check/update-check.service.ts +130 -0
  196. package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
  197. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
  198. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
  199. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
  200. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
  201. package/src/modules/workflow/file-lock.service.ts +15 -0
  202. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
  203. package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
  204. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
  205. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
  206. package/src/modules/workflow/git/git.service.ts +182 -35
  207. package/src/modules/workflow/locking-filesystem.ts +188 -26
  208. package/src/modules/workflow/pending-commits.service.ts +27 -1
  209. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
  210. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
  211. package/src/modules/workflow/workflow.service.ts +140 -20
  212. package/src/version.ts +28 -0
@@ -0,0 +1,238 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+
6
+ import type { AuthUser, IWorkflowService } from '@bevel-software/platform-shared';
7
+ import type { WorkspaceService } from '../../workspace/workspace.service.js';
8
+ import type { AccessControlService } from '../access-control.service.js';
9
+ import type { WorkflowEventBus } from '../../workflow/event-bus.js';
10
+ import { createSyncedGroupsCommitter } from '../synced-groups-committer.js';
11
+ import { PushNeedsAgentResolutionError, WorkflowValidationError } from '../../workflow/workflow.errors.js';
12
+
13
+ const KB = 'knowledge-base';
14
+ const BOT: AuthUser = { id: 'bot-1', email: 'directory-sync@bevel.local', name: 'Directory Sync Bot' };
15
+
16
+ describe('createSyncedGroupsCommitter.persist — post-commit push failures', () => {
17
+ let root: string;
18
+
19
+ beforeEach(async () => {
20
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-sg-committer-'));
21
+ await fs.mkdir(path.join(root, KB), { recursive: true });
22
+ });
23
+
24
+ afterEach(async () => {
25
+ await fs.rm(root, { recursive: true, force: true });
26
+ });
27
+
28
+ function makeDeps(
29
+ workflowOverrides: Partial<
30
+ Record<'commitChanges' | 'releaseLock' | 'hasQueuedCommit' | 'hasUnpushedCommits', unknown>
31
+ > = {},
32
+ ) {
33
+ const workspaceService = {
34
+ getOrCreateForBranch: vi.fn(async () => ({})),
35
+ getWorkspacePath: vi.fn(async () => root),
36
+ readFile: vi.fn(async (_id: string, wsRel: string) =>
37
+ fs.readFile(path.join(root, wsRel), 'utf-8'),
38
+ ),
39
+ } as unknown as WorkspaceService;
40
+ const releaseLock = vi.fn(async () => undefined);
41
+ const releaseLockNoCommit = vi.fn(async () => undefined);
42
+ const releaseLockUntouched = vi.fn(async () => undefined);
43
+ const hasQueuedCommit = vi.fn(async () => false);
44
+ // Default: the branch still carries the landed-but-unpushed commit — the
45
+ // truthful answer everywhere except the worker-won-the-race scenario.
46
+ const hasUnpushedCommits = vi.fn(async () => true);
47
+ const workflowService = {
48
+ acquireLock: vi.fn(async () => ({ acquired: true, lock: {} })),
49
+ releaseLock,
50
+ releaseLockNoCommit,
51
+ releaseLockUntouched,
52
+ hasQueuedCommit,
53
+ hasUnpushedCommits,
54
+ commitChanges: vi.fn(async () => ({})),
55
+ ...workflowOverrides,
56
+ } as unknown as IWorkflowService;
57
+ const accessControl = { invalidate: vi.fn() } as unknown as AccessControlService;
58
+ const eventBus = { emit: vi.fn() } as unknown as WorkflowEventBus;
59
+ const committer = createSyncedGroupsCommitter({
60
+ workspaceService,
61
+ workflowService,
62
+ accessControl,
63
+ eventBus,
64
+ kbDirName: KB,
65
+ bot: BOT,
66
+ defaultBranchOf: () => 'main',
67
+ });
68
+ return { committer, releaseLock, releaseLockNoCommit, hasQueuedCommit };
69
+ }
70
+
71
+ it('resolves (edit is saved) when only the PUSH failed after a landed commit', async () => {
72
+ // The regression this pins: a push failure after a successful local commit
73
+ // used to reject persist — the next sync then read the committed bytes,
74
+ // saw a no-op, and the update stayed unpublished forever. A landed commit
75
+ // is a saved edit; the pending-commits ladder retries the share.
76
+ const { committer, releaseLock, releaseLockNoCommit } = makeDeps({
77
+ commitChanges: vi.fn(async () => {
78
+ throw new PushNeedsAgentResolutionError('main', '(batch)', 'non-fast-forward', 'rebase failed');
79
+ }),
80
+ });
81
+ await expect(committer.persist('groups:\n Team:\n - a@x.io\n')).resolves.toBeUndefined();
82
+ // The bytes landed on disk (write happened before the commit attempt)...
83
+ expect(await fs.readFile(path.join(root, KB, 'synced-groups.yaml'), 'utf-8')).toContain('a@x.io');
84
+ // ...and the lock released through the RETRY-ARMING path, never the discard.
85
+ expect(releaseLock).toHaveBeenCalled();
86
+ expect(releaseLockNoCommit).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it('verifies the QUEUE when the arm probe answers lock-not-held, and resolves on a live row', async () => {
90
+ // In production the writeFiles push-retry release has usually SUCCEEDED —
91
+ // enqueue first, then drop the row — so the committer's verification
92
+ // release finds no lock to release. But lock-not-held alone proves
93
+ // nothing: a release that died BEFORE its enqueue (expired/stolen lock)
94
+ // leaves the exact same answer. Only the queue itself can prove the
95
+ // vehicle — persist must consult it and resolve when a row exists.
96
+ const releaseLock = vi
97
+ .fn()
98
+ // writeFiles' own push-retry release: succeeded (row enqueued + dropped).
99
+ .mockResolvedValueOnce(undefined)
100
+ // The committer's arm probe: nothing left to release.
101
+ .mockRejectedValue(
102
+ new WorkflowValidationError('Cannot release lock: not held by you.', {
103
+ kind: 'lock-not-held',
104
+ }),
105
+ );
106
+ const hasQueuedCommit = vi.fn(async () => true);
107
+ const { committer } = makeDeps({
108
+ commitChanges: vi.fn(async () => {
109
+ throw new PushNeedsAgentResolutionError('main', '(batch)', 'non-fast-forward', 'rebase failed');
110
+ }),
111
+ releaseLock,
112
+ hasQueuedCommit,
113
+ });
114
+ await expect(committer.persist('groups:\n Team:\n - a@x.io\n')).resolves.toBeUndefined();
115
+ expect(releaseLock).toHaveBeenCalledTimes(2);
116
+ expect(hasQueuedCommit).toHaveBeenCalledWith('main', 'main', `${KB}/synced-groups.yaml`);
117
+ });
118
+
119
+ it('REJECTS on lock-not-held with NO live queue row (release died before its enqueue)', async () => {
120
+ // The inference hole this pins: the ORIGINAL push-retry release hit an
121
+ // expired/stolen lock — releaseLock throws lock-not-held BEFORE its
122
+ // enqueue, writeFiles swallows it — and the committer's probe then gets
123
+ // the same lock-not-held. The old "lock gone ⇒ armed" inference reported
124
+ // success while NO pending row existed and the landed commit stayed
125
+ // unpublished forever. With the queue consulted directly, an empty queue
126
+ // must surface the original push failure.
127
+ const releaseLock = vi.fn().mockRejectedValue(
128
+ new WorkflowValidationError('Cannot release lock: not held by you.', {
129
+ kind: 'lock-not-held',
130
+ }),
131
+ );
132
+ const hasQueuedCommit = vi.fn(async () => false);
133
+ const { committer } = makeDeps({
134
+ commitChanges: vi.fn(async () => {
135
+ throw new PushNeedsAgentResolutionError('main', '(batch)', 'non-fast-forward', 'rebase failed');
136
+ }),
137
+ releaseLock,
138
+ hasQueuedCommit,
139
+ });
140
+ await expect(committer.persist('groups:\n Team:\n - a@x.io\n')).rejects.toBeInstanceOf(
141
+ PushNeedsAgentResolutionError,
142
+ );
143
+ expect(hasQueuedCommit).toHaveBeenCalled();
144
+ });
145
+
146
+ it('resolves when the worker DRAINED the row between release and probe (nothing left unpushed)', async () => {
147
+ // The race the unpushed check closes: the pending-commits worker grabbed
148
+ // the freshly enqueued row and pushed it in the window between writeFiles'
149
+ // release and the committer's probe. The queue answers "no live row" —
150
+ // truthfully — but rethrowing would report a directory-sync failure AFTER
151
+ // successful publication. A branch with nothing left unpushed proves the
152
+ // worker won; persist must resolve.
153
+ const releaseLock = vi
154
+ .fn()
155
+ .mockResolvedValueOnce(undefined) // writeFiles' release: enqueued + dropped
156
+ .mockRejectedValue(
157
+ new WorkflowValidationError('Cannot release lock: not held by you.', {
158
+ kind: 'lock-not-held',
159
+ }),
160
+ );
161
+ const { committer } = makeDeps({
162
+ commitChanges: vi.fn(async () => {
163
+ throw new PushNeedsAgentResolutionError('main', '(batch)', 'non-fast-forward', 'rebase failed');
164
+ }),
165
+ releaseLock,
166
+ hasQueuedCommit: vi.fn(async () => false),
167
+ hasUnpushedCommits: vi.fn(async () => false),
168
+ });
169
+ await expect(committer.persist('groups:\n Team:\n - a@x.io\n')).resolves.toBeUndefined();
170
+ });
171
+
172
+ it('REJECTS when the queue itself cannot be read (armed must be PROVEN, never assumed)', async () => {
173
+ const releaseLock = vi.fn().mockRejectedValue(
174
+ new WorkflowValidationError('Cannot release lock: not held by you.', {
175
+ kind: 'lock-not-held',
176
+ }),
177
+ );
178
+ const hasQueuedCommit = vi.fn(async () => {
179
+ throw new Error('db down');
180
+ });
181
+ const { committer } = makeDeps({
182
+ commitChanges: vi.fn(async () => {
183
+ throw new PushNeedsAgentResolutionError('main', '(batch)', 'non-fast-forward', 'rebase failed');
184
+ }),
185
+ releaseLock,
186
+ hasQueuedCommit,
187
+ });
188
+ await expect(committer.persist('groups:\n Team:\n - a@x.io\n')).rejects.toBeInstanceOf(
189
+ PushNeedsAgentResolutionError,
190
+ );
191
+ });
192
+
193
+ it('re-arms the retry itself when the push-retry release could not enqueue its row', async () => {
194
+ // writeFiles' release swallows an enqueue failure (warn + continue), so
195
+ // without the committer's own arm the landed commit would have NO retry
196
+ // vehicle: the next sync reads the committed bytes, sees a no-op, and the
197
+ // update stays unpublished forever. The committer must retry the release
198
+ // (which enqueues the pending-commit row) before reporting success.
199
+ const releaseLock = vi
200
+ .fn()
201
+ .mockRejectedValueOnce(new Error('pending_commits insert failed')) // writeFiles' release
202
+ .mockResolvedValueOnce(undefined); // the committer's re-arm succeeds
203
+ const { committer } = makeDeps({
204
+ commitChanges: vi.fn(async () => {
205
+ throw new PushNeedsAgentResolutionError('main', '(batch)', 'non-fast-forward', 'rebase failed');
206
+ }),
207
+ releaseLock,
208
+ });
209
+ await expect(committer.persist('groups:\n Team:\n - a@x.io\n')).resolves.toBeUndefined();
210
+ expect(releaseLock).toHaveBeenCalledTimes(2);
211
+ });
212
+
213
+ it('REJECTS when no retry vehicle can be proven (both the release and the re-arm fail)', async () => {
214
+ // The one shape that may not report success: the commit landed, the push
215
+ // needs help, and no pending-commit row could be enqueued. Resolving here
216
+ // would tell the writer the update is published-or-retried when nothing
217
+ // will ever retry it — the failure signal must survive to the caller.
218
+ const releaseLock = vi.fn().mockRejectedValue(new Error('pending_commits insert failed'));
219
+ const { committer } = makeDeps({
220
+ commitChanges: vi.fn(async () => {
221
+ throw new PushNeedsAgentResolutionError('main', '(batch)', 'non-fast-forward', 'rebase failed');
222
+ }),
223
+ releaseLock,
224
+ });
225
+ await expect(committer.persist('groups:\n Team:\n - a@x.io\n')).rejects.toBeInstanceOf(
226
+ PushNeedsAgentResolutionError,
227
+ );
228
+ });
229
+
230
+ it('still rejects on a genuine pre-commit failure (nothing landed)', async () => {
231
+ const { committer } = makeDeps({
232
+ commitChanges: vi.fn(async () => {
233
+ throw new Error('commit exploded');
234
+ }),
235
+ });
236
+ await expect(committer.persist('groups: {}\n')).rejects.toThrow('commit exploded');
237
+ });
238
+ });
@@ -0,0 +1,249 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { parseGroupsFile } from '../group-files.js';
3
+ import {
4
+ SyncedGroupsWriter,
5
+ renderSyncedGroupsYaml,
6
+ type SyncedGroupMember,
7
+ type SyncedGroupRecord,
8
+ } from '../synced-groups-writer.js';
9
+
10
+ function member(overrides: Partial<SyncedGroupMember> = {}): SyncedGroupMember {
11
+ return {
12
+ email: 'ada@x.io',
13
+ active: true,
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ function group(
19
+ displayName: string,
20
+ members: SyncedGroupMember[],
21
+ externalId = `ext-${displayName}`,
22
+ ): SyncedGroupRecord {
23
+ return {
24
+ externalId,
25
+ displayName,
26
+ members,
27
+ };
28
+ }
29
+
30
+ describe('renderSyncedGroupsYaml', () => {
31
+ it('renders deterministically: sorted groups, sorted emails, stable bytes', () => {
32
+ const groups = [
33
+ group('Sales', [member({ email: 'zoe@x.io' }), member({ email: 'bo@x.io' })]),
34
+ group('Engineering', [member()]),
35
+ ];
36
+ const a = renderSyncedGroupsYaml(groups);
37
+ const b = renderSyncedGroupsYaml([...groups].reverse());
38
+ expect(a.text).toBe(b.text);
39
+ expect(a.groupCount).toBe(2);
40
+ // Engineering sorts before Sales; emails sort within a group.
41
+ const engineeringAt = a.text.indexOf('Engineering:');
42
+ const salesAt = a.text.indexOf('Sales:');
43
+ expect(engineeringAt).toBeGreaterThan(-1);
44
+ expect(engineeringAt).toBeLessThan(salesAt);
45
+ expect(a.text.indexOf('bo@x.io')).toBeLessThan(a.text.indexOf('zoe@x.io'));
46
+ });
47
+
48
+ it('round-trips through the resolver-side parser', () => {
49
+ const rendered = renderSyncedGroupsYaml([
50
+ group('Engineering', [member(), member({ email: 'bo@x.io' })]),
51
+ group('Empty Team', []),
52
+ ]);
53
+ const parsed = parseGroupsFile(rendered.text, 'synced-groups.yaml');
54
+ expect(parsed.ok).toBe(true);
55
+ if (parsed.ok) {
56
+ expect(parsed.warnings).toEqual([]);
57
+ expect([...parsed.groups.keys()].sort()).toEqual(['empty team', 'engineering']);
58
+ expect([...parsed.groups.get('engineering')!.emails].sort()).toEqual(['ada@x.io', 'bo@x.io']);
59
+ expect(parsed.groups.get('empty team')!.emails.size).toBe(0);
60
+ }
61
+ });
62
+
63
+ it('excludes inactive and email-less members, with a warning', () => {
64
+ const rendered = renderSyncedGroupsYaml([
65
+ group('Engineering', [
66
+ member(),
67
+ member({ email: 'gone@x.io', active: false }),
68
+ member({ email: null }),
69
+ ]),
70
+ ]);
71
+ expect(rendered.text).toContain('ada@x.io');
72
+ expect(rendered.text).not.toContain('gone@x.io');
73
+ expect(rendered.warnings.join(' ')).toContain('2 members not materialized');
74
+ });
75
+
76
+ it('refuses malformed emails from the directory (YAML corruption / injection)', () => {
77
+ const rendered = renderSyncedGroupsYaml([
78
+ group('Engineering', [
79
+ member(),
80
+ // A newline-bearing "email" would smuggle extra member lines into the file.
81
+ member({ email: 'evil@x.io\n - attacker@evil.io' }),
82
+ member({ email: 'not an email' }),
83
+ // Passes the shape regex but would read back as a YAML comment.
84
+ member({ email: '#tag@x.io' }),
85
+ member({ email: ' ADA@X.IO ' }), // canonicalized, deduped with ada@x.io
86
+ ]),
87
+ ]);
88
+ expect(rendered.text).toContain('ada@x.io');
89
+ expect(rendered.text).not.toContain('attacker@evil.io');
90
+ expect(rendered.text).not.toContain('not an email');
91
+ expect(rendered.text).not.toContain('#tag');
92
+ expect(rendered.text.match(/ada@x\.io/g)).toHaveLength(1);
93
+ expect(rendered.warnings.join(' ')).toContain('malformed email');
94
+ });
95
+
96
+ it("refuses the reserved 'group:' prefix as a member email (the read side skips it as a ref token)", () => {
97
+ const rendered = renderSyncedGroupsYaml([
98
+ group('Engineering', [member(), member({ email: 'group:lee@x.io' })]),
99
+ ]);
100
+ expect(rendered.text).toContain('ada@x.io');
101
+ expect(rendered.text).not.toContain('group:lee@x.io');
102
+ expect(rendered.warnings.join(' ')).toContain('malformed email');
103
+ // The emitted file round-trips warning-free through the parser.
104
+ const parsed = parseGroupsFile(rendered.text, 'synced-groups.yaml');
105
+ expect(parsed.ok && parsed.warnings).toEqual([]);
106
+ });
107
+
108
+ it('renders IdP names ESCAPED into warnings — control characters never reach the log verbatim', () => {
109
+ const rendered = renderSyncedGroupsYaml([
110
+ group('Evil\u001b[31mTeam\nFAKE LOG LINE', [member()]),
111
+ ]);
112
+ expect(rendered.groupCount).toBe(0);
113
+ expect(rendered.warnings).toHaveLength(1);
114
+ // The raw control chars must not appear; their JSON escapes must.
115
+ expect(rendered.warnings[0]).not.toContain('\n');
116
+ expect(rendered.warnings[0]).not.toContain('\u001b');
117
+ expect(rendered.warnings[0]).toContain('\\n');
118
+ expect(rendered.warnings[0]).toContain('\\u001b');
119
+ });
120
+
121
+ it('also escapes C1 controls and U+2028/U+2029 — the ranges JSON.stringify leaves raw', () => {
122
+ // JSON.stringify only escapes C0 (U+0000–U+001F): the one-byte CSI
123
+ // U+009B starts an ANSI sequence on its own, DEL (U+007F) is a control
124
+ // too, and U+2028/U+2029 are line breaks to JS consumers — all four
125
+ // would otherwise reach the log stream verbatim.
126
+ const raw = { csi: '\u009B', del: '\u007F', ls: '\u2028', ps: '\u2029' };
127
+ // C1/2028/2029 do NOT make a name unsafe (only C0 does), so the group
128
+ // is emitted — the name reaches the log through a MEMBER warning.
129
+ const rendered = renderSyncedGroupsYaml([
130
+ group(`Csi${raw.csi}31mTeam${raw.del}${raw.ls}${raw.ps}FAKE`, [member({ active: false })]),
131
+ ]);
132
+ expect(rendered.groupCount).toBe(1);
133
+ expect(rendered.warnings).toHaveLength(1);
134
+ const warning = rendered.warnings[0];
135
+ for (const c of Object.values(raw)) {
136
+ expect(warning).not.toContain(c);
137
+ }
138
+ expect(warning).toContain('\\u009b');
139
+ expect(warning).toContain('\\u007f');
140
+ expect(warning).toContain('\\u2028');
141
+ expect(warning).toContain('\\u2029');
142
+ });
143
+
144
+ it('full-key duplicate tie-break is collision-proof (JSON member key, not a delimiter join)', () => {
145
+ // Crafted so the OLD delimiter-joined keys collide: one malformed "email"
146
+ // containing the join delimiters serializes identically to two real
147
+ // members. A collision pushed the tie onto input order — the two renders
148
+ // below would then disagree on which duplicate wins first-wins dedup.
149
+ const a = group('Team', [member({ email: 'a@x.io:true,b@x.io' })], 'ext-1');
150
+ const b = group('Team', [member({ email: 'a@x.io' }), member({ email: 'b@x.io' })], 'ext-1');
151
+ const forward = renderSyncedGroupsYaml([a, b]);
152
+ const reversed = renderSyncedGroupsYaml([b, a]);
153
+ expect(forward.text).toBe(reversed.text);
154
+ });
155
+
156
+ it('sorts by code units, not locale (byte-stable across deployments)', () => {
157
+ // localeCompare in most locales collates 'é' before 'z'; code units put
158
+ // 'z' (0x7a) before 'é' (0xe9). Pin the code-unit order so the rendered
159
+ // bytes never depend on the process's ambient locale/ICU build.
160
+ const rendered = renderSyncedGroupsYaml([
161
+ group('équipe', [member()]),
162
+ group('z team', [member()]),
163
+ ]);
164
+ const z = rendered.text.indexOf('z team:');
165
+ const e = rendered.text.indexOf('équipe:');
166
+ expect(z).toBeGreaterThan(-1);
167
+ expect(z).toBeLessThan(e);
168
+ });
169
+
170
+ it('skips YAML-unsafe, reserved, and name-colliding groups (fail-closed)', () => {
171
+ const rendered = renderSyncedGroupsYaml([
172
+ group('Ops: West', [member()]),
173
+ group('everyone', [member()]),
174
+ group('Engineering', [member()], 'ext-a'),
175
+ group('engineering', [member({ email: 'bo@x.io' })], 'ext-b'),
176
+ ]);
177
+ expect(rendered.groupCount).toBe(1);
178
+ expect(rendered.text).not.toContain('Ops: West');
179
+ expect(rendered.text).not.toContain('everyone');
180
+ const joined = rendered.warnings.join('\n');
181
+ expect(joined).toContain('"Ops: West" skipped');
182
+ expect(joined).toContain('reserved');
183
+ expect(joined).toContain('collides');
184
+ });
185
+ });
186
+
187
+ describe('SyncedGroupsWriter', () => {
188
+ const GROUPS = [group('Engineering', [member()])];
189
+
190
+ function makeWriter(overrides: { current?: string | null; debounceMs?: number } = {}) {
191
+ const persisted: string[] = [];
192
+ const written: unknown[] = [];
193
+ let current = overrides.current ?? null;
194
+ const writer = new SyncedGroupsWriter({
195
+ source: { listGroups: async () => GROUPS },
196
+ readCurrent: async () => current,
197
+ persist: async (content) => {
198
+ persisted.push(content);
199
+ current = content;
200
+ },
201
+ onWritten: (result) => written.push(result),
202
+ debounceMs: overrides.debounceMs ?? 50,
203
+ });
204
+ return { writer, persisted, written };
205
+ }
206
+
207
+ beforeEach(() => vi.useFakeTimers());
208
+ afterEach(() => vi.useRealTimers());
209
+
210
+ it('writeNow persists changed content and fires onWritten', async () => {
211
+ const { writer, persisted, written } = makeWriter();
212
+ const result = await writer.writeNow();
213
+ expect(result.changed).toBe(true);
214
+ expect(result.groupCount).toBe(1);
215
+ expect(persisted).toHaveLength(1);
216
+ expect(persisted[0]).toContain('Engineering:');
217
+ expect(written).toHaveLength(1);
218
+ });
219
+
220
+ it('is a no-op (no commit, no events) when the content is unchanged', async () => {
221
+ const { writer, persisted, written } = makeWriter({
222
+ current: renderSyncedGroupsYaml(GROUPS).text,
223
+ });
224
+ const result = await writer.writeNow();
225
+ expect(result.changed).toBe(false);
226
+ expect(persisted).toHaveLength(0);
227
+ expect(written).toHaveLength(0);
228
+ });
229
+
230
+ it('debounces a burst of mutations into one write', async () => {
231
+ const { writer, persisted } = makeWriter({ debounceMs: 50 });
232
+ writer.notifyMutation();
233
+ await vi.advanceTimersByTimeAsync(30);
234
+ writer.notifyMutation(); // burst continues → timer re-arms
235
+ await vi.advanceTimersByTimeAsync(30);
236
+ expect(persisted).toHaveLength(0); // still inside the burst window
237
+ writer.notifyMutation();
238
+ await vi.advanceTimersByTimeAsync(60); // burst goes quiet → fire once
239
+ expect(persisted).toHaveLength(1);
240
+ });
241
+
242
+ it('dispose cancels a pending debounce', async () => {
243
+ const { writer, persisted } = makeWriter({ debounceMs: 50 });
244
+ writer.notifyMutation();
245
+ writer.dispose();
246
+ await vi.advanceTimersByTimeAsync(200);
247
+ expect(persisted).toHaveLength(0);
248
+ });
249
+ });
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Access-control service interface. The implementation reads `roles.yaml`
3
- * and the `access.md` tree from the per-user `knowledge-base` clone and
4
- * resolves write permissions against them.
2
+ * Access-control service interface. The implementation reads `roles.yaml`,
3
+ * the active group file (`synced-groups.yaml` / `groups.yaml`), and the
4
+ * `access.md` tree from the workspace's `knowledge-base` clone and resolves
5
+ * the access verbs (read / write / download / owner) against them.
5
6
  *
6
7
  * All `relativePath` arguments are repo-relative POSIX paths inside the KB
7
8
  * repo (e.g. `Knowledge/Sales/Foo.md`, `roles.yaml`) — NOT workspace-relative
@@ -20,8 +21,8 @@
20
21
  *
21
22
  * Only a principal NAMED in a file (a direct user grant `Name <email>`, or a
22
23
  * group/role token) produces sources. A USER who merely RESOLVES to access via a
23
- * plugin they belong to, the built-in `everyone`, or admin-rescue is NOT a
24
- * per-target file entry and produces NO source (the plugin itself shows as its own
24
+ * group or role they belong to, the built-in `everyone`, or admin-rescue is NOT a
25
+ * per-target file entry and produces NO source (the group/role itself shows as its own
25
26
  * row instead).
26
27
  */
27
28
  export type GrantSource =
@@ -50,6 +51,24 @@ export type GrantPrincipal =
50
51
  | { kind: 'user'; email: string }
51
52
  | { kind: 'role'; role: string };
52
53
 
54
+ /**
55
+ * One collective principal in a resolved eligible list, with WHAT it is: a
56
+ * ROLE (an app-defined capability) or a GROUP (a grant audience from the
57
+ * active group source). The resolver knows the kind from the merged principal
58
+ * index — a `role/<canonical>` alias hit is always the role; a bare token is
59
+ * whatever owns it under group-first precedence — and the share dialog needs
60
+ * it to badge each grantee row honestly ("Role" vs "Group") and to round-trip
61
+ * the row's principal with the right kind.
62
+ *
63
+ * Additive: `principals` rides NEXT TO the legacy `roles: string[]` (the same
64
+ * names, kind erased), which many name-only consumers (banners, owner
65
+ * contact lines, PR-routing messages) still read. It is optional in the
66
+ * interface so existing test doubles stay valid; the real service always
67
+ * returns it, and payload consumers fall back to `roles` (all treated as
68
+ * roles) when absent.
69
+ */
70
+ export type ResolvedPrincipal = { name: string; kind: 'role' | 'group' };
71
+
53
72
  /**
54
73
  * Per-verb sources of a principal's access on a target. Only verbs the principal
55
74
  * actually holds (via a named file entry) appear; each maps to the closest-first
@@ -155,7 +174,11 @@ export interface IAccessControl {
155
174
  eligibleOwners(
156
175
  workspaceId: string,
157
176
  relativePath: string,
158
- ): Promise<{ roles: string[]; users: { name: string; email: string }[] }>;
177
+ ): Promise<{
178
+ principals?: ResolvedPrincipal[];
179
+ roles: string[];
180
+ users: { name: string; email: string }[];
181
+ }>;
159
182
 
160
183
  /**
161
184
  * The set of principals (roles + direct users) with `write` on this path.
@@ -167,7 +190,11 @@ export interface IAccessControl {
167
190
  eligibleWriters(
168
191
  workspaceId: string,
169
192
  relativePath: string,
170
- ): Promise<{ roles: string[]; users: { name: string; email: string }[] }>;
193
+ ): Promise<{
194
+ principals?: ResolvedPrincipal[];
195
+ roles: string[];
196
+ users: { name: string; email: string }[];
197
+ }>;
171
198
 
172
199
  /**
173
200
  * Answers the file viewer's "who can see this?" affordance. `restricted` is
@@ -181,7 +208,12 @@ export interface IAccessControl {
181
208
  eligibleReaders(
182
209
  workspaceId: string,
183
210
  relativePath: string,
184
- ): Promise<{ restricted: boolean; roles: string[]; users: { name: string; email: string }[] }>;
211
+ ): Promise<{
212
+ restricted: boolean;
213
+ principals?: ResolvedPrincipal[];
214
+ roles: string[];
215
+ users: { name: string; email: string }[];
216
+ }>;
185
217
 
186
218
  /**
187
219
  * The set of principals (roles + direct users) with `download` on this path.
@@ -194,7 +226,11 @@ export interface IAccessControl {
194
226
  eligibleDownloaders(
195
227
  workspaceId: string,
196
228
  relativePath: string,
197
- ): Promise<{ roles: string[]; users: { name: string; email: string }[] }>;
229
+ ): Promise<{
230
+ principals?: ResolvedPrincipal[];
231
+ roles: string[];
232
+ users: { name: string; email: string }[];
233
+ }>;
198
234
 
199
235
  /**
200
236
  * Finite expanded email set for configured users who could approve this path
@@ -237,12 +273,22 @@ export interface IAccessControl {
237
273
  *
238
274
  * Returns only the verbs the principal effectively holds; a verb with no
239
275
  * access is omitted. A principal with no access anywhere yields an empty map.
276
+ *
277
+ * `opts.tokenMatch: 'exact'` pins a ROLE-shaped principal to its literal
278
+ * token spelling: only the exact token (bare, or `role/<name>`) counts as
279
+ * the principal's own entry, regardless of group shadowing. The mutation
280
+ * layer uses this so a check runs against the SAME identity a pinned
281
+ * exact-token splice edited — e.g. verifying a GROUP deny whose group has
282
+ * vanished, where the default (alias-tolerant when unshadowed) matching
283
+ * would misattribute a same-named role's surviving `role/<name>` grant to
284
+ * the group. Omitted (or `'name'`) → the shadowing-derived default.
240
285
  */
241
286
  grantSources(
242
287
  workspaceId: string,
243
288
  kind: AccessTargetKind,
244
289
  relativePath: string,
245
290
  principal: GrantPrincipal,
291
+ opts?: { tokenMatch?: 'exact' | 'name' },
246
292
  ): Promise<GrantSources>;
247
293
 
248
294
  /**
@@ -264,34 +310,22 @@ export interface IAccessControl {
264
310
  */
265
311
  validateRolesYaml(text: string): { ok: true } | { ok: false; errors: string[] };
266
312
 
267
- /**
268
- * Find every folder-`access.md` reference to a role, by canonical name, in the
269
- * cached model. ADVISORY ONLY — it powers the delete-confirmation "N rules
270
- * will be ignored" warning, and it may UNDERCOUNT: `collectAccessFiles` only
271
- * walks files literally named `access.md`, so a role granted in a node's OWN
272
- * frontmatter is a real grant this scan misses. That is acceptable for a
273
- * non-fatal warning. It must NOT be used to gate the rename rewrite — that
274
- * needs the sound git-grep scan in `roles-admin.service.ts`, which also covers
275
- * node frontmatter. An undercount there would silently orphan access.
276
- */
277
- referencesToRole(
278
- workspaceId: string,
279
- canonicalRole: string,
280
- ): Promise<{ path: string; verb: string }[]>;
281
-
282
313
  /**
283
314
  * Enumerate the grantable principals known to the KB, for the share-dialog
284
- * autocomplete. `plugins` are the built-in `everyone` role plus the declared
285
- * `roles.yaml` role display names (`everyone` is surfaced so the UI can
286
- * grant public read; the grant route gates it to the `read` verb only).
287
- * `people` are every email named in `roles.yaml` (name defaults to the local
288
- * part) unioned with every `Name <email>` grant in any `access.md` (named).
289
- * The login-only `users` table is unioned in by the caller — this method
290
- * covers the KB-canonical people the users table misses.
315
+ * autocomplete. `roles` are the built-in `everyone` role plus the declared
316
+ * `roles.yaml` role display names — ROLE principals only, never groups
317
+ * (`everyone` is surfaced so the UI can grant public read; the grant route
318
+ * gates it to the `read` verb only). `groups` are the ACTIVE group source's
319
+ * display names as merged into the resolver's cached model — served from
320
+ * that cache precisely so suggest/grant don't re-read the files per call.
321
+ * `people` are every email named in `roles.yaml` (name defaults to the
322
+ * local part) unioned with every `Name <email>` grant in any `access.md`
323
+ * (named). The login-only `users` table is unioned in by the caller — this
324
+ * method covers the KB-canonical people the users table misses.
291
325
  */
292
326
  kbPrincipals(
293
327
  workspaceId: string,
294
- ): Promise<{ plugins: string[]; people: { name: string; email: string }[] }>;
328
+ ): Promise<{ roles: string[]; groups: string[]; people: { name: string; email: string }[] }>;
295
329
 
296
330
  /**
297
331
  * Reverse-lookup an email by its SHA-256 hash (per `hashEmail` semantics)