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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +12 -1
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +22 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +34 -1
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +4 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/modules/access/access-control.interface.d.ts +54 -28
  13. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  14. package/dist/modules/access/access-control.service.d.ts +129 -14
  15. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  16. package/dist/modules/access/access-control.service.js +435 -66
  17. package/dist/modules/access/access-control.service.js.map +1 -1
  18. package/dist/modules/access/access-declarations.d.ts.map +1 -1
  19. package/dist/modules/access/access-declarations.js +5 -3
  20. package/dist/modules/access/access-declarations.js.map +1 -1
  21. package/dist/modules/access/access-mutation.service.d.ts +39 -6
  22. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  23. package/dist/modules/access/access-mutation.service.js +78 -18
  24. package/dist/modules/access/access-mutation.service.js.map +1 -1
  25. package/dist/modules/access/access-splice.d.ts +31 -4
  26. package/dist/modules/access/access-splice.d.ts.map +1 -1
  27. package/dist/modules/access/access-splice.js +40 -16
  28. package/dist/modules/access/access-splice.js.map +1 -1
  29. package/dist/modules/access/access.routes.d.ts.map +1 -1
  30. package/dist/modules/access/access.routes.js +204 -82
  31. package/dist/modules/access/access.routes.js.map +1 -1
  32. package/dist/modules/access/admin-locked-commit.d.ts +134 -0
  33. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
  34. package/dist/modules/access/admin-locked-commit.js +277 -0
  35. package/dist/modules/access/admin-locked-commit.js.map +1 -0
  36. package/dist/modules/access/admin-route-helpers.d.ts +32 -0
  37. package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
  38. package/dist/modules/access/admin-route-helpers.js +44 -0
  39. package/dist/modules/access/admin-route-helpers.js.map +1 -0
  40. package/dist/modules/access/capability-registry.d.ts +41 -0
  41. package/dist/modules/access/capability-registry.d.ts.map +1 -0
  42. package/dist/modules/access/capability-registry.js +46 -0
  43. package/dist/modules/access/capability-registry.js.map +1 -0
  44. package/dist/modules/access/directory-sync-bot.d.ts +13 -0
  45. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
  46. package/dist/modules/access/directory-sync-bot.js +64 -0
  47. package/dist/modules/access/directory-sync-bot.js.map +1 -0
  48. package/dist/modules/access/group-files.d.ts +83 -0
  49. package/dist/modules/access/group-files.d.ts.map +1 -0
  50. package/dist/modules/access/group-files.js +167 -0
  51. package/dist/modules/access/group-files.js.map +1 -0
  52. package/dist/modules/access/groups-admin.routes.d.ts +19 -0
  53. package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
  54. package/dist/modules/access/groups-admin.routes.js +98 -0
  55. package/dist/modules/access/groups-admin.routes.js.map +1 -0
  56. package/dist/modules/access/groups-admin.service.d.ts +166 -0
  57. package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
  58. package/dist/modules/access/groups-admin.service.js +442 -0
  59. package/dist/modules/access/groups-admin.service.js.map +1 -0
  60. package/dist/modules/access/groups-edit.d.ts +58 -0
  61. package/dist/modules/access/groups-edit.d.ts.map +1 -0
  62. package/dist/modules/access/groups-edit.js +162 -0
  63. package/dist/modules/access/groups-edit.js.map +1 -0
  64. package/dist/modules/access/reference-scan.d.ts +141 -0
  65. package/dist/modules/access/reference-scan.d.ts.map +1 -0
  66. package/dist/modules/access/reference-scan.js +440 -0
  67. package/dist/modules/access/reference-scan.js.map +1 -0
  68. package/dist/modules/access/roles-admin.service.d.ts +88 -119
  69. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  70. package/dist/modules/access/roles-admin.service.js +230 -384
  71. package/dist/modules/access/roles-admin.service.js.map +1 -1
  72. package/dist/modules/access/roles-edit.d.ts +51 -25
  73. package/dist/modules/access/roles-edit.d.ts.map +1 -1
  74. package/dist/modules/access/roles-edit.js +133 -59
  75. package/dist/modules/access/roles-edit.js.map +1 -1
  76. package/dist/modules/access/synced-groups-committer.d.ts +28 -0
  77. package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
  78. package/dist/modules/access/synced-groups-committer.js +139 -0
  79. package/dist/modules/access/synced-groups-committer.js.map +1 -0
  80. package/dist/modules/access/synced-groups-writer.d.ts +78 -0
  81. package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
  82. package/dist/modules/access/synced-groups-writer.js +219 -0
  83. package/dist/modules/access/synced-groups-writer.js.map +1 -0
  84. package/dist/modules/database/core-schema.d.ts +17 -0
  85. package/dist/modules/database/core-schema.d.ts.map +1 -1
  86. package/dist/modules/database/core-schema.js +9 -0
  87. package/dist/modules/database/core-schema.js.map +1 -1
  88. package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
  89. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  90. package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
  91. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  92. package/dist/modules/mcp/mcp.routes.d.ts +9 -3
  93. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  94. package/dist/modules/mcp/mcp.routes.js +126 -2
  95. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  96. package/dist/modules/mcp/mcp.service.d.ts +14 -0
  97. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  98. package/dist/modules/mcp/mcp.service.js +6 -1
  99. package/dist/modules/mcp/mcp.service.js.map +1 -1
  100. package/dist/modules/workflow/file-lock.service.d.ts +11 -1
  101. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  102. package/dist/modules/workflow/file-lock.service.js +15 -1
  103. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  104. package/dist/modules/workflow/git/git.service.d.ts +34 -11
  105. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  106. package/dist/modules/workflow/git/git.service.js +165 -37
  107. package/dist/modules/workflow/git/git.service.js.map +1 -1
  108. package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
  109. package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
  110. package/dist/modules/workflow/locking-filesystem.js +181 -28
  111. package/dist/modules/workflow/locking-filesystem.js.map +1 -1
  112. package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
  113. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  114. package/dist/modules/workflow/pending-commits.service.js +19 -1
  115. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  116. package/dist/modules/workflow/workflow.service.d.ts +17 -2
  117. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  118. package/dist/modules/workflow/workflow.service.js +113 -19
  119. package/dist/modules/workflow/workflow.service.js.map +1 -1
  120. package/kb-template/AGENTS.md +4 -1
  121. package/migrations/0004_file_lock_mode.sql +2 -0
  122. package/migrations/meta/0004_snapshot.json +1564 -0
  123. package/migrations/meta/_journal.json +7 -0
  124. package/package.json +3 -3
  125. package/src/core/create-core-server.ts +13 -0
  126. package/src/core/create-core-services.ts +68 -0
  127. package/src/index.ts +10 -0
  128. package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
  129. package/src/modules/access/__tests__/access-groups.test.ts +427 -0
  130. package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
  131. package/src/modules/access/__tests__/access-splice.test.ts +65 -0
  132. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
  133. package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
  134. package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
  135. package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
  136. package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
  137. package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
  138. package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
  139. package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
  140. package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
  141. package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
  142. package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
  143. package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
  144. package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
  145. package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
  146. package/src/modules/access/access-control.interface.ts +66 -32
  147. package/src/modules/access/access-control.service.ts +536 -73
  148. package/src/modules/access/access-declarations.ts +5 -2
  149. package/src/modules/access/access-mutation.service.ts +88 -14
  150. package/src/modules/access/access-splice.ts +55 -17
  151. package/src/modules/access/access.routes.ts +227 -93
  152. package/src/modules/access/admin-locked-commit.ts +331 -0
  153. package/src/modules/access/admin-route-helpers.ts +55 -0
  154. package/src/modules/access/capability-registry.ts +74 -0
  155. package/src/modules/access/directory-sync-bot.ts +76 -0
  156. package/src/modules/access/group-files.ts +212 -0
  157. package/src/modules/access/groups-admin.routes.ts +113 -0
  158. package/src/modules/access/groups-admin.service.ts +551 -0
  159. package/src/modules/access/groups-edit.ts +187 -0
  160. package/src/modules/access/reference-scan.ts +513 -0
  161. package/src/modules/access/roles-admin.service.ts +290 -418
  162. package/src/modules/access/roles-edit.ts +134 -61
  163. package/src/modules/access/synced-groups-committer.ts +177 -0
  164. package/src/modules/access/synced-groups-writer.ts +303 -0
  165. package/src/modules/database/core-schema.ts +9 -0
  166. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
  167. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
  168. package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
  169. package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
  170. package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
  171. package/src/modules/mcp/mcp.routes.ts +137 -2
  172. package/src/modules/mcp/mcp.service.ts +6 -1
  173. package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
  174. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
  175. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
  176. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
  177. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
  178. package/src/modules/workflow/file-lock.service.ts +15 -0
  179. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
  180. package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
  181. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
  182. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
  183. package/src/modules/workflow/git/git.service.ts +174 -35
  184. package/src/modules/workflow/locking-filesystem.ts +188 -26
  185. package/src/modules/workflow/pending-commits.service.ts +27 -1
  186. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
  187. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
  188. package/src/modules/workflow/workflow.service.ts +140 -20
@@ -0,0 +1,212 @@
1
+ import {
2
+ EMAIL_REGEX,
3
+ GROUP_REF_PREFIX,
4
+ RESERVED_ROLE_NAMES,
5
+ ROLE_TOKEN_PREFIX,
6
+ canonicalEmail,
7
+ canonicalRoleName,
8
+ parseYamlSubset,
9
+ } from './access-control.service.js';
10
+
11
+ /**
12
+ * Group files — the "who you are" half of the roles/groups split.
13
+ *
14
+ * Groups are people-sets referenced by access grants ("GTM Team can read the
15
+ * battlecards"). A deployment has exactly ONE group source at a time — a mode
16
+ * derived from which file exists, so it holds at any git ref:
17
+ *
18
+ * - `synced-groups.yaml` EXISTS → IdP mode. The file is MACHINE-OWNED,
19
+ * regenerated wholesale from the SCIM directory mirror; membership is
20
+ * managed in the IdP. `groups.yaml` is ignored entirely.
21
+ * - otherwise → manual mode. `groups.yaml` (UI-edited)
22
+ * is the source.
23
+ *
24
+ * Both files share one format, the group twin of roles.yaml:
25
+ *
26
+ * groups:
27
+ * Engineering:
28
+ * - ada@x.io
29
+ * - bo@x.io
30
+ * Empty Group: []
31
+ *
32
+ * Parsing is FORGIVING at the entry level by design — a malformed email or a
33
+ * reserved/duplicate name skips that entry with a warning, it never takes the
34
+ * whole file down. Only structural failures (unparsable YAML, `groups:` not a
35
+ * mapping) reject the file, and even then the RESOLVER degrades to "this file
36
+ * contributes nothing" rather than throwing: unlike roles.yaml there is no
37
+ * admin-lockout risk here, so groups must never be able to brick access
38
+ * resolution. Strict acceptance for WRITES is the job of the write-side
39
+ * validator (`validateGroupsFile` below), which treats warnings as refusals.
40
+ */
41
+
42
+ export const GROUPS_YAML = 'groups.yaml';
43
+ export const SYNCED_GROUPS_YAML = 'synced-groups.yaml';
44
+
45
+ /**
46
+ * THE name-safety predicate for principal display names — the single source
47
+ * of truth every surface derives from (group creation/rename, IdP sync
48
+ * materialization; thin assert wrappers sit on top). Returns a human-readable
49
+ * reason the name is unsafe, or null when it is fine.
50
+ *
51
+ * The character rules keep the emitted YAML parseable and the entry grammar
52
+ * unambiguous:
53
+ * `:` mis-tokenises as a nested mapping key
54
+ * `#` truncated as a comment by the subset parser's stripComment
55
+ * `<` / `>` collide with the `Name <email>` user-reference shape
56
+ * leading - tokenises as a list item
57
+ * \x00-\x1f control chars / newlines break line structure outright
58
+ * `role/…` reserved: that spelling is the EXPLICIT role token in access
59
+ * entries — a group carrying it could never be referenced.
60
+ */
61
+ export function unsafeNameReason(displayName: string): string | null {
62
+ const trimmed = displayName.trim();
63
+ if (!trimmed) return 'empty name';
64
+ if (/[:#<>]/.test(trimmed)) return "contains ':', '#', '<', or '>'";
65
+ // eslint-disable-next-line no-control-regex
66
+ if (/[\x00-\x1f]/.test(trimmed)) return 'contains control characters';
67
+ if (trimmed.startsWith('-')) return "starts with '-'";
68
+ if (canonicalRoleName(trimmed).startsWith(ROLE_TOKEN_PREFIX)) {
69
+ return `starts with the reserved '${ROLE_TOKEN_PREFIX}' prefix (the explicit role token in access entries)`;
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /** One parsed group: display name + canonicalised member emails. */
75
+ export interface GroupDefinition {
76
+ displayName: string;
77
+ emails: Set<string>;
78
+ }
79
+
80
+ /** Canonical group name → definition, in file order. */
81
+ export type GroupsIndex = Map<string, GroupDefinition>;
82
+
83
+ export interface ParsedGroupsFile {
84
+ ok: true;
85
+ groups: GroupsIndex;
86
+ /** Entry-level problems that were skipped over (bad email, dup name, …). */
87
+ warnings: string[];
88
+ }
89
+
90
+ export interface GroupsFileError {
91
+ ok: false;
92
+ errors: string[];
93
+ }
94
+
95
+ /**
96
+ * Parse a group file (either of the two — same grammar). See the module note
97
+ * for the forgiving-vs-structural split.
98
+ */
99
+ export function parseGroupsFile(
100
+ text: string,
101
+ filename: string,
102
+ ): ParsedGroupsFile | GroupsFileError {
103
+ // Tolerate empty keys at parse: this file's contract is ENTRY-level
104
+ // forgiveness (a blank group name is skipped with a warning below), and a
105
+ // hard tokenizer error here would retire every OTHER group fail-closed.
106
+ const parsed = parseYamlSubset(text, { tolerateEmptyKeys: true });
107
+ if (!parsed.ok) return { ok: false, errors: [`${filename}: ${parsed.error}`] };
108
+
109
+ const root = parsed.value;
110
+ if (root == null || typeof root !== 'object' || Array.isArray(root)) {
111
+ return { ok: false, errors: [`${filename}: must be a top-level mapping`] };
112
+ }
113
+
114
+ const groupsNode = (root as Record<string, unknown>).groups;
115
+ // An empty file (or one with no `groups:` key yet) is a valid empty set —
116
+ // a fresh deployment's groups.yaml starts this way.
117
+ if (groupsNode == null) return { ok: true, groups: new Map(), warnings: [] };
118
+ if (typeof groupsNode !== 'object' || Array.isArray(groupsNode)) {
119
+ return { ok: false, errors: [`${filename}: 'groups' must be a mapping`] };
120
+ }
121
+
122
+ const groups: GroupsIndex = new Map();
123
+ const warnings: string[] = [];
124
+
125
+ for (const [displayName, value] of Object.entries(groupsNode as Record<string, unknown>)) {
126
+ const canonical = canonicalRoleName(displayName);
127
+ if (!canonical) {
128
+ warnings.push(`${filename}: empty group name — skipped`);
129
+ continue;
130
+ }
131
+ // THE shared name-safety predicate (see `unsafeNameReason` above) — the
132
+ // same rules every write surface asserts. The parser must apply it too:
133
+ // a name the writers refuse (control chars, `<`/`>`, a leading `-`, the
134
+ // reserved `role/` prefix) can still reach a group file by hand edit or
135
+ // through a skewed writer, and accepting it here would let the resolver
136
+ // honor — and `validateGroupsFile` pass — a name no editor can ever
137
+ // produce or reference safely.
138
+ const unsafe = unsafeNameReason(displayName);
139
+ if (unsafe) {
140
+ warnings.push(
141
+ `${filename}: group ${JSON.stringify(displayName)} ${unsafe} — skipped`,
142
+ );
143
+ continue;
144
+ }
145
+ if (RESERVED_ROLE_NAMES.has(canonical)) {
146
+ warnings.push(
147
+ `${filename}: group '${displayName}' uses reserved name '${canonical}' — skipped`,
148
+ );
149
+ continue;
150
+ }
151
+ if (groups.has(canonical)) {
152
+ warnings.push(
153
+ `${filename}: group '${displayName}' canonicalises to '${canonical}', already declared as '${groups.get(canonical)!.displayName}' — skipped`,
154
+ );
155
+ continue;
156
+ }
157
+ if (value !== null && !Array.isArray(value)) {
158
+ warnings.push(`${filename}: group '${displayName}' must be a list of emails — skipped`);
159
+ continue;
160
+ }
161
+ const emails = new Set<string>();
162
+ for (const raw of value ?? []) {
163
+ if (typeof raw !== 'string') {
164
+ warnings.push(`${filename}: group '${displayName}' has a non-string entry — entry skipped`);
165
+ continue;
166
+ }
167
+ const email = canonicalEmail(raw);
168
+ // The reserved `group:` prefix would PASS the email regex
169
+ // (`group:lee@x.io` shapes like an email) but it is the roles.yaml
170
+ // group-reference token, not an address — groups contain emails, never
171
+ // other groups. Skip it with its own warning so the write gate refuses
172
+ // it and the resolver never grants a colon-bearing "email".
173
+ if (email.startsWith(GROUP_REF_PREFIX)) {
174
+ warnings.push(
175
+ `${filename}: group '${displayName}' has a '${GROUP_REF_PREFIX}'-prefixed entry ${JSON.stringify(raw)} — groups contain emails, not group references; entry skipped`,
176
+ );
177
+ continue;
178
+ }
179
+ if (!EMAIL_REGEX.test(email)) {
180
+ warnings.push(
181
+ `${filename}: group '${displayName}' has malformed email '${raw}' — entry skipped`,
182
+ );
183
+ continue;
184
+ }
185
+ emails.add(email);
186
+ }
187
+ groups.set(canonical, { displayName: displayName.trim(), emails });
188
+ }
189
+
190
+ return { ok: true, groups, warnings };
191
+ }
192
+
193
+ /**
194
+ * Write-gate twin of {@link parseGroupsFile}: strict — structural errors AND
195
+ * entry-level warnings both refuse the candidate, so a UI write can never land
196
+ * a group the resolver would silently skip.
197
+ */
198
+ export function validateGroupsFile(
199
+ text: string,
200
+ filename: string,
201
+ ): { ok: true } | { ok: false; errors: string[] } {
202
+ // Strict PARSE first (no empty-key tolerance): the read side forgives a
203
+ // blank key entry-level so other groups keep resolving, but a WRITE that
204
+ // contains one — anywhere, including outside the `groups:` mapping — must
205
+ // refuse rather than land bytes the tolerant reader silently drops.
206
+ const strict = parseYamlSubset(text.trim() ? text : 'groups:');
207
+ if (!strict.ok) return { ok: false, errors: [`${filename}: ${strict.error}`] };
208
+ const parsed = parseGroupsFile(text, filename);
209
+ if (!parsed.ok) return { ok: false, errors: parsed.errors };
210
+ if (parsed.warnings.length > 0) return { ok: false, errors: parsed.warnings };
211
+ return { ok: true };
212
+ }
@@ -0,0 +1,113 @@
1
+ import express from 'express';
2
+ import type { AuthUser } from '@bevel-software/platform-shared';
3
+ import type { IAdminAccessService } from '../admin/admin.interface.js';
4
+ import { canonicalRoleName } from './access-control.service.js';
5
+ import { sendError as sharedSendError, requireNonEmptyString as sharedRequireNonEmptyString } from './admin-route-helpers.js';
6
+ import type { GroupsAdminService } from './groups-admin.service.js';
7
+ import '../auth/auth.middleware.js'; // Express Request augmentation
8
+
9
+ /**
10
+ * Admin surface for GROUPS — the "who you are" half of the roles/groups
11
+ * split. Manual-mode CRUD on `groups.yaml`; in IdP mode the service refuses
12
+ * mutations with a typed 409 (`kind: 'idp-mode'`) and this router simply
13
+ * relays it. Admin-gated like the other admin routers: the roster is the org
14
+ * chart, not something every signed-in user may enumerate.
15
+ */
16
+ export function createGroupsAdminRoutes(deps: {
17
+ groupsAdmin: GroupsAdminService;
18
+ adminAccess: IAdminAccessService;
19
+ /** Resolve the acting user for commit attribution. */
20
+ getUserById: (id: string) => Promise<AuthUser | null>;
21
+ }): express.Router {
22
+ const { groupsAdmin, adminAccess, getUserById } = deps;
23
+ const router = express.Router();
24
+
25
+ const requireAdmin: express.RequestHandler = async (req, res, next) => {
26
+ if (!(await adminAccess.isAdmin(req.userEmail))) {
27
+ res.status(403).json({ error: 'Admins only' });
28
+ return;
29
+ }
30
+ next();
31
+ };
32
+
33
+ const actorOf = async (req: express.Request): Promise<AuthUser> => {
34
+ const user = req.userId ? await getUserById(req.userId) : null;
35
+ if (user) return user;
36
+ // The JWT authenticated but the row is gone (erased mid-session) — the
37
+ // identity claims are still the honest attribution we hold.
38
+ return { id: req.userId ?? 'unknown', email: req.userEmail ?? 'unknown', name: req.userEmail ?? 'unknown' };
39
+ };
40
+
41
+ // Shared access-family error shape + input coercion (admin-route-helpers):
42
+ // typed domain errors (incl. the roster's broken-groups 422) render
43
+ // themselves; anything else logs server-side and answers generically.
44
+ const sendError = (res: express.Response, err: unknown): void => sharedSendError(res, err, 'groups');
45
+
46
+ const requireNonEmptyString = (value: unknown, field: string): string =>
47
+ sharedRequireNonEmptyString(value, field, { trim: true });
48
+
49
+ // GET /api/admin/groups — mode + roster (groups with grant references and
50
+ // role assignments) + `groupsHealth` (the broken-source banner marker; a
51
+ // broken MANUAL groups.yaml instead answers 422 with the parse message so
52
+ // the operator sees what to fix).
53
+ router.get('/admin/groups', requireAdmin, async (_req, res) => {
54
+ try {
55
+ res.json(await groupsAdmin.getRoster());
56
+ } catch (err) {
57
+ sendError(res, err);
58
+ }
59
+ });
60
+
61
+ router.post('/admin/groups', requireAdmin, async (req, res) => {
62
+ try {
63
+ const displayName = requireNonEmptyString((req.body ?? {}).displayName, 'displayName');
64
+ res.json(await groupsAdmin.createGroup(await actorOf(req), displayName));
65
+ } catch (err) {
66
+ sendError(res, err);
67
+ }
68
+ });
69
+
70
+ router.delete('/admin/groups/:canonical', requireAdmin, async (req, res) => {
71
+ try {
72
+ const canonical = canonicalRoleName(String(req.params.canonical));
73
+ res.json(await groupsAdmin.deleteGroup(await actorOf(req), canonical));
74
+ } catch (err) {
75
+ sendError(res, err);
76
+ }
77
+ });
78
+
79
+ // PATCH /api/admin/groups/:canonical { newDisplayName } — rename;
80
+ // canonical-changing renames rewrite grant references atomically.
81
+ router.patch('/admin/groups/:canonical', requireAdmin, async (req, res) => {
82
+ try {
83
+ const canonical = canonicalRoleName(String(req.params.canonical));
84
+ const newDisplayName = requireNonEmptyString((req.body ?? {}).newDisplayName, 'newDisplayName');
85
+ res.json(await groupsAdmin.renameGroup(await actorOf(req), canonical, newDisplayName));
86
+ } catch (err) {
87
+ sendError(res, err);
88
+ }
89
+ });
90
+
91
+ router.post('/admin/groups/:canonical/members', requireAdmin, async (req, res) => {
92
+ try {
93
+ const canonical = canonicalRoleName(String(req.params.canonical));
94
+ const email = requireNonEmptyString((req.body ?? {}).email, 'email');
95
+ res.json(await groupsAdmin.addMember(await actorOf(req), canonical, email));
96
+ } catch (err) {
97
+ sendError(res, err);
98
+ }
99
+ });
100
+
101
+ router.delete('/admin/groups/:canonical/members/:email', requireAdmin, async (req, res) => {
102
+ try {
103
+ const canonical = canonicalRoleName(String(req.params.canonical));
104
+ res.json(
105
+ await groupsAdmin.removeMember(await actorOf(req), canonical, String(req.params.email)),
106
+ );
107
+ } catch (err) {
108
+ sendError(res, err);
109
+ }
110
+ });
111
+
112
+ return router;
113
+ }