@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
@@ -19,9 +19,15 @@ import {
19
19
  accessMdPathForFolder,
20
20
  type TargetKind,
21
21
  } from './access-mutation.service.js';
22
- import { canonicalRoleName, EVERYONE_CANONICAL, type Verb } from './access-control.service.js';
22
+ import {
23
+ canonicalRoleName,
24
+ EVERYONE_CANONICAL,
25
+ ROLE_TOKEN_PREFIX,
26
+ type Verb,
27
+ } from './access-control.service.js';
23
28
  import { listAccessDeclarationsUnder } from './access-declarations.js';
24
- import { RolesAdminService, RolesAdminError } from './roles-admin.service.js';
29
+ import { toHttpError as sharedToHttpError, requireNonEmptyString as sharedRequireNonEmptyString } from './admin-route-helpers.js';
30
+ import { RolesAdminService } from './roles-admin.service.js';
25
31
  import type { Principal } from './access-splice.js';
26
32
  import type { Database } from '../database/connection.js';
27
33
  import { users } from '../database/schema.js';
@@ -30,27 +36,16 @@ import '../auth/auth.middleware.js';
30
36
  /** Verbs the share UI may grant. Verbs are independent — `download` is grantable on its own. */
31
37
  const GRANTABLE_VERBS = new Set<string>(['read', 'write', 'owner', 'download']);
32
38
 
33
- function toHttpError(
34
- err: unknown,
35
- ): { status: number; body: Record<string, unknown> } {
36
- if (err instanceof WorkflowDomainError) {
37
- return { status: err.status, body: { error: err.message, ...(err.payload ?? {}) } };
38
- }
39
- const message = err instanceof Error ? err.message : 'Unknown error';
40
- return { status: 500, body: { error: message } };
39
+ /** Shared access-family error shape (see admin-route-helpers): typed domain
40
+ * errors render themselves; anything else is logged and answered generically
41
+ * — a raw `err.message` never leaks through a 500. */
42
+ function toHttpError(err: unknown): { status: number; body: Record<string, unknown> } {
43
+ return sharedToHttpError(err, 'access');
41
44
  }
42
45
 
43
- /**
44
- * Coerce a request-body field that must be a non-empty string. Rejects arrays,
45
- * objects, numbers, null — anything `String()` would silently stringify into a
46
- * bogus role name / email (`[object Object]`, `"a,b"`) — with a 400 instead of
47
- * persisting garbage. Returns the raw string (untrimmed; the service trims).
48
- */
46
+ /** Shared non-empty-string coercion; 400s render as RolesAdminError-family. */
49
47
  function requireNonEmptyString(value: unknown, field: string): string {
50
- if (typeof value !== 'string' || value.trim() === '') {
51
- throw new RolesAdminError(`${field} must be a non-empty string`, 400);
52
- }
53
- return value;
48
+ return sharedRequireNonEmptyString(value, field);
54
49
  }
55
50
 
56
51
  export function createAccessRoutes(
@@ -76,15 +71,62 @@ export function createAccessRoutes(
76
71
  recoveryAdmins,
77
72
  );
78
73
 
79
- // Roles (plugins) are authoritative on the DEFAULT branch — the Roles admin
80
- // screen only ever edits roles.yaml there. Resolve the plugin list from the
81
- // default-branch workspace so the share dialog suggests (and the grant route
82
- // accepts) the same roles the Roles screen manages, regardless of which branch
83
- // the caller is viewing. People stay branch-local (the caller's own workspace).
84
- const defaultBranchPlugins = async (): Promise<string[]> => {
74
+ // Roles and groups are authoritative on the DEFAULT branch — the Roles
75
+ // admin screen only ever edits roles.yaml there, and the ACTIVE group
76
+ // source (IdP-synced or manual) lives there. Resolve both from the
77
+ // default-branch workspace's CACHED resolver model (one call, no per-
78
+ // keystroke file reads) so the share dialog suggests — and the grant route
79
+ // accepts — the same principals the admin screens manage, regardless of
80
+ // which branch the caller is viewing. People stay branch-local (the
81
+ // caller's own workspace).
82
+ const defaultBranchPrincipals = async (): Promise<{ roles: string[]; groups: string[] }> => {
85
83
  await workspaceService.getOrCreateForBranch(DEFAULT_BRANCH);
86
- const { plugins } = await accessControl.kbPrincipals(workspaceIdForBranch(DEFAULT_BRANCH));
87
- return plugins;
84
+ const { roles, groups } = await accessControl.kbPrincipals(workspaceIdForBranch(DEFAULT_BRANCH));
85
+ return { roles, groups };
86
+ };
87
+
88
+ /**
89
+ * What the mutation routes accept as a principal. `group` exists only at
90
+ * this boundary: in the access.md entry grammar a group grant is a
91
+ * bare-name token (bare names resolve GROUP-FIRST, then fall back to the
92
+ * role), so it is spliced as a role-shaped principal — the separate kind
93
+ * buys validation against the right namespace and honest 404s.
94
+ */
95
+ type RoutePrincipal = Principal | { kind: 'group'; group: string };
96
+
97
+ /**
98
+ * The role-shaped principal the splice layer actually writes/matches.
99
+ * - group → its bare name (group-first precedence resolves it).
100
+ * - role → the explicit `role/<Name>` token — the grant route WRITES
101
+ * roles that way from now on (bare role tokens remain
102
+ * read-compatible). The built-in `everyone` stays bare: it
103
+ * is not a roles.yaml role and has no `role/` alias.
104
+ */
105
+ const asSplicePrincipal = (p: RoutePrincipal): Principal => {
106
+ if (p.kind === 'group') return { kind: 'role', role: p.group };
107
+ if (p.kind === 'role') {
108
+ const canonical = canonicalRoleName(p.role);
109
+ const bare = canonical.startsWith(ROLE_TOKEN_PREFIX)
110
+ ? canonical.slice(ROLE_TOKEN_PREFIX.length)
111
+ : canonical;
112
+ // The built-in `everyone` has NO `role/` alias in the principal index
113
+ // (it is not a roles.yaml role), so `role/everyone` would be a DEAD
114
+ // token — granted but resolving to nothing. Normalize either spelling
115
+ // to the bare built-in (keeping the caller's casing, e.g. `Everyone`),
116
+ // on grant and revoke symmetrically.
117
+ if (bare === EVERYONE_CANONICAL) {
118
+ const raw = p.role.trim();
119
+ return {
120
+ kind: 'role',
121
+ role: canonical.startsWith(ROLE_TOKEN_PREFIX)
122
+ ? raw.slice(ROLE_TOKEN_PREFIX.length).trim()
123
+ : raw,
124
+ };
125
+ }
126
+ if (canonical.startsWith(ROLE_TOKEN_PREFIX)) return p;
127
+ return { kind: 'role', role: `${ROLE_TOKEN_PREFIX}${p.role.trim()}` };
128
+ }
129
+ return p;
88
130
  };
89
131
 
90
132
  // Authentication gate only. Per PLAN §3 the workspace `:id` is a branch
@@ -180,7 +222,7 @@ export function createAccessRoutes(
180
222
  *
181
223
  * Every access declaration living INSIDE a folder — the descendant
182
224
  * `access.md` files and the node frontmatter that override the folder's own
183
- * rules for the principals they name. Display-only: the plugin access surface
225
+ * rules for the principals they name. Display-only: the folder access surface
184
226
  * shows it so nobody reads a folder's share list as the whole story.
185
227
  *
186
228
  * Gating is stricter than the sibling `GET /access`, which hands its eligible
@@ -270,14 +312,19 @@ export function createAccessRoutes(
270
312
 
271
313
  /**
272
314
  * GET /api/workspace/:id/access/suggest?q=<query>
273
- * Autocomplete for the share dialog. Returns matching plugins (roles.yaml role
274
- * names — resolved from the DEFAULT branch, the authoritative roles source)
275
- * and people (branch-local to `:id`). Plugins are small + non-sensitive so they
276
- * always show;
277
- * PEOPLE are withheld until `q` is ≥ 2 chars, so an empty query can't dump the
278
- * whole directory (email-harvesting guard). People are the union of the
279
- * KB-canonical set (roles.yaml + access.md grants) and the `users` table
280
- * (logged-in users). Results are capped.
315
+ * Autocomplete for the share dialog. Returns matching roles (roles.yaml role
316
+ * names — resolved from the DEFAULT branch, the authoritative source),
317
+ * groups (the ACTIVE group source, same authority), and people (branch-local
318
+ * to `:id`). Roles/groups come from the resolver's CACHED model — no fresh
319
+ * file reads per keystroke. Roles + groups are small + non-sensitive so they
320
+ * always show; PEOPLE are withheld until `q` is ≥ 2 chars, so an empty query
321
+ * can't dump the whole directory (email-harvesting guard). People are the
322
+ * union of the KB-canonical set (roles.yaml + access.md grants) and the
323
+ * `users` table (logged-in users). Results are capped.
324
+ *
325
+ * A name shared by a group and a role is offered as BOTH — nothing is
326
+ * withheld: grant precedence resolves the collision (bare token = the
327
+ * group; the role is written as `role/<Name>`).
281
328
  */
282
329
  router.get('/workspace/:id/access/suggest', async (req, res) => {
283
330
  const user = await requireUser(req, res);
@@ -286,10 +333,20 @@ export function createAccessRoutes(
286
333
  const q = (typeof req.query.q === 'string' ? req.query.q : '').trim().toLowerCase();
287
334
  const CAP = 15;
288
335
  try {
289
- const plugins = await defaultBranchPlugins();
290
- const { people: kbPeople } = await accessControl.kbPrincipals(req.params.id);
336
+ // Independent lookups batched in ONE Promise.all: the default-branch
337
+ // principals (cached model), the branch-local KB people (cached model),
338
+ // and the users table (only consulted once the query is long enough).
339
+ const [{ roles, groups }, { people: kbPeople }, userRows] = await Promise.all([
340
+ defaultBranchPrincipals(),
341
+ accessControl.kbPrincipals(req.params.id),
342
+ q.length >= 2 ? db.select().from(users) : Promise.resolve([]),
343
+ ]);
344
+
345
+ const matchedRoles = roles
346
+ .filter((g) => !q || g.toLowerCase().includes(q))
347
+ .slice(0, CAP);
291
348
 
292
- const matchedPlugins = plugins
349
+ const matchedGroups = groups
293
350
  .filter((g) => !q || g.toLowerCase().includes(q))
294
351
  .slice(0, CAP);
295
352
 
@@ -298,7 +355,6 @@ export function createAccessRoutes(
298
355
  // Union the KB-canonical people with the login-only users table.
299
356
  const byEmail = new Map<string, { name: string; email: string }>();
300
357
  for (const p of kbPeople) byEmail.set(p.email.toLowerCase(), p);
301
- const userRows = await db.select().from(users);
302
358
  for (const u of userRows) {
303
359
  const key = u.email.toLowerCase();
304
360
  // Prefer a real display name over the email local-part default: only
@@ -314,7 +370,16 @@ export function createAccessRoutes(
314
370
  .slice(0, CAP);
315
371
  }
316
372
 
317
- res.json({ plugins: matchedPlugins, people, peopleWithheld: q.length < 2 });
373
+ res.json({
374
+ roles: matchedRoles,
375
+ groups: matchedGroups,
376
+ people,
377
+ peopleWithheld: q.length < 2,
378
+ // DEPRECATED alias of `roles` — the shipped share dialog still reads
379
+ // `plugins`. Kept populated for ONE release; remove in 0.2.0 together
380
+ // with the dialog's rename to `roles`.
381
+ plugins: matchedRoles,
382
+ });
318
383
  } catch (err) {
319
384
  const { status, body } = toHttpError(err);
320
385
  res.status(status).json(body);
@@ -328,7 +393,7 @@ export function createAccessRoutes(
328
393
  function parseMutationBody(body: unknown): {
329
394
  repoRelTarget: string;
330
395
  kind: TargetKind;
331
- principal: Principal;
396
+ principal: RoutePrincipal;
332
397
  } {
333
398
  const b = (body ?? {}) as Record<string, unknown>;
334
399
  const rawPath = b.path;
@@ -343,7 +408,7 @@ export function createAccessRoutes(
343
408
  if (!principalRaw || typeof principalRaw !== 'object') {
344
409
  throw new AccessMutationError('principal is required');
345
410
  }
346
- let principal: Principal;
411
+ let principal: RoutePrincipal;
347
412
  if (principalRaw.kind === 'user') {
348
413
  if (typeof principalRaw.email !== 'string' || typeof principalRaw.displayName !== 'string') {
349
414
  throw new AccessMutationError('user principal needs email + displayName');
@@ -358,8 +423,13 @@ export function createAccessRoutes(
358
423
  throw new AccessMutationError('role principal needs a role name');
359
424
  }
360
425
  principal = { kind: 'role', role: principalRaw.role };
426
+ } else if (principalRaw.kind === 'group') {
427
+ if (typeof principalRaw.group !== 'string') {
428
+ throw new AccessMutationError('group principal needs a group name');
429
+ }
430
+ principal = { kind: 'group', group: principalRaw.group };
361
431
  } else {
362
- throw new AccessMutationError("principal.kind must be 'user' or 'role'");
432
+ throw new AccessMutationError("principal.kind must be 'user', 'group', or 'role'");
363
433
  }
364
434
  const targetKind = kind as TargetKind;
365
435
  const repoRelTarget = toRepoRelative(rawPath);
@@ -433,30 +503,44 @@ export function createAccessRoutes(
433
503
  ]);
434
504
 
435
505
  // Per-principal, per-verb origin (direct / ancestor — MECE over editable
436
- // files). Keyed `u:<email>` / `r:<role>` to match the dialog's row keys, so
437
- // each row can show where its access comes from and which verbs are
438
- // removable here. A row whose verbs resolve only via a group/everyone/rescue
439
- // has no source (the verb is absent) and renders non-actionable. Built over
440
- // the union of every principal in the four eligible lists.
441
- const roleSet = new Set<string>([
442
- ...eligible.roles,
443
- ...readers.roles,
444
- ...owners.roles,
445
- ...downloaders.roles,
446
- ]);
506
+ // files). Keyed `u:<email>` / `r:<role>` / `g:<group>` to match the
507
+ // dialog's row keys, so each row can show where its access comes from and
508
+ // which verbs are removable here. Groups get their OWN `g:` namespace: a
509
+ // group and a role sharing a name are DIFFERENT principals (bare token vs
510
+ // `role/<name>`), and one shared `r:` entry could only describe one of
511
+ // them. Each kind resolves through the token spelling that IS that
512
+ // principal — a group through its bare token (group-first precedence), a
513
+ // role through its explicit `role/<name>` alias (correct whether or not a
514
+ // group shadows the name; the built-in `everyone` keeps its bare spelling,
515
+ // it has no alias). A row whose verbs resolve only via a
516
+ // group/everyone/rescue has no source (the verb is absent) and renders
517
+ // non-actionable. Built over the union of every principal in the four
518
+ // eligible lists (kinded `principals`, with the name-only `roles` list as
519
+ // the all-roles fallback).
520
+ const collectives = new Map<string, { name: string; kind: 'role' | 'group' }>();
521
+ for (const list of [eligible, readers, owners, downloaders]) {
522
+ const kinded =
523
+ list.principals ?? list.roles.map((name) => ({ name, kind: 'role' as const }));
524
+ for (const p of kinded) {
525
+ const key = `${p.kind === 'group' ? 'g' : 'r'}:${p.name.toLowerCase()}`;
526
+ if (!collectives.has(key)) collectives.set(key, p);
527
+ }
528
+ }
447
529
  const userSet = new Map<string, { name: string; email: string }>();
448
530
  for (const u of [...eligible.users, ...readers.users, ...owners.users, ...downloaders.users]) {
449
531
  if (!userSet.has(u.email.toLowerCase())) userSet.set(u.email.toLowerCase(), u);
450
532
  }
451
533
  const sources: Record<string, Awaited<ReturnType<IAccessControl['grantSources']>>> = {};
452
534
  await Promise.all([
453
- ...[...roleSet].map(async (role) => {
454
- sources[`r:${role.toLowerCase()}`] = await accessControl.grantSources(
455
- workspaceId,
456
- kind,
457
- repoRelTarget,
458
- { kind: 'role', role },
459
- );
535
+ ...[...collectives.entries()].map(async ([key, p]) => {
536
+ const token =
537
+ p.kind === 'role' && canonicalRoleName(p.name) !== EVERYONE_CANONICAL
538
+ ? `${ROLE_TOKEN_PREFIX}${p.name}`
539
+ : p.name;
540
+ sources[key] = await accessControl.grantSources(workspaceId, kind, repoRelTarget, {
541
+ kind: 'role',
542
+ role: token,
543
+ });
460
544
  }),
461
545
  ...[...userSet.values()].map(async (u) => {
462
546
  sources[`u:${u.email.toLowerCase()}`] = await accessControl.grantSources(
@@ -526,7 +610,7 @@ export function createAccessRoutes(
526
610
 
527
611
  /**
528
612
  * POST /api/workspace/:id/access/grant
529
- * Body: `{ path, kind: 'folder'|'file', verb: 'write'|'owner', principal }`.
613
+ * Body: `{ path, kind: 'folder'|'file', verb: 'read'|'write'|'download'|'owner', principal }`.
530
614
  * Grants the principal the verb on the target (folder → its access.md, file →
531
615
  * its own frontmatter). Gated on write to the access config (fail-closed on
532
616
  * protected branches). Returns the fresh resolved access for the target.
@@ -545,31 +629,54 @@ export function createAccessRoutes(
545
629
  }
546
630
  const { repoRelTarget, kind, principal } = parseMutationBody(req.body);
547
631
 
548
- // For a plugin grant, the role must exist in roles.yaml (Slice 1 grants
549
- // existing plugins only; Create Plugin is Slice 2). Reject loudly otherwise.
550
- // Validate against the DEFAULT-branch roles (same authoritative source the
551
- // suggest autocomplete uses) so a default-branch role can't be suggested
552
- // then rejected here on a feature branch whose roles.yaml has diverged.
632
+ // For a role grant, the role must exist in roles.yaml. Reject loudly
633
+ // otherwise. Validate against the DEFAULT-branch roles (same
634
+ // authoritative source the suggest autocomplete uses) so a
635
+ // default-branch role can't be suggested then rejected here on a
636
+ // feature branch whose roles.yaml has diverged. No collision refusals:
637
+ // a name shared with a group is fine — the grant is WRITTEN as the
638
+ // explicit `role/<Name>` token, which precedence always resolves to the
639
+ // role.
553
640
  if (principal.kind === 'role') {
554
- const plugins = await defaultBranchPlugins();
555
- const known = plugins.some((g) => canonicalRoleName(g) === canonicalRoleName(principal.role));
641
+ const { roles } = await defaultBranchPrincipals();
642
+ const bare = canonicalRoleName(principal.role).startsWith(ROLE_TOKEN_PREFIX)
643
+ ? canonicalRoleName(principal.role).slice(ROLE_TOKEN_PREFIX.length)
644
+ : canonicalRoleName(principal.role);
645
+ const known = roles.some((g) => canonicalRoleName(g) === bare);
556
646
  if (!known) {
557
647
  throw new AccessMutationError(
558
- `No plugin named "${principal.role}". Pick an existing plugin, or ask an admin to create it.`,
648
+ `No role named "${principal.role}". Pick an existing role.`,
559
649
  404,
560
- { kind: 'unknown-plugin', role: principal.role },
650
+ { kind: 'unknown-role', role: principal.role },
561
651
  );
562
652
  }
563
653
  // The built-in `everyone` role is grantable from the share UI for READ
564
654
  // only (public read). Write/owner/download to everyone would make the
565
655
  // node world-writable from a casual dialog, so those stay a direct
566
656
  // access.md edit.
567
- if (canonicalRoleName(principal.role) === EVERYONE_CANONICAL && verb !== 'read') {
657
+ if (bare === EVERYONE_CANONICAL && verb !== 'read') {
658
+ throw new AccessMutationError(
659
+ '"Everyone" can only be granted read access (public). Grant other access levels to specific people or roles.',
660
+ );
661
+ }
662
+ } else if (principal.kind === 'group') {
663
+ // A group grant must name a group in the ACTIVE source (IdP-synced or
664
+ // manual) on the default branch — same authority the suggest list
665
+ // uses. No role-collision refusal: bare tokens resolve GROUP-FIRST,
666
+ // so the written bare name IS the group.
667
+ const { groups } = await defaultBranchPrincipals();
668
+ const canonical = canonicalRoleName(principal.group);
669
+ if (!groups.some((g) => canonicalRoleName(g) === canonical)) {
568
670
  throw new AccessMutationError(
569
- '"Everyone" can only be granted read access (public). Grant other access levels to specific people or plugins.',
671
+ `No group named "${principal.group}". Pick an existing group.`,
672
+ 404,
673
+ { kind: 'unknown-group', group: principal.group },
570
674
  );
571
675
  }
572
676
  }
677
+ // Splice shape: group → bare token (group-first precedence); role →
678
+ // explicit `role/<Name>` token (see asSplicePrincipal).
679
+ const splicePrincipal = asSplicePrincipal(principal);
573
680
 
574
681
  const { gatePath } = gateAndEditPaths(kind, repoRelTarget);
575
682
  // Ensure the branch workspace exists before locking.
@@ -579,7 +686,7 @@ export function createAccessRoutes(
579
686
 
580
687
  const editPath = gatePath; // grant edits the same path it gates on
581
688
  await withEditLock(workspaceId, branch, editPath, user, async () => {
582
- await mutation.grant(workspaceId, kind, repoRelTarget, verb as Verb, principal);
689
+ await mutation.grant(workspaceId, kind, repoRelTarget, verb as Verb, splicePrincipal);
583
690
  });
584
691
 
585
692
  // The write landed on disk under the lock; drop the cache so the
@@ -635,7 +742,19 @@ export function createAccessRoutes(
635
742
  const branch = branchForWorkspaceId(workspaceId);
636
743
  try {
637
744
  const mode = (req.body as { mode?: unknown }).mode;
638
- const { repoRelTarget, kind, principal } = parseMutationBody(req.body);
745
+ const { repoRelTarget, kind, principal: routePrincipal } = parseMutationBody(req.body);
746
+ // A group principal converts straight to the role-shaped bare token —
747
+ // no existence check (revoking a grant whose group has since vanished
748
+ // must keep working).
749
+ const principal = asSplicePrincipal(routePrincipal);
750
+ // GROUP revokes match the EXACT bare token only. The service's own
751
+ // shadow probe decides exact-vs-name for roles, but it cannot know a
752
+ // bare token was a GROUP once that group vanished from the active
753
+ // source — the name would read "unshadowed" and name-level matching
754
+ // would strip a same-named role's live `role/<Name>` grant. The route
755
+ // still knows the caller's kind, so it pins the matching here.
756
+ const revokeOpts =
757
+ routePrincipal.kind === 'group' ? ({ tokenMatch: 'exact' } as const) : undefined;
639
758
  // Optional `verb` scopes ALL three paths to a single verb: a default revoke
640
759
  // strips just that verb; remove-from-parent removes just that verb on the
641
760
  // ancestor; deny-here denies just that verb at the target. Absent ⇒ the
@@ -691,7 +810,7 @@ export function createAccessRoutes(
691
810
  );
692
811
  }
693
812
  // Scope the ancestor revoke to the same verb the user acted on (if any).
694
- await mutation.revoke(workspaceId, 'folder', ancestorDir, principal, user.email, verb);
813
+ await mutation.revoke(workspaceId, 'folder', ancestorDir, principal, user.email, verb, revokeOpts);
695
814
  });
696
815
 
697
816
  accessControl.invalidate(workspaceId);
@@ -709,7 +828,7 @@ export function createAccessRoutes(
709
828
  await assertCanMutate(workspaceId, branch, user.email, gatePath);
710
829
  await withEditLock(workspaceId, branch, gatePath, user, async () => {
711
830
  // Scope the deny to the same verb the user acted on (if any).
712
- await mutation.denyHere(workspaceId, kind, repoRelTarget, principal, verb);
831
+ await mutation.denyHere(workspaceId, kind, repoRelTarget, principal, verb, revokeOpts);
713
832
  });
714
833
  accessControl.invalidate(workspaceId);
715
834
  console.log(
@@ -727,7 +846,7 @@ export function createAccessRoutes(
727
846
  const editPath = gatePath;
728
847
  let changed = false;
729
848
  await withEditLock(workspaceId, branch, editPath, user, async () => {
730
- const r = await mutation.revoke(workspaceId, kind, repoRelTarget, principal, user.email, verb);
849
+ const r = await mutation.revoke(workspaceId, kind, repoRelTarget, principal, user.email, verb, revokeOpts);
731
850
  changed = r.changed;
732
851
  });
733
852
 
@@ -738,7 +857,7 @@ export function createAccessRoutes(
738
857
  // any source left is an `ancestor`. Distinguish "inherited" (still named in
739
858
  // an ancestor access.md — offer cascade/deny) from "no removable source
740
859
  // here" (nothing named in any file we can edit — a genuine no-op). A
741
- // principal who only RESOLVES via a plugin / everyone / admin-rescue has no
860
+ // principal who only RESOLVES via a group/role / everyone / admin-rescue has no
742
861
  // source at all (it's not their own file entry) and so is correctly a 200
743
862
  // no-op: there is nothing to remove here and no Remove was ever offered for
744
863
  // them in the dialog. NEVER a silent unchanged-success when there IS a
@@ -796,7 +915,10 @@ export function createAccessRoutes(
796
915
  });
797
916
 
798
917
  // -------------------------------------------------------------------------
799
- // Roles & Members (admin) — full CRUD on the DEFAULT-branch roles.yaml.
918
+ // Roles & Members (admin) — MEMBERSHIP editing on the DEFAULT-branch
919
+ // roles.yaml. Roles are app-defined capabilities: there is deliberately NO
920
+ // create/rename/delete route — the admin surface cannot mint, rebrand, or
921
+ // retire a role (legacy people-set roles migrate out via convert-to-group).
800
922
  // Routes are GLOBAL (no `:id`): the handler resolves the default-branch
801
923
  // workspace internally, because that is the file admin status derives from.
802
924
  // Mutating routes are admin-gated by `assertRolesAdmin` (the same admin-only
@@ -828,54 +950,66 @@ export function createAccessRoutes(
828
950
  }
829
951
  });
830
952
 
831
- router.post('/access/roles', async (req, res) => {
953
+ // NOTE: POST /access/roles (create), PATCH /access/roles/:canonical
954
+ // (rename), and DELETE /access/roles/:canonical are GONE — roles are
955
+ // app-defined capabilities, not user-editable objects.
956
+
957
+ router.post('/access/roles/:canonical/members', async (req, res) => {
832
958
  const user = await requireUser(req, res);
833
959
  if (!user) return;
834
960
  try {
835
961
  await assertRolesAdmin(user.email);
836
- const displayName = requireNonEmptyString((req.body ?? {}).displayName, 'displayName');
837
- res.json({ roles: await rolesAdmin.createRole(user, displayName) });
962
+ const canonical = canonicalRoleName(req.params.canonical);
963
+ const email = requireNonEmptyString((req.body ?? {}).email, 'email');
964
+ res.json({ roles: await rolesAdmin.addMember(user, canonical, email) });
838
965
  } catch (err) {
839
966
  const { status, body } = toHttpError(err);
840
967
  res.status(status).json(body);
841
968
  }
842
969
  });
843
970
 
844
- router.delete('/access/roles/:canonical', async (req, res) => {
971
+ // Assign / unassign a GROUP to a role (capability-follows-membership). Any
972
+ // roster role accepts group references, Admin included — Admin's safety net
973
+ // is the parse-time invariant that it always keeps at least one DIRECT
974
+ // email member (a group-only Admin is rejected as hard as an adminless
975
+ // roles.yaml), so a broken or hostile directory can never leave the
976
+ // deployment adminless.
977
+ router.post('/access/roles/:canonical/groups', async (req, res) => {
845
978
  const user = await requireUser(req, res);
846
979
  if (!user) return;
847
980
  try {
848
981
  await assertRolesAdmin(user.email);
849
982
  const canonical = canonicalRoleName(req.params.canonical);
850
- res.json({ roles: await rolesAdmin.deleteRole(user, canonical) });
983
+ const group = requireNonEmptyString((req.body ?? {}).group, 'group');
984
+ res.json({ roles: await rolesAdmin.assignGroup(user, canonical, group) });
851
985
  } catch (err) {
852
986
  const { status, body } = toHttpError(err);
853
987
  res.status(status).json(body);
854
988
  }
855
989
  });
856
990
 
857
- router.patch('/access/roles/:canonical', async (req, res) => {
991
+ router.delete('/access/roles/:canonical/groups/:group', async (req, res) => {
858
992
  const user = await requireUser(req, res);
859
993
  if (!user) return;
860
994
  try {
861
995
  await assertRolesAdmin(user.email);
862
996
  const canonical = canonicalRoleName(req.params.canonical);
863
- const newDisplayName = requireNonEmptyString((req.body ?? {}).newDisplayName, 'newDisplayName');
864
- res.json({ roles: await rolesAdmin.renameRole(user, canonical, newDisplayName) });
997
+ res.json({ roles: await rolesAdmin.unassignGroup(user, canonical, String(req.params.group)) });
865
998
  } catch (err) {
866
999
  const { status, body } = toHttpError(err);
867
1000
  res.status(status).json(body);
868
1001
  }
869
1002
  });
870
1003
 
871
- router.post('/access/roles/:canonical/members', async (req, res) => {
1004
+ // Convert a legacy people-set role into a manual group (atomic two-file
1005
+ // move; grants keep working because the name is unchanged).
1006
+ router.post('/access/roles/:canonical/convert-to-group', async (req, res) => {
872
1007
  const user = await requireUser(req, res);
873
1008
  if (!user) return;
874
1009
  try {
875
1010
  await assertRolesAdmin(user.email);
876
1011
  const canonical = canonicalRoleName(req.params.canonical);
877
- const email = requireNonEmptyString((req.body ?? {}).email, 'email');
878
- res.json({ roles: await rolesAdmin.addMember(user, canonical, email) });
1012
+ res.json({ roles: await rolesAdmin.convertRoleToGroup(user, canonical) });
879
1013
  } catch (err) {
880
1014
  const { status, body } = toHttpError(err);
881
1015
  res.status(status).json(body);