@bevel-software/platform-core-backend 0.10.0 → 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 (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,427 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { execFile } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+
8
+ import type { WorkspaceService } from '../../workspace/workspace.service.js';
9
+ import { AccessControlService, parseRolesYaml } from '../access-control.service.js';
10
+ import { AccessConfigError } from '../access-errors.js';
11
+ import { addMember, parseRolesModel, emitRolesModel } from '../roles-edit.js';
12
+
13
+ const execFileAsync = promisify(execFile);
14
+ const KB_DIR = 'knowledge-base';
15
+
16
+ async function mkTmpRoot(): Promise<string> {
17
+ return fs.mkdtemp(path.join(os.tmpdir(), 'bevel-groups-'));
18
+ }
19
+
20
+ async function seedWorkspace(root: string, workspaceId: string) {
21
+ const workspaceDir = path.join(root, workspaceId);
22
+ const repo = path.join(workspaceDir, KB_DIR);
23
+ await fs.mkdir(repo, { recursive: true });
24
+ return { workspaceDir, repo };
25
+ }
26
+
27
+ async function writeFile(repo: string, rel: string, contents: string): Promise<void> {
28
+ const abs = path.join(repo, rel);
29
+ await fs.mkdir(path.dirname(abs), { recursive: true });
30
+ await fs.writeFile(abs, contents);
31
+ }
32
+
33
+ function stubWorkspaceService(workspaceId: string, workspaceDir: string): WorkspaceService {
34
+ return {
35
+ getWorkspacePath: async (id: string) => {
36
+ if (id !== workspaceId) throw new Error(`unexpected workspace ${id}`);
37
+ return workspaceDir;
38
+ },
39
+ ensureRemotesFetched: async () => undefined,
40
+ } as unknown as WorkspaceService;
41
+ }
42
+
43
+ const ROLES_YAML = `roles:
44
+ Admin:
45
+ - admin@x.io
46
+ `;
47
+
48
+ const GROUPS_YAML_TEXT = `groups:
49
+ Engineering:
50
+ - ada@x.io
51
+ - bo@x.io
52
+ Sales: []
53
+ `;
54
+
55
+ describe('group files as access principals', () => {
56
+ let root: string;
57
+ const workspaceId = 'ws-groups-1';
58
+
59
+ beforeEach(async () => {
60
+ root = await mkTmpRoot();
61
+ });
62
+
63
+ afterEach(async () => {
64
+ await fs.rm(root, { recursive: true, force: true });
65
+ });
66
+
67
+ async function makeService(files: Record<string, string>) {
68
+ const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
69
+ for (const [rel, contents] of Object.entries(files)) {
70
+ await writeFile(repo, rel, contents);
71
+ }
72
+ return new AccessControlService(stubWorkspaceService(workspaceId, workspaceDir), KB_DIR);
73
+ }
74
+
75
+ it('manual mode: a groups.yaml group grants access when named in access.md', async () => {
76
+ const svc = await makeService({
77
+ 'roles.yaml': ROLES_YAML,
78
+ 'groups.yaml': GROUPS_YAML_TEXT,
79
+ 'access.md': '---\nwrite:\n - Admin\nread:\n - Engineering\n---\n',
80
+ });
81
+ expect(await svc.canRead(workspaceId, 'ada@x.io', 'Knowledge/Doc.md')).toBe(true);
82
+ expect(await svc.canRead(workspaceId, 'bo@x.io', 'Knowledge/Doc.md')).toBe(true);
83
+ // Not in the group, not otherwise granted → default-deny.
84
+ expect(await svc.canRead(workspaceId, 'mallory@x.io', 'Knowledge/Doc.md')).toBe(false);
85
+ // Group grant confers read only — not write.
86
+ expect(await svc.canWrite(workspaceId, 'ada@x.io', 'Knowledge/Doc.md')).toBe(false);
87
+ });
88
+
89
+ it('blank group keys (even several) are skipped — every OTHER group keeps resolving', async () => {
90
+ const svc = await makeService({
91
+ 'roles.yaml': ROLES_YAML,
92
+ // Blank keys (and their members) are entry-level problems; a hard parse
93
+ // failure — including a duplicate-'' key error — would retire
94
+ // Engineering too, fail-closed.
95
+ 'groups.yaml':
96
+ 'groups:\n :\n - stray@x.io\n Engineering:\n - ada@x.io\n :\n - stray2@x.io\n',
97
+ 'access.md': '---\nread:\n - Engineering\n---\n',
98
+ });
99
+ expect(await svc.canRead(workspaceId, 'ada@x.io', 'Knowledge/Doc.md')).toBe(true);
100
+ expect(await svc.canRead(workspaceId, 'stray@x.io', 'Knowledge/Doc.md')).toBe(false);
101
+ expect(await svc.canRead(workspaceId, 'stray2@x.io', 'Knowledge/Doc.md')).toBe(false);
102
+ });
103
+
104
+ it('validateGroupsFile (the WRITE gate) still refuses blank keys the reader forgives', async () => {
105
+ const { validateGroupsFile } = await import('../group-files.js');
106
+ // Inside `groups:` — refused via the entry warning (strict gate).
107
+ expect(validateGroupsFile('groups:\n :\n - a@x.io\n', 'groups.yaml').ok).toBe(false);
108
+ // At the ROOT — the tolerant reader silently drops this without any
109
+ // warning, so only the strict pre-parse refuses it. This is the case the
110
+ // strict parse exists for.
111
+ expect(
112
+ validateGroupsFile(':\n - stray@x.io\ngroups:\n Engineering:\n - ada@x.io\n', 'groups.yaml').ok,
113
+ ).toBe(false);
114
+ });
115
+
116
+ it('parseGroupsFile applies the shared name-safety predicate (warn-and-skip), and the write gate refuses', async () => {
117
+ const { parseGroupsFile, validateGroupsFile } = await import('../group-files.js');
118
+ // `<`/`>` and control characters pass the YAML subset as plain keys but
119
+ // fail unsafeNameReason — the parser must skip them (other groups keep
120
+ // resolving) and the strict write gate must refuse the whole candidate.
121
+ const text = 'groups:\n Bad<Name>:\n - a@x.io\n Good Team:\n - b@x.io\n';
122
+ const parsed = parseGroupsFile(text, 'groups.yaml');
123
+ expect(parsed.ok).toBe(true);
124
+ if (parsed.ok) {
125
+ expect([...parsed.groups.keys()]).toEqual(['good team']);
126
+ expect(parsed.warnings.join(' ')).toMatch(/Bad<Name>/);
127
+ expect(parsed.warnings.join(' ')).toMatch(/skipped/);
128
+ }
129
+ expect(validateGroupsFile(text, 'groups.yaml').ok).toBe(false);
130
+ // A leading '-' name (reachable via `-x:` — no space after the dash).
131
+ const dashed = parseGroupsFile('groups:\n -lead:\n - a@x.io\n', 'groups.yaml');
132
+ expect(dashed.ok && [...dashed.groups.keys()]).toEqual([]);
133
+ });
134
+
135
+ it("parseGroupsFile skips a 'group:'-prefixed member (shapes like an email, but is the ref token)", async () => {
136
+ const { parseGroupsFile, validateGroupsFile } = await import('../group-files.js');
137
+ // `group:lee@x.io` PASSES the email regex — without the explicit skip it
138
+ // would land as a member "email" nothing can ever log in as.
139
+ const text = 'groups:\n Team:\n - group:lee@x.io\n - real@x.io\n';
140
+ const parsed = parseGroupsFile(text, 'groups.yaml');
141
+ expect(parsed.ok).toBe(true);
142
+ if (parsed.ok) {
143
+ expect([...parsed.groups.get('team')!.emails]).toEqual(['real@x.io']);
144
+ expect(parsed.warnings.join(' ')).toMatch(/group:/);
145
+ expect(parsed.warnings.join(' ')).toMatch(/emails, not group references/);
146
+ }
147
+ // Warnings are refusals on the write side.
148
+ expect(validateGroupsFile(text, 'groups.yaml').ok).toBe(false);
149
+ // And the resolver never grants through it.
150
+ const svc = await makeService({
151
+ 'roles.yaml': ROLES_YAML,
152
+ 'groups.yaml': text,
153
+ 'access.md': '---\nread:\n - Team\n---\n',
154
+ });
155
+ expect(await svc.canRead(workspaceId, 'real@x.io', 'Knowledge/Doc.md')).toBe(true);
156
+ expect(await svc.canRead(workspaceId, 'group:lee@x.io', 'Knowledge/Doc.md')).toBe(false);
157
+ });
158
+
159
+ it('a group reference with no group file behind it grants nothing', async () => {
160
+ const svc = await makeService({
161
+ 'roles.yaml': ROLES_YAML,
162
+ 'access.md': '---\nread:\n - Engineering\n---\n',
163
+ });
164
+ expect(await svc.canRead(workspaceId, 'ada@x.io', 'Knowledge/Doc.md')).toBe(false);
165
+ });
166
+
167
+ it('IdP mode: synced-groups.yaml presence retires groups.yaml entirely', async () => {
168
+ const svc = await makeService({
169
+ 'roles.yaml': ROLES_YAML,
170
+ // The manual file still names ada; the synced file has a different team.
171
+ 'groups.yaml': GROUPS_YAML_TEXT,
172
+ 'synced-groups.yaml': 'groups:\n Platform:\n - zoe@x.io\n',
173
+ 'access.md': '---\nread:\n - Engineering\n - Platform\n---\n',
174
+ });
175
+ // Synced group resolves…
176
+ expect(await svc.canRead(workspaceId, 'zoe@x.io', 'Knowledge/Doc.md')).toBe(true);
177
+ // …the retired manual group does NOT (its name is now unknown → dropped).
178
+ expect(await svc.canRead(workspaceId, 'ada@x.io', 'Knowledge/Doc.md')).toBe(false);
179
+ });
180
+
181
+ it('BROKEN groups degrade loudly: bare tokens fall through to roles, health marker set', async () => {
182
+ const svc = await makeService({
183
+ 'roles.yaml': `roles:\n Admin:\n - admin@x.io\n Engineering:\n - lead@x.io\n`,
184
+ // Structurally broken manual file — groups contribute nothing…
185
+ 'groups.yaml': 'groups:\n - not\n - a\n - mapping\n',
186
+ 'access.md': '---\nread:\n - Engineering\n---\n',
187
+ });
188
+ // …and the bare token falls through to the ROLE per precedence, so
189
+ // access control keeps working (owner's degrade decision, not fail-closed).
190
+ expect(await svc.canRead(workspaceId, 'lead@x.io', 'Knowledge/Doc.md')).toBe(true);
191
+ expect(await svc.canRead(workspaceId, 'ada@x.io', 'Knowledge/Doc.md')).toBe(false);
192
+
193
+ // The load-level health marker names the file and the reason.
194
+ const { loadActiveGroups } = await import('../access-control.service.js');
195
+ const active = await loadActiveGroups(async (f) =>
196
+ f === 'groups.yaml' ? 'groups:\n - not\n - a\n - mapping\n' : null,
197
+ );
198
+ expect(active.groups.size).toBe(0);
199
+ expect(active.health).toMatchObject({ ok: false, file: 'groups.yaml' });
200
+ expect((active.health as { reason?: string }).reason).toMatch(/mapping|list/i);
201
+ });
202
+
203
+ it('a non-absence READ error on synced-groups.yaml is broken-groups, never a manual fallback', async () => {
204
+ const { loadActiveGroups } = await import('../access-control.service.js');
205
+ const active = await loadActiveGroups(async (f) => {
206
+ if (f === 'synced-groups.yaml') {
207
+ const err = new Error('EACCES: permission denied') as NodeJS.ErrnoException;
208
+ err.code = 'EACCES';
209
+ throw err;
210
+ }
211
+ // A fallback would read the manual file and resurrect retired groups.
212
+ return 'groups:\n Retired Team:\n - ghost@x.io\n';
213
+ });
214
+ expect(active.groups.size).toBe(0); // NOT the retired manual groups
215
+ expect(active.health).toMatchObject({ ok: false, file: 'synced-groups.yaml' });
216
+ // While genuine absence (ENOENT) still means manual mode:
217
+ const manual = await loadActiveGroups(async (f) => {
218
+ if (f === 'synced-groups.yaml') {
219
+ const err = new Error('ENOENT') as NodeJS.ErrnoException;
220
+ err.code = 'ENOENT';
221
+ throw err;
222
+ }
223
+ return 'groups:\n Live Team:\n - here@x.io\n';
224
+ });
225
+ expect(manual.health).toEqual({ ok: true });
226
+ expect([...manual.groups.keys()]).toEqual(['live team']);
227
+ });
228
+
229
+ it('a malformed synced file keeps IdP mode (no fallback to manual groups)', async () => {
230
+ const svc = await makeService({
231
+ 'roles.yaml': ROLES_YAML,
232
+ 'groups.yaml': GROUPS_YAML_TEXT,
233
+ 'synced-groups.yaml': 'groups:\n - not\n - a\n - mapping\n',
234
+ 'access.md': '---\nread:\n - Engineering\n---\n',
235
+ });
236
+ // Falling back to groups.yaml would resurrect retired manual groups.
237
+ expect(await svc.canRead(workspaceId, 'ada@x.io', 'Knowledge/Doc.md')).toBe(false);
238
+ });
239
+
240
+ it('a bare name shared by a group and a role resolves to the GROUP (grant side)', async () => {
241
+ const svc = await makeService({
242
+ 'roles.yaml': `roles:\n Admin:\n - admin@x.io\n Engineering:\n - lead@x.io\n`,
243
+ // The group "Engineering" collides with the ROLE Engineering.
244
+ 'groups.yaml': GROUPS_YAML_TEXT,
245
+ 'access.md': '---\nread:\n - Engineering\n---\n',
246
+ });
247
+ // Group-first precedence: the group's members read; the role's member no
248
+ // longer resolves through the bare token (use role/Engineering for that).
249
+ expect(await svc.canRead(workspaceId, 'ada@x.io', 'Knowledge/Doc.md')).toBe(true);
250
+ expect(await svc.canRead(workspaceId, 'lead@x.io', 'Knowledge/Doc.md')).toBe(false);
251
+ // The display surface resolves the bare token to the group too — and says
252
+ // WHAT it is, so the dialog badges the row "Group", not "Role".
253
+ const readers = await svc.eligibleReaders(workspaceId, 'Knowledge/Doc.md');
254
+ expect(readers.roles).toContain('Engineering');
255
+ expect(readers.principals).toContainEqual({ name: 'Engineering', kind: 'group' });
256
+ });
257
+
258
+ it('a bare-name DENY resolves to the group; role/<Name> reaches the role (writers included)', async () => {
259
+ const svc = await makeService({
260
+ 'roles.yaml': `roles:\n Admin:\n - admin@x.io\n Engineering:\n - lead@x.io\n`,
261
+ 'groups.yaml': GROUPS_YAML_TEXT,
262
+ 'access.md':
263
+ '---\nread:\n - everyone\n - deny Engineering\nwrite:\n - role/Engineering\n---\n',
264
+ });
265
+ // The deny bites the GROUP's members, not the role's.
266
+ expect(await svc.canRead(workspaceId, 'ada@x.io', 'Knowledge/Doc.md')).toBe(false);
267
+ expect(await svc.canRead(workspaceId, 'lead@x.io', 'Knowledge/Doc.md')).toBe(true);
268
+ // The explicit token grants the ROLE write.
269
+ expect(await svc.canWrite(workspaceId, 'lead@x.io', 'Knowledge/Doc.md')).toBe(true);
270
+ expect(await svc.canWrite(workspaceId, 'ada@x.io', 'Knowledge/Doc.md')).toBe(false);
271
+ // eligibleWriters resolves the explicit token to the role's display name —
272
+ // and carries kind 'role': the `role/` alias always means the role, even
273
+ // with a same-named group in play.
274
+ const writers = await svc.eligibleWriters(workspaceId, 'Knowledge/Doc.md');
275
+ expect(writers.roles).toContain('Engineering');
276
+ expect(writers.principals).toContainEqual({ name: 'Engineering', kind: 'role' });
277
+ });
278
+
279
+ it('role/Admin resolves to the ROLE even when a synced group is named Admin', async () => {
280
+ const svc = await makeService({
281
+ 'roles.yaml': ROLES_YAML,
282
+ 'synced-groups.yaml': 'groups:\n Admin:\n - attacker@evil.io\n',
283
+ 'access.md': '---\nwrite:\n - role/Admin\nread:\n - Admin\n---\n',
284
+ });
285
+ // The explicit token is the role — the shadowing group gets nothing here.
286
+ expect(await svc.canWrite(workspaceId, 'admin@x.io', 'Knowledge/Doc.md')).toBe(true);
287
+ expect(await svc.canWrite(workspaceId, 'attacker@evil.io', 'Knowledge/Doc.md')).toBe(false);
288
+ // The BARE token is the group now (group-first): its member reads.
289
+ expect(await svc.canRead(workspaceId, 'attacker@evil.io', 'Knowledge/Doc.md')).toBe(true);
290
+ // Admin CAPABILITY never leaks to the group: roles.yaml write is a pure
291
+ // is-admin check keyed on the role, and admin-rescue keeps working.
292
+ expect(await svc.canWrite(workspaceId, 'attacker@evil.io', 'roles.yaml')).toBe(false);
293
+ expect(await svc.canWrite(workspaceId, 'admin@x.io', 'roles.yaml')).toBe(true);
294
+ expect(await svc.canWrite(workspaceId, 'admin@x.io', 'access.md')).toBe(true);
295
+ });
296
+
297
+ it('assigning a role to a group (`group:` ref) expands through membership', async () => {
298
+ const svc = await makeService({
299
+ 'roles.yaml': `roles:\n Admin:\n - admin@x.io\n Plugin Creator:\n - jane@x.io\n - group:Engineering\n`,
300
+ 'groups.yaml': GROUPS_YAML_TEXT,
301
+ 'access.md': '---\nwrite:\n - Plugin Creator\n---\n',
302
+ });
303
+ // Individual assignee and every group member hold the role's grants.
304
+ expect(await svc.canWrite(workspaceId, 'jane@x.io', 'Tools/plugin.tool')).toBe(true);
305
+ expect(await svc.canWrite(workspaceId, 'ada@x.io', 'Tools/plugin.tool')).toBe(true);
306
+ expect(await svc.canWrite(workspaceId, 'bo@x.io', 'Tools/plugin.tool')).toBe(true);
307
+ // Group membership confers the ROLE's grants, not admin status.
308
+ expect(await svc.canWrite(workspaceId, 'ada@x.io', 'roles.yaml')).toBe(false);
309
+ });
310
+
311
+ it('a role group-ref to an unknown group contributes nothing', async () => {
312
+ const svc = await makeService({
313
+ 'roles.yaml': `roles:\n Admin:\n - admin@x.io\n Reviewer:\n - rev@x.io\n - group:Ghosts\n`,
314
+ 'groups.yaml': GROUPS_YAML_TEXT,
315
+ 'access.md': '---\nread:\n - Reviewer\n---\n',
316
+ });
317
+ expect(await svc.canRead(workspaceId, 'rev@x.io', 'Knowledge/Doc.md')).toBe(true);
318
+ });
319
+
320
+ it('Admin group refs are allowed (NEW) and expand to admin capability', async () => {
321
+ const withRef = `roles:\n Admin:\n - admin@x.io\n - group:Platform Admins\n`;
322
+ expect(parseRolesYaml(withRef).ok).toBe(true);
323
+ const svc = await makeService({
324
+ 'roles.yaml': withRef,
325
+ 'groups.yaml': 'groups:\n Platform Admins:\n - ops@x.io\n',
326
+ 'access.md': '---\nwrite:\n - role/Admin\n---\n',
327
+ });
328
+ // Group members expand into the Admin ROLE — capability included.
329
+ expect(await svc.canWrite(workspaceId, 'ops@x.io', 'Knowledge/Doc.md')).toBe(true);
330
+ expect(await svc.canWrite(workspaceId, 'ops@x.io', 'roles.yaml')).toBe(true);
331
+ expect(await svc.canWrite(workspaceId, 'admin@x.io', 'roles.yaml')).toBe(true);
332
+ });
333
+
334
+ it('a group-ONLY Admin is refused at parse and hard-errors at load (kept invariant)', async () => {
335
+ const bad = `roles:\n Admin:\n - group:Platform Admins\n`;
336
+ const parsed = parseRolesYaml(bad);
337
+ expect(parsed.ok).toBe(false);
338
+ if (!parsed.ok) {
339
+ expect(parsed.errors.join(' ')).toMatch(/direct email/);
340
+ }
341
+
342
+ const svc = await makeService({
343
+ 'roles.yaml': bad,
344
+ 'groups.yaml': 'groups:\n Platform Admins:\n - ops@x.io\n',
345
+ 'access.md': '---\nwrite:\n - Admin\n---\n',
346
+ });
347
+ await expect(svc.canWrite(workspaceId, 'admin@x.io', 'Knowledge/Doc.md')).rejects.toThrow(
348
+ AccessConfigError,
349
+ );
350
+ });
351
+
352
+ it('roles.yaml group refs survive unrelated roles-edit round-trips', () => {
353
+ const text = `roles:\n Admin:\n - admin@x.io\n Plugin Creator:\n - group:Engineering\n`;
354
+ const edited = addMember(text, 'admin', 'second@x.io');
355
+ expect(edited.changed).toBe(true);
356
+ expect(edited.text).toContain('group:engineering');
357
+ // And the re-emit is stable: parse → emit round-trips the ref.
358
+ expect(emitRolesModel(parseRolesModel(edited.text))).toBe(edited.text);
359
+ });
360
+
361
+ it('resolves groups AT A REF for the gate paths', async () => {
362
+ const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
363
+ await writeFile(repo, 'roles.yaml', ROLES_YAML);
364
+ await writeFile(repo, 'synced-groups.yaml', 'groups:\n Engineering:\n - ada@x.io\n');
365
+ await writeFile(repo, 'access.md', '---\nread:\n - Engineering\nwrite:\n - Engineering\n---\n');
366
+ const git = (...args: string[]) => execFileAsync('git', ['-C', repo, ...args]);
367
+ await git('init', '-b', 'main');
368
+ await git('config', 'user.email', 'test@x.io');
369
+ await git('config', 'user.name', 'Test');
370
+ await git('add', '-A');
371
+ await git('commit', '-m', 'seed');
372
+
373
+ // The committed state grants ada; the WORKING TREE then revokes the group
374
+ // — the at-ref answer must come from the commit, not the tree.
375
+ await fs.writeFile(path.join(repo, 'synced-groups.yaml'), 'groups: {}\n'.replace('{}', ''));
376
+
377
+ const svc = new AccessControlService(stubWorkspaceService(workspaceId, workspaceDir), KB_DIR);
378
+ expect(await svc.canWriteAtRef(workspaceId, 'main', 'ada@x.io', 'Knowledge/Doc.md')).toBe(true);
379
+ expect(await svc.canWriteAtRef(workspaceId, 'main', 'zoe@x.io', 'Knowledge/Doc.md')).toBe(false);
380
+ });
381
+
382
+ it('eligible holders keep BOTH kinds when a group and a role share a name', async () => {
383
+ // A group named Engineering shadows the role Engineering. When a file
384
+ // grants BOTH spellings — the bare token (the group's) and role/… (the
385
+ // role's) — the eligible list must carry two entries, one per kind:
386
+ // collapsing them hides the role's live role/<name> grant entirely.
387
+ const svc = await makeService({
388
+ 'roles.yaml': `roles:\n Admin:\n - admin@x.io\n Engineering:\n - lead@x.io\n`,
389
+ 'groups.yaml': GROUPS_YAML_TEXT,
390
+ 'access.md': '---\nread:\n - Engineering\n - role/Engineering\n---\n',
391
+ });
392
+ const readers = await svc.eligibleReaders(workspaceId, 'Knowledge/Doc.md');
393
+ expect(readers.principals).toContainEqual({ name: 'Engineering', kind: 'group' });
394
+ expect(readers.principals).toContainEqual({ name: 'Engineering', kind: 'role' });
395
+ // The legacy name-only list stays de-duplicated.
396
+ expect(readers.roles.filter((r) => r === 'Engineering')).toHaveLength(1);
397
+ // Both principals' members resolve.
398
+ expect(await svc.canRead(workspaceId, 'ada@x.io', 'Knowledge/Doc.md')).toBe(true);
399
+ expect(await svc.canRead(workspaceId, 'lead@x.io', 'Knowledge/Doc.md')).toBe(true);
400
+ });
401
+
402
+ it('synced-groups.yaml is machine-owned: only the sync bot writes it, ever', async () => {
403
+ const { workspaceDir } = await seedWorkspace(root, workspaceId);
404
+ const svc = new AccessControlService(stubWorkspaceService(workspaceId, workspaceDir), KB_DIR);
405
+
406
+ // The bot needs no roles/grants — the rule resolves before the model
407
+ // loads (this workspace has no git repo at all).
408
+ expect(
409
+ await svc.canWriteAtRef(workspaceId, 'HEAD', 'directory-sync@bevel.local', 'synced-groups.yaml'),
410
+ ).toBe(true);
411
+ // No human is eligible — not even Admin. Hand-edits would be silently
412
+ // overwritten by the next provisioning push.
413
+ expect(
414
+ await svc.canWriteAtRef(workspaceId, 'HEAD', 'admin@x.io', 'synced-groups.yaml'),
415
+ ).toBe(false);
416
+ // Same answers through the batch gate the lock service actually uses.
417
+ const batch = await svc.canWriteBatchAtRef(workspaceId, 'HEAD', 'directory-sync@bevel.local', [
418
+ 'synced-groups.yaml',
419
+ ]);
420
+ expect(batch?.get('synced-groups.yaml')).toBe(true);
421
+ // The eligible-writers surface names the bot, so the 403 payload and the
422
+ // UI say "machine-owned", not "nobody".
423
+ const eligible = await svc.eligibleWritersAtRef(workspaceId, 'HEAD', 'synced-groups.yaml');
424
+ expect(eligible?.users.map((u) => u.email)).toEqual(['directory-sync@bevel.local']);
425
+ expect(eligible?.roles).toEqual([]);
426
+ });
427
+ });
@@ -80,11 +80,21 @@ describe('AccessMutationService', () => {
80
80
  expect(text).toContain('# Sales folder');
81
81
  });
82
82
 
83
- it('kbPrincipals surfaces the built-in Everyone plugin alongside declared roles', async () => {
84
- const { plugins } = await access.kbPrincipals(WS);
85
- expect(plugins).toContain('Everyone');
86
- expect(plugins).toContain('Admin');
87
- expect(plugins).toContain('Product Team');
83
+ it('kbPrincipals (REAL service): roles lists ROLE principals only, groups separately', async () => {
84
+ // Through the real resolver — the route-level tests stub this method,
85
+ // which is exactly how the every-group-listed-as-a-role bug hid.
86
+ await write(repo, 'groups.yaml', 'groups:\n GTM Team:\n - pat@x.io\n Admin:\n - shadow@x.io\n');
87
+ access.invalidate(WS);
88
+ const { roles, groups } = await access.kbPrincipals(WS);
89
+ expect(roles).toContain('Everyone');
90
+ expect(roles).toContain('Admin');
91
+ expect(roles).toContain('Product Team');
92
+ // NO group ever appears under roles — not even one shadowing a role name;
93
+ // and each role appears exactly once (the alias key must not double it).
94
+ expect(roles).not.toContain('GTM Team');
95
+ expect(roles.filter((r) => r === 'Admin')).toHaveLength(1);
96
+ expect(roles.filter((r) => r === 'Product Team')).toHaveLength(1);
97
+ expect(groups.sort()).toEqual(['Admin', 'GTM Team']);
88
98
  });
89
99
 
90
100
  it('grant everyone read makes the folder publicly readable (read: everyone)', async () => {
@@ -303,6 +313,57 @@ describe('AccessMutationService', () => {
303
313
  expect(await access.canWrite(WS, 'razvan@bevel.software', 'access.md')).toBe(true);
304
314
  });
305
315
 
316
+ it('GROUP deny with a VANISHED group succeeds even when a same-named ROLE keeps a role/<Name> grant', async () => {
317
+ // The false-negative this pins: the route pins tokenMatch:'exact' for a
318
+ // GROUP principal, so the deny strips/denies ONLY the bare token. The
319
+ // post-write effectiveness assert must judge the SAME exact identity —
320
+ // with the group vanished the name reads "unshadowed", and the
321
+ // alias-tolerant default would read the surviving `role/Product Team`
322
+ // grant as the GROUP still having access, roll back a fully effective
323
+ // deny, and answer 409 deny-ineffective.
324
+ await write(
325
+ repo,
326
+ 'Sales/access.md',
327
+ // The bare grant is the (now vanished) GROUP's; the role/ grant is the
328
+ // same-named ROLE's own. No groups.yaml exists — the group is gone.
329
+ '---\nwrite:\n - Product Team\n - role/Product Team\n---\n# Sales folder\n',
330
+ );
331
+ access.invalidate(WS);
332
+
333
+ const r = await mutation.denyHere(
334
+ WS,
335
+ 'folder',
336
+ 'Sales',
337
+ { kind: 'role', role: 'Product Team' },
338
+ undefined,
339
+ { tokenMatch: 'exact' },
340
+ );
341
+ expect(r.changed).toBe(true);
342
+ access.invalidate(WS);
343
+
344
+ const text = await fs.readFile(path.join(repo, 'Sales/access.md'), 'utf-8');
345
+ // The bare (group) grant became a deny...
346
+ expect(text).toContain('deny Product Team');
347
+ // ...and the ROLE's own grant was never touched.
348
+ expect(text).toContain('role/Product Team');
349
+ expect(text).not.toContain('deny role/Product Team');
350
+ // The role still resolves its grant through the surviving role/ entry.
351
+ const roleSources = await access.grantSources(WS, 'folder', 'Sales', {
352
+ kind: 'role',
353
+ role: 'role/Product Team',
354
+ });
355
+ expect(roleSources.write).toEqual([{ kind: 'direct' }]);
356
+ // The exact bare token — the group's identity — holds nothing anymore.
357
+ const groupSources = await access.grantSources(
358
+ WS,
359
+ 'folder',
360
+ 'Sales',
361
+ { kind: 'role', role: 'Product Team' },
362
+ { tokenMatch: 'exact' },
363
+ );
364
+ expect(groupSources).toEqual({});
365
+ });
366
+
306
367
  it('is a no-op (changed:false) when the principal is already fully denied here', async () => {
307
368
  await write(
308
369
  repo,
@@ -437,3 +498,108 @@ describe('AccessMutationService', () => {
437
498
  });
438
499
  });
439
500
  });
501
+
502
+
503
+ // The four grant/revoke x shadowed/unshadowed cases, end-to-end through the
504
+ // REAL resolver (kbPrincipals decides shadowing) — the token-kind family.
505
+ describe('token-kind family — shadow-aware revoke, exact-token grant (real resolver)', () => {
506
+ let root: string;
507
+ let repo: string;
508
+ let access: AccessControlService;
509
+ let mutation: AccessMutationService;
510
+
511
+ const roleP: Principal = { kind: 'role', role: 'role/Product Team' }; // the ROLE, explicit
512
+ const bareP: Principal = { kind: 'role', role: 'Product Team' }; // the bare name (group when shadowed)
513
+
514
+ beforeEach(async () => {
515
+ root = await mkTmpRoot();
516
+ const workspaceDir = path.join(root, WS);
517
+ repo = path.join(workspaceDir, KB);
518
+ await fs.mkdir(repo, { recursive: true });
519
+ await write(repo, 'roles.yaml', ROLES_YAML);
520
+ await write(repo, 'access.md', '---\nwrite:\n - Admin\n---\n');
521
+ const ws = stubWorkspace(workspaceDir);
522
+ access = new AccessControlService(ws, KB);
523
+ mutation = new AccessMutationService(ws, access, KB);
524
+ });
525
+
526
+ afterEach(async () => {
527
+ await fs.rm(root, { recursive: true, force: true });
528
+ });
529
+
530
+ /** Shadow on = a GROUP named "Product Team" exists (owns the bare token). */
531
+ async function shadow(on: boolean): Promise<void> {
532
+ if (on) {
533
+ await write(repo, 'groups.yaml', 'groups:\n Product Team:\n - pat@x.io\n');
534
+ }
535
+ access.invalidate(WS);
536
+ }
537
+
538
+ it('GRANT shadowed: the group grant lands even though role/<Name> is already granted', async () => {
539
+ await shadow(true);
540
+ await write(repo, 'Sales/access.md', '---\nwrite:\n - role/Product Team\n---\n');
541
+ const r = await mutation.grant(WS, 'folder', 'Sales', 'write', bareP);
542
+ expect(r.changed).toBe(true); // exact-token idempotency: bare and role/ are different principals
543
+ const text = await fs.readFile(path.join(repo, 'Sales/access.md'), 'utf-8');
544
+ expect(text).toContain('- role/Product Team');
545
+ expect(text.split('\n')).toContain(' - Product Team');
546
+ // The group member now resolves write through the bare token.
547
+ access.invalidate(WS);
548
+ expect(await access.canWrite(WS, 'pat@x.io', 'Sales/Deal.md')).toBe(true);
549
+ });
550
+
551
+ it('GRANT unshadowed: same-spelling grants stay idempotent', async () => {
552
+ await shadow(false);
553
+ await write(repo, 'Sales/access.md', '---\nwrite:\n - role/Product Team\n---\n');
554
+ const r = await mutation.grant(WS, 'folder', 'Sales', 'write', roleP);
555
+ expect(r.changed).toBe(false);
556
+ });
557
+
558
+ it("REVOKE shadowed: revoking the ROLE leaves the group's bare grant intact", async () => {
559
+ await shadow(true);
560
+ await write(repo, 'Sales/access.md', '---\nwrite:\n - Product Team\n - role/Product Team\n---\n');
561
+ const r = await mutation.revoke(WS, 'folder', 'Sales', roleP, 'admin@x');
562
+ expect(r.changed).toBe(true);
563
+ const text = await fs.readFile(path.join(repo, 'Sales/access.md'), 'utf-8');
564
+ expect(text).not.toContain('role/Product Team');
565
+ expect(text.split('\n')).toContain(' - Product Team'); // the GROUP's token survives the role revoke
566
+ // ...and the group member still resolves.
567
+ access.invalidate(WS);
568
+ expect(await access.canWrite(WS, 'pat@x.io', 'Sales/Deal.md')).toBe(true);
569
+ });
570
+
571
+ it("REVOKE shadowed: revoking the GROUP leaves the role's explicit grant intact", async () => {
572
+ await shadow(true);
573
+ await write(repo, 'Sales/access.md', '---\nwrite:\n - Product Team\n - role/Product Team\n---\n');
574
+ const r = await mutation.revoke(WS, 'folder', 'Sales', bareP, 'admin@x');
575
+ expect(r.changed).toBe(true);
576
+ const text = await fs.readFile(path.join(repo, 'Sales/access.md'), 'utf-8');
577
+ expect(text).toContain('- role/Product Team'); // the ROLE's token survives the group revoke
578
+ expect(text.split('\n')).not.toContain(' - Product Team');
579
+ // The role member (felix, per roles.yaml) still resolves via role/.
580
+ access.invalidate(WS);
581
+ expect(await access.canWrite(WS, 'felix@example.com', 'Sales/Deal.md')).toBe(true);
582
+ // The group member no longer does.
583
+ expect(await access.canWrite(WS, 'pat@x.io', 'Sales/Deal.md')).toBe(false);
584
+ });
585
+
586
+ it('REVOKE unshadowed: revoking the role strips BOTH spellings (legacy bare cleanup)', async () => {
587
+ await shadow(false);
588
+ await write(repo, 'Sales/access.md', '---\nwrite:\n - Product Team\n - role/Product Team\n---\n');
589
+ const r = await mutation.revoke(WS, 'folder', 'Sales', roleP, 'admin@x');
590
+ expect(r.changed).toBe(true);
591
+ const text = await fs.readFile(path.join(repo, 'Sales/access.md'), 'utf-8');
592
+ expect(text).not.toContain('Product Team');
593
+ access.invalidate(WS);
594
+ expect(await access.canWrite(WS, 'felix@example.com', 'Sales/Deal.md')).toBe(false);
595
+ });
596
+
597
+ it('REVOKE unshadowed: a bare revoke (group since vanished) also strips both spellings', async () => {
598
+ await shadow(false);
599
+ await write(repo, 'Sales/access.md', '---\nwrite:\n - Product Team\n - role/Product Team\n---\n');
600
+ const r = await mutation.revoke(WS, 'folder', 'Sales', bareP, 'admin@x');
601
+ expect(r.changed).toBe(true);
602
+ const text = await fs.readFile(path.join(repo, 'Sales/access.md'), 'utf-8');
603
+ expect(text).not.toContain('Product Team'); // unshadowed: both spellings are the role
604
+ });
605
+ });