@bevel-software/platform-core-backend 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/THIRD-PARTY-NOTICES.md +2 -2
  2. package/dist/core/create-core-server.d.ts.map +1 -1
  3. package/dist/core/create-core-server.js +16 -1
  4. package/dist/core/create-core-server.js.map +1 -1
  5. package/dist/core/create-core-services.d.ts +25 -0
  6. package/dist/core/create-core-services.d.ts.map +1 -1
  7. package/dist/core/create-core-services.js +45 -1
  8. package/dist/core/create-core-services.js.map +1 -1
  9. package/dist/core-config.d.ts +19 -1
  10. package/dist/core-config.d.ts.map +1 -1
  11. package/dist/core-config.js +44 -4
  12. package/dist/core-config.js.map +1 -1
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +4 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/modules/access/access-control.interface.d.ts +54 -28
  18. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  19. package/dist/modules/access/access-control.service.d.ts +129 -14
  20. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  21. package/dist/modules/access/access-control.service.js +435 -66
  22. package/dist/modules/access/access-control.service.js.map +1 -1
  23. package/dist/modules/access/access-declarations.d.ts.map +1 -1
  24. package/dist/modules/access/access-declarations.js +5 -3
  25. package/dist/modules/access/access-declarations.js.map +1 -1
  26. package/dist/modules/access/access-mutation.service.d.ts +39 -6
  27. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-mutation.service.js +78 -18
  29. package/dist/modules/access/access-mutation.service.js.map +1 -1
  30. package/dist/modules/access/access-splice.d.ts +31 -4
  31. package/dist/modules/access/access-splice.d.ts.map +1 -1
  32. package/dist/modules/access/access-splice.js +40 -16
  33. package/dist/modules/access/access-splice.js.map +1 -1
  34. package/dist/modules/access/access.routes.d.ts.map +1 -1
  35. package/dist/modules/access/access.routes.js +204 -82
  36. package/dist/modules/access/access.routes.js.map +1 -1
  37. package/dist/modules/access/admin-locked-commit.d.ts +134 -0
  38. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
  39. package/dist/modules/access/admin-locked-commit.js +277 -0
  40. package/dist/modules/access/admin-locked-commit.js.map +1 -0
  41. package/dist/modules/access/admin-route-helpers.d.ts +32 -0
  42. package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
  43. package/dist/modules/access/admin-route-helpers.js +44 -0
  44. package/dist/modules/access/admin-route-helpers.js.map +1 -0
  45. package/dist/modules/access/capability-registry.d.ts +41 -0
  46. package/dist/modules/access/capability-registry.d.ts.map +1 -0
  47. package/dist/modules/access/capability-registry.js +46 -0
  48. package/dist/modules/access/capability-registry.js.map +1 -0
  49. package/dist/modules/access/directory-sync-bot.d.ts +13 -0
  50. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
  51. package/dist/modules/access/directory-sync-bot.js +64 -0
  52. package/dist/modules/access/directory-sync-bot.js.map +1 -0
  53. package/dist/modules/access/group-files.d.ts +83 -0
  54. package/dist/modules/access/group-files.d.ts.map +1 -0
  55. package/dist/modules/access/group-files.js +167 -0
  56. package/dist/modules/access/group-files.js.map +1 -0
  57. package/dist/modules/access/groups-admin.routes.d.ts +19 -0
  58. package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
  59. package/dist/modules/access/groups-admin.routes.js +98 -0
  60. package/dist/modules/access/groups-admin.routes.js.map +1 -0
  61. package/dist/modules/access/groups-admin.service.d.ts +166 -0
  62. package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
  63. package/dist/modules/access/groups-admin.service.js +442 -0
  64. package/dist/modules/access/groups-admin.service.js.map +1 -0
  65. package/dist/modules/access/groups-edit.d.ts +58 -0
  66. package/dist/modules/access/groups-edit.d.ts.map +1 -0
  67. package/dist/modules/access/groups-edit.js +162 -0
  68. package/dist/modules/access/groups-edit.js.map +1 -0
  69. package/dist/modules/access/reference-scan.d.ts +141 -0
  70. package/dist/modules/access/reference-scan.d.ts.map +1 -0
  71. package/dist/modules/access/reference-scan.js +440 -0
  72. package/dist/modules/access/reference-scan.js.map +1 -0
  73. package/dist/modules/access/roles-admin.service.d.ts +88 -119
  74. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  75. package/dist/modules/access/roles-admin.service.js +230 -384
  76. package/dist/modules/access/roles-admin.service.js.map +1 -1
  77. package/dist/modules/access/roles-edit.d.ts +51 -25
  78. package/dist/modules/access/roles-edit.d.ts.map +1 -1
  79. package/dist/modules/access/roles-edit.js +133 -59
  80. package/dist/modules/access/roles-edit.js.map +1 -1
  81. package/dist/modules/access/synced-groups-committer.d.ts +28 -0
  82. package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
  83. package/dist/modules/access/synced-groups-committer.js +139 -0
  84. package/dist/modules/access/synced-groups-committer.js.map +1 -0
  85. package/dist/modules/access/synced-groups-writer.d.ts +78 -0
  86. package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
  87. package/dist/modules/access/synced-groups-writer.js +219 -0
  88. package/dist/modules/access/synced-groups-writer.js.map +1 -0
  89. package/dist/modules/database/core-schema.d.ts +17 -0
  90. package/dist/modules/database/core-schema.d.ts.map +1 -1
  91. package/dist/modules/database/core-schema.js +9 -0
  92. package/dist/modules/database/core-schema.js.map +1 -1
  93. package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
  94. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  95. package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
  96. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  97. package/dist/modules/mcp/mcp.routes.d.ts +9 -3
  98. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  99. package/dist/modules/mcp/mcp.routes.js +126 -2
  100. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  101. package/dist/modules/mcp/mcp.service.d.ts +14 -0
  102. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  103. package/dist/modules/mcp/mcp.service.js +6 -1
  104. package/dist/modules/mcp/mcp.service.js.map +1 -1
  105. package/dist/modules/update-check/update-check.routes.d.ts +13 -0
  106. package/dist/modules/update-check/update-check.routes.d.ts.map +1 -0
  107. package/dist/modules/update-check/update-check.routes.js +21 -0
  108. package/dist/modules/update-check/update-check.routes.js.map +1 -0
  109. package/dist/modules/update-check/update-check.service.d.ts +57 -0
  110. package/dist/modules/update-check/update-check.service.d.ts.map +1 -0
  111. package/dist/modules/update-check/update-check.service.js +109 -0
  112. package/dist/modules/update-check/update-check.service.js.map +1 -0
  113. package/dist/modules/workflow/file-lock.service.d.ts +11 -1
  114. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  115. package/dist/modules/workflow/file-lock.service.js +15 -1
  116. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  117. package/dist/modules/workflow/git/git.service.d.ts +34 -11
  118. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  119. package/dist/modules/workflow/git/git.service.js +173 -37
  120. package/dist/modules/workflow/git/git.service.js.map +1 -1
  121. package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
  122. package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
  123. package/dist/modules/workflow/locking-filesystem.js +181 -28
  124. package/dist/modules/workflow/locking-filesystem.js.map +1 -1
  125. package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
  126. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  127. package/dist/modules/workflow/pending-commits.service.js +19 -1
  128. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  129. package/dist/modules/workflow/workflow.service.d.ts +17 -2
  130. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  131. package/dist/modules/workflow/workflow.service.js +113 -19
  132. package/dist/modules/workflow/workflow.service.js.map +1 -1
  133. package/dist/version.d.ts +14 -0
  134. package/dist/version.d.ts.map +1 -1
  135. package/dist/version.js +25 -0
  136. package/dist/version.js.map +1 -1
  137. package/kb-template/AGENTS.md +4 -1
  138. package/migrations/0004_file_lock_mode.sql +2 -0
  139. package/migrations/meta/0004_snapshot.json +1564 -0
  140. package/migrations/meta/_journal.json +7 -0
  141. package/package.json +4 -4
  142. package/src/__tests__/core-config.domain.test.ts +92 -0
  143. package/src/core/create-core-server.ts +21 -0
  144. package/src/core/create-core-services.ts +82 -0
  145. package/src/core-config.ts +48 -4
  146. package/src/index.ts +10 -0
  147. package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
  148. package/src/modules/access/__tests__/access-groups.test.ts +427 -0
  149. package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
  150. package/src/modules/access/__tests__/access-splice.test.ts +65 -0
  151. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
  152. package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
  153. package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
  154. package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
  155. package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
  156. package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
  157. package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
  158. package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
  159. package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
  160. package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
  161. package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
  162. package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
  163. package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
  164. package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
  165. package/src/modules/access/access-control.interface.ts +66 -32
  166. package/src/modules/access/access-control.service.ts +536 -73
  167. package/src/modules/access/access-declarations.ts +5 -2
  168. package/src/modules/access/access-mutation.service.ts +88 -14
  169. package/src/modules/access/access-splice.ts +55 -17
  170. package/src/modules/access/access.routes.ts +227 -93
  171. package/src/modules/access/admin-locked-commit.ts +331 -0
  172. package/src/modules/access/admin-route-helpers.ts +55 -0
  173. package/src/modules/access/capability-registry.ts +74 -0
  174. package/src/modules/access/directory-sync-bot.ts +76 -0
  175. package/src/modules/access/group-files.ts +212 -0
  176. package/src/modules/access/groups-admin.routes.ts +113 -0
  177. package/src/modules/access/groups-admin.service.ts +551 -0
  178. package/src/modules/access/groups-edit.ts +187 -0
  179. package/src/modules/access/reference-scan.ts +513 -0
  180. package/src/modules/access/roles-admin.service.ts +290 -418
  181. package/src/modules/access/roles-edit.ts +134 -61
  182. package/src/modules/access/synced-groups-committer.ts +177 -0
  183. package/src/modules/access/synced-groups-writer.ts +303 -0
  184. package/src/modules/database/core-schema.ts +9 -0
  185. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
  186. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
  187. package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
  188. package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
  189. package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
  190. package/src/modules/mcp/mcp.routes.ts +137 -2
  191. package/src/modules/mcp/mcp.service.ts +6 -1
  192. package/src/modules/update-check/__tests__/update-check.routes.test.ts +69 -0
  193. package/src/modules/update-check/__tests__/update-check.service.test.ts +170 -0
  194. package/src/modules/update-check/update-check.routes.ts +28 -0
  195. package/src/modules/update-check/update-check.service.ts +130 -0
  196. package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
  197. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
  198. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
  199. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
  200. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
  201. package/src/modules/workflow/file-lock.service.ts +15 -0
  202. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
  203. package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
  204. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
  205. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
  206. package/src/modules/workflow/git/git.service.ts +182 -35
  207. package/src/modules/workflow/locking-filesystem.ts +188 -26
  208. package/src/modules/workflow/pending-commits.service.ts +27 -1
  209. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
  210. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
  211. package/src/modules/workflow/workflow.service.ts +140 -20
  212. package/src/version.ts +28 -0
@@ -29,6 +29,13 @@
29
29
  "when": 1785943322636,
30
30
  "tag": "0003_deployment_settings",
31
31
  "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "7",
36
+ "when": 1787191920033,
37
+ "tag": "0004_file_lock_mode",
38
+ "breakpoints": true
32
39
  }
33
40
  ]
34
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bevel-software/platform-core-backend",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "Open-source core backend of the Bevel platform: git-backed knowledge workspace, workflow (branches/change requests/locks/SSE), skills, tools, secrets vault, access control and the remote MCP surface.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -34,7 +34,7 @@
34
34
  "@utcp/code-mode": "^1.2.12",
35
35
  "@utcp/direct-call": "^1.1.1",
36
36
  "@utcp/http": "^1.1.7",
37
- "@utcp/mcp": "^1.1.3",
37
+ "@utcp/mcp": "^1.1.4",
38
38
  "@utcp/sdk": "^1.1.1",
39
39
  "adm-zip": "^0.6.0",
40
40
  "cors": "^2.8.5",
@@ -46,8 +46,8 @@
46
46
  "pg": "^8.20.0",
47
47
  "yaml": "^2.9.0",
48
48
  "zod": "^3.24.0",
49
- "@bevel-software/platform-mcp-core": "0.9.1",
50
- "@bevel-software/platform-shared": "0.9.1"
49
+ "@bevel-software/platform-mcp-core": "0.11.0",
50
+ "@bevel-software/platform-shared": "0.11.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/adm-zip": "^0.5.8",
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { CoreConfig } from '../core-config.js';
3
+
4
+ /**
5
+ * The public-shape derivation from `DOMAIN`.
6
+ *
7
+ * Setting `DOMAIN` declares "the bundled Caddy `https` profile fronts this
8
+ * deployment": one proxy hop, origins = the domain. The config derives
9
+ * `PUBLIC_BACKEND_URL` / `PUBLIC_FRONTEND_URL` (`https://<DOMAIN>`) and
10
+ * `TRUST_PROXY` (`1`) from it — but only as DEFAULTS: an explicit value for
11
+ * any of the three always wins, so a CDN in front of Caddy or a frontend
12
+ * served elsewhere stays expressible.
13
+ */
14
+ const REQUIRED = {
15
+ KB_REPO_URL: 'https://example.com/org/kb.git',
16
+ ADMIN_EMAIL: 'root@example.com',
17
+ ADMIN_PASSWORD: 'sup3r-secret',
18
+ JWT_SECRET: 'test-jwt-secret',
19
+ SECRETS_ENC_KEY: 'kToAi8FXWDpDn3A6yQ/60O39bv05N7XzVOIu/0CJrFc=',
20
+ };
21
+
22
+ let saved: NodeJS.ProcessEnv;
23
+
24
+ beforeEach(() => {
25
+ saved = { ...process.env };
26
+ // A clean slate for the keys under test (a developer .env loaded by
27
+ // dotenv at import time may carry any of them).
28
+ for (const key of [
29
+ 'DOMAIN',
30
+ 'PUBLIC_BACKEND_URL',
31
+ 'PUBLIC_FRONTEND_URL',
32
+ 'TRUST_PROXY',
33
+ 'NODE_ENV',
34
+ 'PORT',
35
+ ]) {
36
+ delete process.env[key];
37
+ }
38
+ Object.assign(process.env, REQUIRED);
39
+ });
40
+
41
+ afterEach(() => {
42
+ process.env = saved;
43
+ });
44
+
45
+ describe('CoreConfig — DOMAIN derives the public shape', () => {
46
+ it('DOMAIN alone yields https origins and one proxy hop', () => {
47
+ process.env.DOMAIN = 'bevel.example.com';
48
+ const config = new CoreConfig();
49
+ expect(config.publicBackendUrl).toBe('https://bevel.example.com');
50
+ expect(config.publicFrontendUrl).toBe('https://bevel.example.com');
51
+ expect(config.trustProxy).toBe('1');
52
+ });
53
+
54
+ it('explicit PUBLIC_* and TRUST_PROXY always win over DOMAIN', () => {
55
+ // The CDN-in-front-of-Caddy shape: two hops, and a frontend served from
56
+ // somewhere the bundled proxy is not.
57
+ process.env.DOMAIN = 'bevel.example.com';
58
+ process.env.PUBLIC_BACKEND_URL = 'https://api.example.com';
59
+ process.env.PUBLIC_FRONTEND_URL = 'https://app.example.com';
60
+ process.env.TRUST_PROXY = '2';
61
+ const config = new CoreConfig();
62
+ expect(config.publicBackendUrl).toBe('https://api.example.com');
63
+ expect(config.publicFrontendUrl).toBe('https://app.example.com');
64
+ expect(config.trustProxy).toBe('2');
65
+ });
66
+
67
+ it('without DOMAIN the defaults are unchanged (dev shape)', () => {
68
+ const config = new CoreConfig();
69
+ expect(config.publicBackendUrl).toBe('http://localhost:3001');
70
+ expect(config.publicFrontendUrl).toBe('http://localhost:5173');
71
+ // Unset means "directly exposed": forwarded headers stay ignored.
72
+ expect(config.trustProxy).toBe('');
73
+ });
74
+
75
+ it('without DOMAIN in production, the frontend origin is the backend origin', () => {
76
+ // The backend serves the built SPA — under docker compose (which sets
77
+ // NODE_ENV=production and no PUBLIC_* by default) this is what keeps a
78
+ // bare `up -d` bouncing logins back to the container's own origin.
79
+ process.env.NODE_ENV = 'production';
80
+ const config = new CoreConfig();
81
+ expect(config.publicBackendUrl).toBe('http://localhost:3001');
82
+ expect(config.publicFrontendUrl).toBe('http://localhost:3001');
83
+ expect(config.trustProxy).toBe('');
84
+ });
85
+
86
+ it('an explicit production backend origin carries over to the frontend default', () => {
87
+ process.env.NODE_ENV = 'production';
88
+ process.env.PUBLIC_BACKEND_URL = 'https://bevel.example.com';
89
+ const config = new CoreConfig();
90
+ expect(config.publicFrontendUrl).toBe('https://bevel.example.com');
91
+ });
92
+ });
@@ -28,6 +28,8 @@ import {
28
28
  createSecretsVaultPublicRoutes,
29
29
  } from '../modules/secrets-vault/index.js';
30
30
  import { createAdminAccessRoutes } from '../modules/admin/admin-access.routes.js';
31
+ import { createGroupsAdminRoutes } from '../modules/access/groups-admin.routes.js';
32
+ import { createUpdateCheckRoutes } from '../modules/update-check/update-check.routes.js';
31
33
  import { createAccountRoutes } from '../modules/auth/account.routes.js';
32
34
  import { createSetupRoutes } from '../modules/settings/setup.routes.js';
33
35
  import { DEFAULT_BRANCH, PROTECTED_BRANCHES, type AuthUser } from '@bevel-software/platform-shared';
@@ -265,6 +267,11 @@ export async function createCoreServer(
265
267
  core.mcpAuthMiddleware,
266
268
  core.authMiddleware,
267
269
  core.usageMeter,
270
+ // The local-token exchange: verifies an MCP OAuth access token and mints
271
+ // the loopback internal token the local MCP server uses for its REST reads.
272
+ core.internalTokenService,
273
+ core.mcpOAuthProvider,
274
+ core.mcpResourceMetadataUrl,
268
275
  ));
269
276
 
270
277
  // MCP OAuth 2.1 authorization server: /authorize, /token, /register,
@@ -426,6 +433,20 @@ export async function createCoreServer(
426
433
  // Admin-status resolver (CORE — see the note in admin-access.routes.ts;
427
434
  // the full admin router is an enterprise `ext.authed` extension).
428
435
  app.use('/api', core.authMiddleware, createAdminAccessRoutes(core.adminAccess));
436
+ // Groups admin (manual-mode CRUD; typed refusals in IdP mode) —
437
+ // admin-gated inside.
438
+ app.use('/api', core.authMiddleware, createGroupsAdminRoutes({
439
+ groupsAdmin: core.groupsAdminService,
440
+ adminAccess: core.adminAccess,
441
+ getUserById: async (id) => (await core.authService.getUserById(id)) ?? null,
442
+ }));
443
+ // Update check (admin-only inside): the newest published release vs the
444
+ // running version, cached server-side — see update-check.service.ts.
445
+ app.use(
446
+ '/api',
447
+ core.authMiddleware,
448
+ createUpdateCheckRoutes(core.updateCheckService, core.adminAccess),
449
+ );
429
450
  // Account management (list/create password accounts, GDPR erasure) —
430
451
  // admin-gated inside.
431
452
  app.use('/api', core.authMiddleware, createAccountRoutes(
@@ -35,6 +35,7 @@ import { OidcAuthProvider } from '../modules/auth/oidc-auth-provider.js';
35
35
  import { createAuthMiddleware } from '../modules/auth/auth.middleware.js';
36
36
  import { AccessControlService } from '../modules/access/access-control.service.js';
37
37
  import { CreatorAccessService } from '../modules/access/creator-access.js';
38
+ import { GroupsAdminService } from '../modules/access/groups-admin.service.js';
38
39
  import { PendingSkillsService, SkillService } from '../modules/skills/index.js';
39
40
  import { ToolManualService } from '../modules/tool-manuals/index.js';
40
41
  import { McpServerEditService } from '../modules/tool-manuals/mcp-server-edit.service.js';
@@ -63,6 +64,12 @@ import {
63
64
  } from '../modules/workflow/pending-commits.worker.js';
64
65
  import { ensureRecoveryBotUser } from '../modules/workflow/recovery-bot.js';
65
66
  import { AdminAccessService } from '../modules/admin/admin-access.service.js';
67
+ import {
68
+ SyncedGroupsWriter,
69
+ type SyncedGroupsSource,
70
+ } from '../modules/access/synced-groups-writer.js';
71
+ import { createSyncedGroupsCommitter } from '../modules/access/synced-groups-committer.js';
72
+ import { ensureDirectorySyncBot } from '../modules/access/directory-sync-bot.js';
66
73
  import { ExternalApiKeyService } from '../modules/tool-auth/external-api-key.service.js';
67
74
  import {
68
75
  InternalTokenService,
@@ -82,6 +89,8 @@ import { ToolRegistry } from '../modules/tool-registry/tool-registry.js';
82
89
  import { createToolContextResolver } from '../modules/tool-helpers/tool-context.js';
83
90
  import { createToolHandlerFactory } from '../modules/tool-helpers/tool-handler.js';
84
91
  import { TokenCrypto } from '../shared/token-crypto.js';
92
+ import { UpdateCheckService } from '../modules/update-check/update-check.service.js';
93
+ import { resolveAppVersion } from '../version.js';
85
94
  import { noopRecoveryAgent, type CorePorts } from './core-ports.js';
86
95
 
87
96
  /**
@@ -131,6 +140,22 @@ export interface CoreServices {
131
140
  pendingCommitsWorker: PendingCommitsWorker;
132
141
  recoveryBot: AuthUser;
133
142
  adminAccess: AdminAccessService;
143
+ /** Manual-mode groups CRUD + the manual→IdP retirement half. */
144
+ groupsAdminService: GroupsAdminService;
145
+ /**
146
+ * Build the debounced directory → `synced-groups.yaml` materializer for a
147
+ * directory source an OVERLAY provides (e.g. a SCIM mirror fed by the IdP's
148
+ * provisioning engine — core ships no directory integration of its own).
149
+ * Ensures the directory-sync bot user and closes over the git commit
150
+ * pipeline; the overlay only implements {@link SyncedGroupsSource} and
151
+ * calls `notifyMutation()` after each directory change.
152
+ */
153
+ createSyncedGroupsMaterializer: (
154
+ source: SyncedGroupsSource,
155
+ opts?: { debounceMs?: number; log?: (message: string) => void },
156
+ ) => Promise<SyncedGroupsWriter>;
157
+ /** Newest-release lookup behind `GET /api/update-check` (admin-only). */
158
+ updateCheckService: UpdateCheckService;
134
159
  /** Deployment settings, env-first — the KB remote resolves through these. */
135
160
  settings: DeploymentSettingsService;
136
161
  /**
@@ -147,6 +172,12 @@ export interface CoreServices {
147
172
  mcpService: McpService;
148
173
  mcpAuthMiddleware: ReturnType<typeof createMcpAuthMiddleware>;
149
174
  mcpOAuthProvider: BevelOAuthProvider;
175
+ /**
176
+ * RFC 9728 protected-resource-metadata URL carried on MCP 401 challenges.
177
+ * Published so the route layer's own challenges (the local-token exchange)
178
+ * advertise the same pointer the auth middleware does.
179
+ */
180
+ mcpResourceMetadataUrl: string;
150
181
  toolRegistry: ToolRegistry;
151
182
  toolAuthMiddleware: ReturnType<typeof createToolAuthMiddleware>;
152
183
  manualAuthMiddleware: ReturnType<typeof createManualAuthMiddleware>;
@@ -499,6 +530,15 @@ export async function createCoreServices(
499
530
  [config.adminEmail],
500
531
  );
501
532
 
533
+ // In-app update check: lazily compares the running release version against
534
+ // the newest published GitHub release, only when an admin's browser asks —
535
+ // no timers, so a deployment nobody looks at makes zero calls. The flag
536
+ // removes even that (air-gapped deployments; see CoreConfig).
537
+ const updateCheckService = new UpdateCheckService({
538
+ enabled: config.updateCheckEnabled,
539
+ currentVersion: resolveAppVersion(),
540
+ });
541
+
502
542
  // Secrets Vault: the per-user store of credentials (static API keys + OAuth
503
543
  // tokens) that back UTCP tool variables (`${FOO_API_KEY}`). Encrypted at rest
504
544
  // with the connector-config key; read only by the `bevel-secrets` variable
@@ -614,6 +654,7 @@ export async function createCoreServices(
614
654
  externalApiKeyService,
615
655
  mcpOAuthProvider,
616
656
  mcpResourceMetadataUrl,
657
+ internalTokenService,
617
658
  );
618
659
 
619
660
  // ── Unified tool surface ──────────────────────────────────────────────
@@ -712,6 +753,43 @@ export async function createCoreServices(
712
753
  );
713
754
  }
714
755
 
756
+ // Materializer factory for an overlay-provided directory source: every
757
+ // provisioning burst regenerates `synced-groups.yaml` on the default branch
758
+ // (debounced), committed by the sync bot — that file is what puts access
759
+ // resolution in IdP mode. Core itself ships no directory integration, so
760
+ // nothing is constructed here until an overlay brings a source.
761
+ const createSyncedGroupsMaterializer = async (
762
+ source: SyncedGroupsSource,
763
+ opts?: { debounceMs?: number; log?: (message: string) => void },
764
+ ): Promise<SyncedGroupsWriter> => {
765
+ const bot = await ensureDirectorySyncBot(db);
766
+ return new SyncedGroupsWriter({
767
+ source,
768
+ ...createSyncedGroupsCommitter({
769
+ workspaceService,
770
+ workflowService,
771
+ accessControl,
772
+ eventBus,
773
+ kbDirName,
774
+ bot,
775
+ defaultBranchOf: () => DEFAULT_BRANCH,
776
+ }),
777
+ debounceMs: opts?.debounceMs,
778
+ log: opts?.log ?? ((message) => console.warn(message)),
779
+ });
780
+ };
781
+ // Groups — the "who you are" principal sets. Manual-mode CRUD on
782
+ // groups.yaml, IdP-mode refusals, and the connect-time retirement the
783
+ // directory token route drives.
784
+ const groupsAdminService = new GroupsAdminService(
785
+ workspaceService,
786
+ workflowService,
787
+ accessControl,
788
+ kbDirName,
789
+ () => DEFAULT_BRANCH,
790
+ eventBus,
791
+ );
792
+
715
793
  return {
716
794
  config,
717
795
  db,
@@ -746,12 +824,16 @@ export async function createCoreServices(
746
824
  pendingCommitsWorker,
747
825
  recoveryBot,
748
826
  adminAccess,
827
+ groupsAdminService,
828
+ createSyncedGroupsMaterializer,
829
+ updateCheckService,
749
830
  secretsVaultService,
750
831
  externalApiKeyService,
751
832
  internalTokenService,
752
833
  mcpService,
753
834
  mcpAuthMiddleware,
754
835
  mcpOAuthProvider,
836
+ mcpResourceMetadataUrl,
755
837
  toolRegistry,
756
838
  toolAuthMiddleware,
757
839
  manualAuthMiddleware,
@@ -144,6 +144,16 @@ export class CoreConfig {
144
144
  * `session-ontology.gate.ts`), so it must exist wherever the tracking runs.
145
145
  */
146
146
  readonly ontologySessionBlock: boolean;
147
+ /**
148
+ * In-app update check. When true (default), `GET /api/update-check` lazily
149
+ * asks api.github.com for the newest Hexis release — only when an admin's
150
+ * browser asks, cached for hours, never on a timer — so admins see a quiet
151
+ * banner when this deployment is behind. The request carries nothing but
152
+ * the request itself: no token, no identifier, no telemetry. Set
153
+ * `UPDATE_CHECK=false` for air-gapped deployments or anyone who objects to
154
+ * the phone-home; disabled, the server never makes the call.
155
+ */
156
+ readonly updateCheckEnabled: boolean;
147
157
  /**
148
158
  * Password-login toggle for the login screen. Default enabled; set
149
159
  * `LOGIN_PASSWORD=false` to hide the method AND reject `/auth/login`
@@ -203,14 +213,22 @@ export class CoreConfig {
203
213
  * but behind a proxy it makes every client share the proxy's IP (so the
204
214
  * per-IP login rate limit would pool all users). Set it to the actual hop
205
215
  * count — never a blanket trust — so clients can't spoof X-Forwarded-For.
216
+ * With `DOMAIN` set (the bundled Caddy fronts the deployment) it defaults
217
+ * to `1` instead — see the derivation in the constructor.
206
218
  */
207
219
  readonly trustProxy: string;
208
220
  /**
209
221
  * Public base URL of THIS backend, used to build OAuth redirect URIs.
210
222
  * Must match a redirect URI registered with the OAuth provider(s).
223
+ * Defaults to `https://<DOMAIN>` when `DOMAIN` is set.
211
224
  */
212
225
  readonly publicBackendUrl: string;
213
- /** Public base URL of the frontend, where callbacks redirect post-login. */
226
+ /**
227
+ * Public base URL of the frontend, where callbacks redirect post-login.
228
+ * Defaults to `https://<DOMAIN>` when `DOMAIN` is set, else to the backend
229
+ * origin in production (the backend serves the SPA) and Vite's `:5173` in
230
+ * development.
231
+ */
214
232
  readonly publicFrontendUrl: string;
215
233
 
216
234
  constructor() {
@@ -304,6 +322,8 @@ export class CoreConfig {
304
322
  this.kbTemplateDir = process.env.KB_TEMPLATE_DIR || defaultKbTemplateDir();
305
323
  this.ontologySessionBlock =
306
324
  (process.env.ONTOLOGY_SESSION_BLOCK ?? 'true').trim().toLowerCase() !== 'false';
325
+ this.updateCheckEnabled =
326
+ (process.env.UPDATE_CHECK ?? 'true').trim().toLowerCase() !== 'false';
307
327
  this.allowedEmailDomains = (process.env.ALLOWED_EMAIL_DOMAINS || '')
308
328
  .split(/[\s,]+/)
309
329
  .map((d) => d.trim().toLowerCase().replace(/^[@.]+/, ''))
@@ -322,11 +342,35 @@ export class CoreConfig {
322
342
  );
323
343
  }
324
344
  this.internalTokenSecret = (process.env.INTERNAL_TOKEN_SECRET || '').trim();
325
- this.trustProxy = (process.env.TRUST_PROXY || '').trim();
326
- this.publicBackendUrl = (process.env.PUBLIC_BACKEND_URL || `http://localhost:${this.port}`)
345
+ // Setting DOMAIN declares "the bundled Caddy `https` profile fronts this
346
+ // deployment" — one proxy hop, and the public origin IS that domain. The
347
+ // three values below therefore default from it, so `DOMAIN=x.example.com`
348
+ // in `.env` is the whole configuration for that shape: TRUST_PROXY falls
349
+ // to 1 (Caddy is the hop) and both public URLs to `https://<DOMAIN>` (the
350
+ // backend serves the SPA, so they share an origin). Explicit
351
+ // PUBLIC_BACKEND_URL / PUBLIC_FRONTEND_URL / TRUST_PROXY always win — a
352
+ // CDN in front of Caddy (TRUST_PROXY=2) or a frontend served elsewhere
353
+ // stays expressible.
354
+ const domain = (process.env.DOMAIN || '').trim();
355
+ this.trustProxy = (process.env.TRUST_PROXY || (domain ? '1' : '')).trim();
356
+ this.publicBackendUrl = (
357
+ process.env.PUBLIC_BACKEND_URL ||
358
+ (domain ? `https://${domain}` : `http://localhost:${this.port}`)
359
+ )
327
360
  .trim()
328
361
  .replace(/\/+$/, '');
329
- this.publicFrontendUrl = (process.env.PUBLIC_FRONTEND_URL || 'http://localhost:5173')
362
+ // Unset, the frontend origin is the backend's own in production (the
363
+ // backend serves the built SPA — under docker compose this is what makes
364
+ // a bare `up -d` bounce logins back to the right place), and Vite's dev
365
+ // server in development.
366
+ this.publicFrontendUrl = (
367
+ process.env.PUBLIC_FRONTEND_URL ||
368
+ (domain
369
+ ? `https://${domain}`
370
+ : this.nodeEnv === 'production'
371
+ ? this.publicBackendUrl
372
+ : 'http://localhost:5173')
373
+ )
330
374
  .trim()
331
375
  .replace(/\/+$/, '');
332
376
  // Parse-validate so a malformed URL fails at boot rather than producing a
package/src/index.ts CHANGED
@@ -52,6 +52,16 @@ export type {
52
52
  } from './modules/tool-auth/external-api-key.interface.js';
53
53
 
54
54
  // Key port/seam types an overlay implements.
55
+ // SyncedGroupsWriter is a CLASS — exported by VALUE so an overlay can
56
+ // construct one; the seam types (including the writer's deps) stay type-only.
57
+ export { SyncedGroupsWriter } from './modules/access/synced-groups-writer.js';
58
+ export type {
59
+ SyncedGroupsSource,
60
+ SyncedGroupRecord,
61
+ SyncedGroupMember,
62
+ SyncedGroupsWriteResult,
63
+ SyncedGroupsWriterDeps,
64
+ } from './modules/access/synced-groups-writer.js';
55
65
  export type { ISessionSink } from './modules/workspace/session-sink.js';
56
66
  export type {
57
67
  ISystemNoticeSink,
@@ -283,6 +283,12 @@ describe('AccessControlService', () => {
283
283
  const e = await svc.eligibleWriters(workspaceId, 'Knowledge/Foo.md');
284
284
  expect(e.roles).toEqual(['Admin']);
285
285
  expect(e.users.map((u) => u.email)).toEqual(['felix@example.com']);
286
+ // The kinded twin of `roles`: a roles.yaml principal reads kind 'role'.
287
+ expect(e.principals).toEqual([{ name: 'Admin', kind: 'role' }]);
288
+ // The Admin-override insertion (write on an access.md is admin-rescued)
289
+ // also carries kind 'role' — it is the role's capability, never a group's.
290
+ const onAccessMd = await svc.eligibleWriters(workspaceId, 'access.md');
291
+ expect(onAccessMd.principals).toContainEqual({ name: 'Admin', kind: 'role' });
286
292
  });
287
293
 
288
294
  it('throws AccessConfigError when roles.yaml is missing', async () => {
@@ -947,7 +953,7 @@ describe('AccessControlService', () => {
947
953
 
948
954
  const svc = new AccessControlService(stubWorkspaceService(workspaceId, workspaceDir), PROCESS_MAP_DIR);
949
955
  const e = await svc.eligibleReaders(workspaceId, 'Knowledge/Foo.md');
950
- expect(e).toEqual({ restricted: true, roles: [], users: [] });
956
+ expect(e).toEqual({ restricted: true, principals: [], roles: [], users: [] });
951
957
  });
952
958
 
953
959
  it('reports restricted=false when read: everyone applies', async () => {
@@ -957,7 +963,7 @@ describe('AccessControlService', () => {
957
963
 
958
964
  const svc = new AccessControlService(stubWorkspaceService(workspaceId, workspaceDir), PROCESS_MAP_DIR);
959
965
  const e = await svc.eligibleReaders(workspaceId, 'Knowledge/Foo.md');
960
- expect(e).toEqual({ restricted: false, roles: [], users: [] });
966
+ expect(e).toEqual({ restricted: false, principals: [], roles: [], users: [] });
961
967
  });
962
968
 
963
969
  it('restricted=false when a closer everyone grant shadows a farther by-name deny', async () => {
@@ -972,7 +978,7 @@ describe('AccessControlService', () => {
972
978
  // node really is readable by everyone — not restricted.
973
979
  expect(await svc.canRead(workspaceId, 'felix@example.com', 'Knowledge/Open/Foo.md')).toBe(true);
974
980
  const e = await svc.eligibleReaders(workspaceId, 'Knowledge/Open/Foo.md');
975
- expect(e).toEqual({ restricted: false, roles: [], users: [] });
981
+ expect(e).toEqual({ restricted: false, principals: [], roles: [], users: [] });
976
982
  });
977
983
 
978
984
  it('restricted=true when a same-scope by-name deny carves someone out of read: everyone', async () => {