@bevel-software/platform-core-backend 0.1.1 → 0.2.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 (86) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +13 -0
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts.map +1 -1
  5. package/dist/core/create-core-services.js +23 -2
  6. package/dist/core/create-core-services.js.map +1 -1
  7. package/dist/core-config.d.ts +36 -1
  8. package/dist/core-config.d.ts.map +1 -1
  9. package/dist/core-config.js +56 -2
  10. package/dist/core-config.js.map +1 -1
  11. package/dist/modules/admin/admin-access.service.d.ts +17 -1
  12. package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
  13. package/dist/modules/admin/admin-access.service.js +14 -1
  14. package/dist/modules/admin/admin-access.service.js.map +1 -1
  15. package/dist/modules/auth/account.routes.d.ts +17 -0
  16. package/dist/modules/auth/account.routes.d.ts.map +1 -0
  17. package/dist/modules/auth/account.routes.js +47 -0
  18. package/dist/modules/auth/account.routes.js.map +1 -0
  19. package/dist/modules/auth/auth.routes.d.ts +13 -7
  20. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  21. package/dist/modules/auth/auth.routes.js +51 -3
  22. package/dist/modules/auth/auth.routes.js.map +1 -1
  23. package/dist/modules/auth/auth.service.d.ts +44 -8
  24. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  25. package/dist/modules/auth/auth.service.js +129 -32
  26. package/dist/modules/auth/auth.service.js.map +1 -1
  27. package/dist/modules/auth/oidc-auth-provider.d.ts +51 -0
  28. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -0
  29. package/dist/modules/auth/oidc-auth-provider.js +189 -0
  30. package/dist/modules/auth/oidc-auth-provider.js.map +1 -0
  31. package/dist/modules/auth/password-hash.d.ts +12 -0
  32. package/dist/modules/auth/password-hash.d.ts.map +1 -0
  33. package/dist/modules/auth/password-hash.js +67 -0
  34. package/dist/modules/auth/password-hash.js.map +1 -0
  35. package/dist/modules/auth/rate-limit.d.ts +32 -0
  36. package/dist/modules/auth/rate-limit.d.ts.map +1 -0
  37. package/dist/modules/auth/rate-limit.js +49 -0
  38. package/dist/modules/auth/rate-limit.js.map +1 -0
  39. package/dist/modules/database/core-schema.d.ts +17 -0
  40. package/dist/modules/database/core-schema.d.ts.map +1 -1
  41. package/dist/modules/database/core-schema.js +6 -0
  42. package/dist/modules/database/core-schema.js.map +1 -1
  43. package/dist/modules/tool-auth/internal-token.service.d.ts +10 -0
  44. package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -1
  45. package/dist/modules/tool-auth/internal-token.service.js +1 -0
  46. package/dist/modules/tool-auth/internal-token.service.js.map +1 -1
  47. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +8 -0
  48. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  49. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -1
  50. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  51. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  52. package/dist/modules/tool-helpers/tool-context.js +6 -0
  53. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  54. package/dist/modules/tool-helpers/tool.contract.d.ts +9 -0
  55. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  56. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  57. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  58. package/dist/modules/workspace/workspace.tools.js +46 -17
  59. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  60. package/migrations/0001_user_password_hash.sql +4 -0
  61. package/migrations/meta/_journal.json +20 -13
  62. package/package.json +2 -2
  63. package/src/core/create-core-server.ts +352 -338
  64. package/src/core/create-core-services.ts +542 -514
  65. package/src/core-config.ts +55 -2
  66. package/src/modules/admin/admin-access.service.ts +10 -0
  67. package/src/modules/auth/__tests__/account.routes.test.ts +98 -0
  68. package/src/modules/auth/__tests__/auth.routes.test.ts +91 -0
  69. package/src/modules/auth/__tests__/auth.service.test.ts +217 -0
  70. package/src/modules/auth/__tests__/oidc-auth-provider.test.ts +174 -0
  71. package/src/modules/auth/__tests__/password-hash.test.ts +53 -0
  72. package/src/modules/auth/__tests__/rate-limit.test.ts +36 -0
  73. package/src/modules/auth/account.routes.ts +59 -0
  74. package/src/modules/auth/auth.routes.ts +68 -10
  75. package/src/modules/auth/auth.service.ts +151 -32
  76. package/src/modules/auth/oidc-auth-provider.ts +229 -0
  77. package/src/modules/auth/password-hash.ts +80 -0
  78. package/src/modules/auth/rate-limit.ts +45 -0
  79. package/src/modules/database/core-schema.ts +437 -431
  80. package/src/modules/tool-auth/__tests__/internal-token.service.test.ts +16 -0
  81. package/src/modules/tool-auth/internal-token.service.ts +11 -0
  82. package/src/modules/tool-auth/tool-auth.middleware.ts +9 -1
  83. package/src/modules/tool-helpers/tool-context.ts +6 -0
  84. package/src/modules/tool-helpers/tool.contract.ts +9 -0
  85. package/src/modules/workspace/__tests__/workspace.tools.test.ts +79 -22
  86. package/src/modules/workspace/workspace.tools.ts +49 -17
@@ -1,514 +1,542 @@
1
- import type { AuthProviderPlugin } from '../modules/auth/auth.routes.js';
2
- import type { AuthUser } from '@bevel-software/platform-shared';
3
- import { DEFAULT_BRANCH, PROTECTED_BRANCHES, SKILLS_DIR, TOOLS_DIR } from '@bevel-software/platform-shared';
4
- import { CoreConfig } from '../core-config.js';
5
- import { getDb, type Database } from '../modules/database/connection.js';
6
- import { runCoreMigrations } from '../modules/database/migrate.js';
7
- import { coreMigrationsDir } from '../assets.js';
8
- import { WorkspaceService } from '../modules/workspace/workspace.service.js';
9
- import { RoutineWritePolicyService } from '../modules/workspace/routine-write-policy.js';
10
- import { KbSeedService } from '../modules/workspace/kb-seed.service.js';
11
- import { SpillStore } from '../modules/workspace/spill-store.js';
12
- import { UuidSessionSink, type ISessionSink } from '../modules/workspace/session-sink.js';
13
- import { AuthService } from '../modules/auth/auth.service.js';
14
- import { AccountErasureService } from '../modules/auth/account-erasure.service.js';
15
- import { createAuthMiddleware } from '../modules/auth/auth.middleware.js';
16
- import { AccessControlService } from '../modules/access/access-control.service.js';
17
- import { CreatorAccessService } from '../modules/access/creator-access.js';
18
- import { SkillService } from '../modules/skills/index.js';
19
- import { ToolManualService } from '../modules/tool-manuals/index.js';
20
- import {
21
- DbSecretsVaultService,
22
- McpOAuthDiscoveryService,
23
- registerBevelSecretsVariableLoader,
24
- } from '../modules/secrets-vault/index.js';
25
- import { GitService } from '../modules/workflow/git/git.service.js';
26
- import { PullRequestService } from '../modules/workflow/git/pull-request.service.js';
27
- import { WorkspaceMutex } from '../modules/workflow/git/mutex.js';
28
- import { assertGitVersion } from '../modules/workflow/git/git-version.js';
29
- import { DiffService } from '../modules/diff/diff.service.js';
30
- import { ReviewWorkflowService } from '../modules/workflow/review-workflow/review-workflow.service.js';
31
- import { FileLockService } from '../modules/workflow/file-lock.service.js';
32
- import { WorkflowEventBus } from '../modules/workflow/event-bus.js';
33
- import { FileChangeNotifier } from '../modules/workflow/file-change-notifier.js';
34
- import { WorkflowService } from '../modules/workflow/workflow.service.js';
35
- import { WorkflowHooks } from '../modules/workflow/workflow-hooks.js';
36
- import { SessionOntologyService } from '../modules/workflow/session-ontology.service.js';
37
- import { PendingCommitsService } from '../modules/workflow/pending-commits.service.js';
38
- import {
39
- PendingCommitsWorker,
40
- consoleSystemNoticeSink,
41
- } from '../modules/workflow/pending-commits.worker.js';
42
- import { ensureRecoveryBotUser } from '../modules/workflow/recovery-bot.js';
43
- import { AdminAccessService } from '../modules/admin/admin-access.service.js';
44
- import { ExternalApiKeyService } from '../modules/tool-auth/external-api-key.service.js';
45
- import {
46
- InternalTokenService,
47
- deriveInternalTokenSecret,
48
- } from '../modules/tool-auth/internal-token.service.js';
49
- import {
50
- createToolAuthMiddleware,
51
- createManualAuthMiddleware,
52
- } from '../modules/tool-auth/tool-auth.middleware.js';
53
- import { unmeteredLlmUsage, type ILlmUsageMeter } from '../modules/tool-auth/llm-usage-meter.js';
54
- import { McpSessionStore } from '../modules/mcp/mcp-session-store.js';
55
- import { McpService } from '../modules/mcp/mcp.service.js';
56
- import { createMcpAuthMiddleware } from '../modules/mcp/mcp-auth.middleware.js';
57
- import { BevelOAuthProvider } from '../modules/mcp/oauth/bevel-oauth-provider.js';
58
- import { getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js';
59
- import { ToolRegistry } from '../modules/tool-registry/tool-registry.js';
60
- import { createToolContextResolver } from '../modules/tool-helpers/tool-context.js';
61
- import { createToolHandlerFactory } from '../modules/tool-helpers/tool-handler.js';
62
- import { TokenCrypto } from '../shared/token-crypto.js';
63
- import { noopRecoveryAgent, type CorePorts } from './core-ports.js';
64
-
65
- /**
66
- * Everything the CORE composition builds: the core services `createCoreServer`
67
- * mounts, plus the primitives the enterprise overlay's own construction needs
68
- * (db, event bus, notifier, registries, middlewares, …).
69
- *
70
- * The fields under "server-time seams" are deliberately MUTABLE: they are only
71
- * read when the server is built / at request time, and the enterprise overlay
72
- * overwrites them after `createCoreServices` returns (its implementations need
73
- * core services to construct). See {@link CorePorts} for the pattern.
74
- */
75
- export interface CoreServices {
76
- config: CoreConfig;
77
- db: Database;
78
- workspaceService: WorkspaceService;
79
- kbSeedService: KbSeedService;
80
- spillStore: SpillStore;
81
- accessControl: AccessControlService;
82
- creatorAccess: CreatorAccessService;
83
- sessionOntologyService: SessionOntologyService;
84
- routineWritePolicy: RoutineWritePolicyService;
85
- skillService: SkillService;
86
- toolManualService: ToolManualService;
87
- authService: AuthService;
88
- authMiddleware: ReturnType<typeof createAuthMiddleware>;
89
- accountErasureService: AccountErasureService;
90
- gitService: GitService;
91
- pullRequestService: PullRequestService;
92
- diffService: DiffService;
93
- reviewWorkflowService: ReviewWorkflowService;
94
- fileLockService: FileLockService;
95
- workflowService: WorkflowService;
96
- eventBus: WorkflowEventBus;
97
- fileChangeNotifier: FileChangeNotifier;
98
- pendingCommitsService: PendingCommitsService;
99
- pendingCommitsWorker: PendingCommitsWorker;
100
- recoveryBot: AuthUser;
101
- adminAccess: AdminAccessService;
102
- secretsVaultService: DbSecretsVaultService;
103
- externalApiKeyService: ExternalApiKeyService;
104
- internalTokenService: InternalTokenService;
105
- mcpService: McpService;
106
- mcpAuthMiddleware: ReturnType<typeof createMcpAuthMiddleware>;
107
- mcpOAuthProvider: BevelOAuthProvider;
108
- toolRegistry: ToolRegistry;
109
- toolAuthMiddleware: ReturnType<typeof createToolAuthMiddleware>;
110
- manualAuthMiddleware: ReturnType<typeof createManualAuthMiddleware>;
111
- toolHandlerFactory: ReturnType<typeof createToolHandlerFactory>;
112
- // ── Server-time seams (enterprise overwrites after construction) ────────
113
- /** `start_session` backing — core default {@link UuidSessionSink}. */
114
- sessionSink: ISessionSink;
115
- /** Connection-key LLM metering view — core default unmetered. */
116
- usageMeter: ILlmUsageMeter;
117
- /** SSO plugins the auth router mounts — same array instance as the port. */
118
- authProviders: AuthProviderPlugin[];
119
- }
120
-
121
- export async function createCoreServices(
122
- config: CoreConfig,
123
- ports: CorePorts = {},
124
- ): Promise<CoreServices> {
125
- // Fail fast on a runtime whose git is too old for `--no-write-fetch-head`
126
- // (see `git-version.ts`): every clone, fetch and refresh below depends on it,
127
- // so an unsupported binary is better surfaced here than at the first merge.
128
- await assertGitVersion();
129
- const db = getDb(config.databaseUrl);
130
- // Migrations must run BEFORE any service that reads or writes a managed
131
- // table. `PendingCommitsService.startupReconcile` (called later in this
132
- // function) hits `pending_commits` — on a fresh DB, deferring migrations to
133
- // after createServices would 42P01 the boot.
134
- //
135
- // DIVERGENCE vs bevel-platform's copy of this file (Phase C reconciliation):
136
- // upstream still calls the legacy single-folder `runMigrations(db)`; this
137
- // package runs its own squashed idempotent CORE history from the packaged
138
- // `migrations/` folder, tracked in `__drizzle_migrations_core`. An
139
- // enterprise overlay runs its own history AFTER this (see migrate.ts).
140
- await runCoreMigrations(db, coreMigrationsDir());
141
- const workspaceService = new WorkspaceService(
142
- config.workspacesRoot,
143
- config.kbRepoUrl,
144
- config.kbDirName,
145
- config.gitUsername,
146
- );
147
- // Seed (or top-up) the KB remote before the first clone so the rest of the app
148
- // can keep assuming the remote already carries the protected branches + base
149
- // scaffolding — a fresh or partially-populated remote no longer needs to have
150
- // been forked from the standalone template.
151
- const kbSeedService = new KbSeedService(
152
- config.kbRepoUrl,
153
- config.kbTemplateDir,
154
- [...PROTECTED_BRANCHES],
155
- DEFAULT_BRANCH,
156
- config.seedAdminEmails,
157
- config.gitUsername,
158
- );
159
- workspaceService.setSeedService(kbSeedService);
160
- // Shared, workspace-independent store for oversized `call_tool_chain` results,
161
- // read back via `read_file`. Sibling of `workspacesRoot`, never committed.
162
- const spillStore = new SpillStore(config.spillRoot);
163
- const accessControl = new AccessControlService(workspaceService, config.kbDirName);
164
- // Creator read-grant on creation: read is default-deny, so every surface
165
- // that creates KB files/folders (human routes, agent tools, upload apply)
166
- // consults this planner to keep creations visible to their creator.
167
- const creatorAccess = new CreatorAccessService(workspaceService, accessControl, config.kbDirName);
168
-
169
- // Ontology-session boundary: records each agent run's touched ontologies and
170
- // blocks writes once a run has crossed ontologies. Postgres-backed so the
171
- // boundary survives a restart.
172
- const sessionOntologyService = new SessionOntologyService(db);
173
- // Per-run write restriction (by file extension). Shared by the workspace tool
174
- // surface (which enforces it) and the routine runner (which sets it for
175
- // dashboard-only `watchlist_check` runs). In-memory: a restriction lives only for
176
- // one run, unlike the Postgres-backed ontology touched-set above.
177
- const routineWritePolicy = new RoutineWritePolicyService();
178
- // Skills: discovered from the default-branch workspace only (global catalog).
179
- const skillService = new SkillService(workspaceService, accessControl, config.kbDirName);
180
- // Tool manuals: user-authored `*.tool` files under `Tools/` in the default
181
- // branch — access-controlled like Skills, served to external agents via
182
- // `GET /api/agent/all-tools` and registered on the MCP proxy's UTCP client.
183
- const toolManualService = new ToolManualService(workspaceService, accessControl, config.kbDirName);
184
- // Auth service — resolves identities for login, PR author attribution, and
185
- // access lookups. (Change requests now store the author email directly, so
186
- // PullRequestService no longer depends on it for attribution.)
187
- const authService = new AuthService(db, config);
188
- const authMiddleware = createAuthMiddleware(authService);
189
-
190
- // Shared mutex so git and diff operations on the same workspace serialize
191
- // against each other — a backup-reseed races a concurrent commit otherwise.
192
- const workspaceMutex = new WorkspaceMutex();
193
- // Workflow lifecycle hooks — the ONE registry this composition shares
194
- // between GitService (advisory commit validation), the session-ontology
195
- // gate (blocking preWrite), and the enterprise composition root, which
196
- // registers module-owned handlers on `workflowService.hooks` right after
197
- // this function returns. Core registers none: no commit-time validation
198
- // (advisory anyway) and no ontology write block.
199
- const workflowHooks = new WorkflowHooks();
200
- const gitService = new GitService(
201
- workspaceService,
202
- workflowHooks,
203
- config.kbDirName,
204
- workspaceMutex,
205
- accessControl,
206
- );
207
- // A fresh clone has already fetched every ref — let the git layer skip the
208
- // redundant implicit `git fetch` on the first `listBranches` after bootstrap.
209
- workspaceService.setWorkspaceClonedListener((id) => gitService.noteWorkspaceFetched(id));
210
- const pullRequestService = new PullRequestService(
211
- db,
212
- workspaceService,
213
- accessControl,
214
- gitService,
215
- );
216
- const diffService = new DiffService(
217
- workspaceService,
218
- workspaceMutex,
219
- config.workspacesRoot,
220
- config.backupsRoot,
221
- config.kbDirName,
222
- );
223
- // Late-bind the diff service into WorkspaceService so file writes/moves
224
- // trigger backup updates. (GitService used to take a diffService too — for
225
- // the post-switch backup reseed — but switchBranch is gone with the
226
- // per-branch workspace model, so the injection no longer applies.)
227
- workspaceService.setDiffService(diffService);
228
- const reviewWorkflowService = new ReviewWorkflowService(
229
- db,
230
- accessControl,
231
- workspaceService,
232
- gitService,
233
- );
234
- // Late-bind the comment enricher so PullRequestService.getPrDetail includes
235
- // our DB-backed comments without forcing a circular constructor dependency.
236
- pullRequestService.setDetailEnricher(reviewWorkflowService);
237
-
238
- // Workflow facade — the abstraction every app consumer should reach for
239
- // instead of the individual git / PR / review-workflow services. See
240
- // PLAN.md for the migration story. Today it delegates straight through;
241
- // implementations migrate into the workflow module over later steps.
242
- const fileLockService = new FileLockService(db);
243
- // Background commit queue. The `WorkflowService.releaseLock` path
244
- // enqueues a row here instead of running commitFile + push inline; the
245
- // worker drains the queue out of band so a git hiccup can't block the
246
- // user-visible save path. See `lock-decoupling-plan.md`.
247
- const pendingCommitsService = new PendingCommitsService(db);
248
- // Let the git status path tell an expected dirty tree (queued saves still
249
- // draining) from a genuinely orphaned one before it warns loudly.
250
- gitService.setPendingCommitsProbe((workspaceId) =>
251
- pendingCommitsService.hasAnyForWorkspace(workspaceId),
252
- );
253
- // In-process event bus: every WorkflowService mutation method that
254
- // succeeds emits a typed event; the SSE route fans it to connected
255
- // browser sessions. Single instance per Node process — survives across
256
- // requests, doesn't survive restarts (clients reconnect + resync).
257
- const eventBus = new WorkflowEventBus();
258
- // In-process post-commit hook (distinct from the SSE bus): fires once per
259
- // committed change (batched paths) so catalogs refresh instantly and the
260
- // id-repair keeps the id namespace collision-free. Subscribers are registered
261
- // below, once the reacting services exist.
262
- const fileChangeNotifier = new FileChangeNotifier();
263
- const workflowService = new WorkflowService(
264
- db,
265
- gitService,
266
- pullRequestService,
267
- reviewWorkflowService,
268
- workspaceService,
269
- accessControl,
270
- fileLockService,
271
- pendingCommitsService,
272
- config.kbDirName,
273
- eventBus,
274
- fileChangeNotifier,
275
- // Exposed as `workflowService.hooks` — the SAME instance GitService and
276
- // the session-ontology gate consult, so enterprise registrations against
277
- // it reach every hook point.
278
- workflowHooks,
279
- );
280
-
281
- // Subscriber A — catalog freshness: a committed change drops the affected
282
- // caches immediately instead of waiting out their TTLs. The tool/skill
283
- // catalogs are global but read the DEFAULT branch only, so only
284
- // default-branch changes under their folders matter. (The kb-graph id-index
285
- // invalidation that used to live in this subscriber is registered by the
286
- // enterprise overlay, next to the kb-graph service it belongs to — the
287
- // caches are independent, so the split preserves behavior.)
288
- fileChangeNotifier.onFilesChanged(({ branch, paths }) => {
289
- if (branch !== DEFAULT_BRANCH) return;
290
- if (paths.some((p) => p.startsWith(`${config.kbDirName}/${TOOLS_DIR}/`))) toolManualService.invalidate();
291
- if (paths.some((p) => p.startsWith(`${config.kbDirName}/${SKILLS_DIR}/`))) skillService.invalidate();
292
- });
293
-
294
- // Admin = `Admin` role in roles.yaml, resolved through the access model on the
295
- // default branch (no env allow-list).
296
- const adminAccess = new AdminAccessService(accessControl, workspaceService, DEFAULT_BRANCH);
297
-
298
- // Secrets Vault: the per-user store of credentials (static API keys + OAuth
299
- // tokens) that back UTCP tool variables (`${FOO_API_KEY}`). Encrypted at rest
300
- // with the connector-config key; read only by the `bevel-secrets` variable
301
- // loader at tool-call time. Swap-seam: `ISecretsVaultService` could later be a
302
- // client's external secret manager without touching the loader or the tools.
303
- const secretsVaultService = new DbSecretsVaultService(
304
- db,
305
- config.secretsEncKey,
306
- undefined,
307
- // Scope-driven resolution: a `.tool`'s declared variable scope decides whether
308
- // `resolve` reads the shared (admin) row or the caller's own per-user row.
309
- (key) => toolManualService.scopeOfVariable(key),
310
- );
311
- // Register the `bevel-secrets` UTCP variable loader against this vault so any
312
- // UTCP client can resolve `${VAR}` from the caller's secrets at tool-call time.
313
- registerBevelSecretsVariableLoader(secretsVaultService);
314
-
315
- // Zero-config OAuth for bare `type: mcp` `.tool`s: when the remote server
316
- // demands OAuth (MCP authorization spec), discover its authorization server,
317
- // dynamically register a public PKCE client, and persist the provider as the
318
- // shared vault row — the tool catalog then decorates the manual with a
319
- // synthetic per-user sign-in. Setter-injected: this service needs the vault,
320
- // which is constructed after the tool-manual service.
321
- const mcpOAuthDiscovery = new McpOAuthDiscoveryService({
322
- secretsVault: secretsVaultService,
323
- redirectUri: `${config.publicBackendUrl}/api/secrets/oauth/callback`,
324
- });
325
- toolManualService.setMcpAuthDiscovery(mcpOAuthDiscovery);
326
-
327
- // Least-privilege internal tokens: minted per (workspace, user, scope) by the
328
- // agents' code-mode clients and by the MCP proxy, verified by the tool-auth
329
- // middleware below. The signing key is STABLE across processes sharing the
330
- // deployment env (INTERNAL_TOKEN_SECRET, else derived from JWT_SECRET) so a
331
- // sibling process — the enterprise routine CLI, a second replica — mints
332
- // tokens this server verifies. Only a deployment with NO jwtSecret at all
333
- // falls back to the per-boot random key (single-process smoke setups).
334
- const internalTokenSecret =
335
- config.internalTokenSecret ||
336
- (config.jwtSecret ? deriveInternalTokenSecret(config.jwtSecret) : undefined);
337
- const internalTokenService = new InternalTokenService({
338
- prefix: config.internalTokenPrefix,
339
- secret: internalTokenSecret,
340
- });
341
-
342
- // GDPR erasure path: admin-driven user deletion. The core service erases the
343
- // rows it owns; each module contributes its slice as a participant.
344
- const accountErasureService = new AccountErasureService(db, ports.erasureParticipants ?? []);
345
-
346
- // MCP (remote agent access). ExternalApiKeyService handles connection-key
347
- // lifecycle; McpSessionStore holds per-session userId in memory
348
- // (single-replica — see docs/mcp-remote-access.md). McpService is now a
349
- // GENERIC proxy: per session it discovers the UTCP manual at /api/agent/utcp
350
- // over loopback and re-exposes every tool, dispatching calls back through the
351
- // REST tool surface (so agent logic + metering live there, once).
352
- const externalApiKeyService = new ExternalApiKeyService(db, config.externalApiKeyPrefix);
353
- const mcpSessionStore = new McpSessionStore();
354
- const mcpService = new McpService(
355
- mcpSessionStore,
356
- {
357
- // Loopback to our own REST tool surface — 127.0.0.1 (not localhost) to pin
358
- // IPv4 and dodge resolver ambiguity. The proxy authenticates each call with
359
- // the caller's own connection key.
360
- loopbackBaseUrl: `http://127.0.0.1:${config.port}`,
361
- // Manual namespace + UTCP variable prefix (KNOWLEDGE_BASE_API_URL / …).
362
- manualName: 'KNOWLEDGE_BASE',
363
- // Oversized chain results spill here too — an external MCP session has no
364
- // ambient workspace, so it reads the spill back by ref via `read_file`.
365
- spillStore,
366
- // For the needs-authorization setup link surfaced to external agents.
367
- publicFrontendUrl: config.publicFrontendUrl,
368
- },
369
- // Pre-dispatch per-user credential check: the vault answers "has this caller
370
- // set it?" and the manual catalog answers "which user-scoped vars does this
371
- // tool need?".
372
- secretsVaultService,
373
- toolManualService,
374
- // Loopback bearer mint for OAuth/JWT sessions — their own bearer would
375
- // 401 at the connection-key/internal-only /api/agent/* hop.
376
- internalTokenService,
377
- // Session-grant reset for broken tool sign-ins. A closure because the
378
- // provider is constructed just below (it needs nothing from McpService;
379
- // the binding is only dereferenced at call time, long after boot).
380
- (bearer) => mcpOAuthProvider.revokeByAccessToken(bearer),
381
- );
382
- // MCP OAuth 2.1 authorization server (our own AS): lets MCP clients with no
383
- // pre-shared connection key connect via the standard 401 → discovery → DCR →
384
- // authorize (PKCE) flow. The authorize step routes the browser to /connect
385
- // where the user configures their tools; the resulting access token resolves
386
- // to that user in the MCP auth middleware, like a connection key.
387
- const mcpOAuthProvider = new BevelOAuthProvider({
388
- db,
389
- crypto: config.secretsEncKey ? new TokenCrypto(config.secretsEncKey) : null,
390
- stateSecret: config.jwtSecret,
391
- publicFrontendUrl: config.publicFrontendUrl,
392
- tokenPrefix: config.mcpOAuthTokenPrefix,
393
- });
394
- // RFC 9728 pointer carried on every MCP 401 challenge so OAuth-capable
395
- // clients discover the AS. Single source of truth for the resource id.
396
- const mcpResourceUrl = new URL('/api/mcp', config.publicBackendUrl);
397
- const mcpResourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(mcpResourceUrl);
398
- const mcpAuthMiddleware = createMcpAuthMiddleware(
399
- authService,
400
- externalApiKeyService,
401
- mcpOAuthProvider,
402
- mcpResourceMetadataUrl,
403
- );
404
-
405
- // ── Unified tool surface ──────────────────────────────────────────────
406
- // ONE catalog of self-describing UTCP tools, served as two manuals (external
407
- // for CI/MCP; internal for our agent's loopback code-mode client) and hosted
408
- // by each owning module behind shared auth + context utilities. The core
409
- // server assembles the router (registers tool defs + mounts endpoints). The
410
- // `internalTokenService` minted above (shared with the agent factories) is
411
- // the same one the tool-auth middleware verifies internal tokens against.
412
- const toolRegistry = new ToolRegistry();
413
- const resolveToolContext = createToolContextResolver({
414
- authService,
415
- workspaceService,
416
- workflowService,
417
- events: eventBus,
418
- kbDirName: config.kbDirName,
419
- creatorAccess,
420
- });
421
- const toolHandlerFactory = createToolHandlerFactory(resolveToolContext);
422
- const toolAuthMiddleware = createToolAuthMiddleware(externalApiKeyService, internalTokenService);
423
- // Read-only manual endpoints accept the above PLUS a browser JWT, so a
424
- // logged-in user can browse the catalog with their session. Execution routes
425
- // keep `toolAuthMiddleware` (no JWT), so a session can read but not invoke.
426
- const manualAuthMiddleware = createManualAuthMiddleware(externalApiKeyService, internalTokenService, authService);
427
-
428
- // Recovery-bot identity: a synthetic `recovery-bot@bevel.local` user
429
- // backing (a) the recovery agent's commit authorship and (b) the
430
- // `'system'` feedback notice's user_id FK. Idempotent ensure so this
431
- // is safe to call on every boot.
432
- const recoveryBot = await ensureRecoveryBotUser(db);
433
-
434
- // Background commit worker. Drains `pending_commits` rows that the
435
- // refactored `releaseLock` enqueues. On terminal failure (transient
436
- // retry budget AND recovery-agent budget exhausted) the worker emits
437
- // a `'system'` feedback notice that admins triage out of band.
438
- //
439
- // The orphan-startup sweep runs BEFORE start() so any rows that
440
- // previous deploys left as `running` (process crashed mid-commit) get
441
- // reset to `pending` before the worker starts claiming. The sweep
442
- // also enqueues working-tree dirt that pre-dates the queue (the
443
- // existing `target-company-state` orphans).
444
- await pendingCommitsService.startupReconcile(
445
- workspaceService.knownWorkspaces(),
446
- { scan: (ws) => workspaceService.scanOrphanedPaths(ws.id) },
447
- { email: recoveryBot.email, name: recoveryBot.name },
448
- );
449
- const pendingCommitsWorker = new PendingCommitsWorker({
450
- service: pendingCommitsService,
451
- workflow: {
452
- runPendingCommit: (workspaceId, branch, path, user) =>
453
- workflowService.runPendingCommit(workspaceId, branch, path, user),
454
- },
455
- // Both escalation sinks come through CorePorts: the recovery agent and the
456
- // notice sink are enterprise-owned (background-agent factory / feedback
457
- // dashboard); core defaults skip recovery and log to stderr.
458
- recoveryAgent: ports.recoveryAgent ?? noopRecoveryAgent,
459
- feedback: ports.systemNotice ?? consoleSystemNoticeSink,
460
- workspaces: {
461
- knownWorkspaces: () => workspaceService.knownWorkspaces(),
462
- },
463
- recoveryBot: {
464
- id: recoveryBot.id,
465
- email: recoveryBot.email,
466
- name: recoveryBot.name,
467
- },
468
- });
469
- pendingCommitsWorker.start();
470
-
471
- return {
472
- config,
473
- db,
474
- workspaceService,
475
- kbSeedService,
476
- spillStore,
477
- accessControl,
478
- creatorAccess,
479
- sessionOntologyService,
480
- routineWritePolicy,
481
- skillService,
482
- toolManualService,
483
- authService,
484
- authMiddleware,
485
- accountErasureService,
486
- gitService,
487
- pullRequestService,
488
- diffService,
489
- reviewWorkflowService,
490
- fileLockService,
491
- workflowService,
492
- eventBus,
493
- fileChangeNotifier,
494
- pendingCommitsService,
495
- pendingCommitsWorker,
496
- recoveryBot,
497
- adminAccess,
498
- secretsVaultService,
499
- externalApiKeyService,
500
- internalTokenService,
501
- mcpService,
502
- mcpAuthMiddleware,
503
- mcpOAuthProvider,
504
- toolRegistry,
505
- toolAuthMiddleware,
506
- manualAuthMiddleware,
507
- toolHandlerFactory,
508
- // Server-time seams — defaults here; the enterprise overlay overwrites
509
- // (or, for the array, pushes into) these after construction.
510
- sessionSink: ports.sessionSink ?? new UuidSessionSink(),
511
- usageMeter: ports.usageMeter ?? unmeteredLlmUsage,
512
- authProviders: ports.authProviders ?? [],
513
- };
514
- }
1
+ import type { AuthProviderPlugin } from '../modules/auth/auth.routes.js';
2
+ import type { AuthUser } from '@bevel-software/platform-shared';
3
+ import { DEFAULT_BRANCH, PROTECTED_BRANCHES, SKILLS_DIR, TOOLS_DIR } from '@bevel-software/platform-shared';
4
+ import { CoreConfig } from '../core-config.js';
5
+ import { getDb, type Database } from '../modules/database/connection.js';
6
+ import { runCoreMigrations } from '../modules/database/migrate.js';
7
+ import { coreMigrationsDir } from '../assets.js';
8
+ import { WorkspaceService } from '../modules/workspace/workspace.service.js';
9
+ import { RoutineWritePolicyService } from '../modules/workspace/routine-write-policy.js';
10
+ import { KbSeedService } from '../modules/workspace/kb-seed.service.js';
11
+ import { SpillStore } from '../modules/workspace/spill-store.js';
12
+ import { UuidSessionSink, type ISessionSink } from '../modules/workspace/session-sink.js';
13
+ import { AuthService } from '../modules/auth/auth.service.js';
14
+ import { AccountErasureService } from '../modules/auth/account-erasure.service.js';
15
+ import { OidcAuthProvider } from '../modules/auth/oidc-auth-provider.js';
16
+ import { createAuthMiddleware } from '../modules/auth/auth.middleware.js';
17
+ import { AccessControlService } from '../modules/access/access-control.service.js';
18
+ import { CreatorAccessService } from '../modules/access/creator-access.js';
19
+ import { SkillService } from '../modules/skills/index.js';
20
+ import { ToolManualService } from '../modules/tool-manuals/index.js';
21
+ import {
22
+ DbSecretsVaultService,
23
+ McpOAuthDiscoveryService,
24
+ registerBevelSecretsVariableLoader,
25
+ } from '../modules/secrets-vault/index.js';
26
+ import { GitService } from '../modules/workflow/git/git.service.js';
27
+ import { PullRequestService } from '../modules/workflow/git/pull-request.service.js';
28
+ import { WorkspaceMutex } from '../modules/workflow/git/mutex.js';
29
+ import { assertGitVersion } from '../modules/workflow/git/git-version.js';
30
+ import { DiffService } from '../modules/diff/diff.service.js';
31
+ import { ReviewWorkflowService } from '../modules/workflow/review-workflow/review-workflow.service.js';
32
+ import { FileLockService } from '../modules/workflow/file-lock.service.js';
33
+ import { WorkflowEventBus } from '../modules/workflow/event-bus.js';
34
+ import { FileChangeNotifier } from '../modules/workflow/file-change-notifier.js';
35
+ import { WorkflowService } from '../modules/workflow/workflow.service.js';
36
+ import { WorkflowHooks } from '../modules/workflow/workflow-hooks.js';
37
+ import { SessionOntologyService } from '../modules/workflow/session-ontology.service.js';
38
+ import { PendingCommitsService } from '../modules/workflow/pending-commits.service.js';
39
+ import {
40
+ PendingCommitsWorker,
41
+ consoleSystemNoticeSink,
42
+ } from '../modules/workflow/pending-commits.worker.js';
43
+ import { ensureRecoveryBotUser } from '../modules/workflow/recovery-bot.js';
44
+ import { AdminAccessService } from '../modules/admin/admin-access.service.js';
45
+ import { ExternalApiKeyService } from '../modules/tool-auth/external-api-key.service.js';
46
+ import {
47
+ InternalTokenService,
48
+ deriveInternalTokenSecret,
49
+ } from '../modules/tool-auth/internal-token.service.js';
50
+ import {
51
+ createToolAuthMiddleware,
52
+ createManualAuthMiddleware,
53
+ } from '../modules/tool-auth/tool-auth.middleware.js';
54
+ import { unmeteredLlmUsage, type ILlmUsageMeter } from '../modules/tool-auth/llm-usage-meter.js';
55
+ import { McpSessionStore } from '../modules/mcp/mcp-session-store.js';
56
+ import { McpService } from '../modules/mcp/mcp.service.js';
57
+ import { createMcpAuthMiddleware } from '../modules/mcp/mcp-auth.middleware.js';
58
+ import { BevelOAuthProvider } from '../modules/mcp/oauth/bevel-oauth-provider.js';
59
+ import { getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js';
60
+ import { ToolRegistry } from '../modules/tool-registry/tool-registry.js';
61
+ import { createToolContextResolver } from '../modules/tool-helpers/tool-context.js';
62
+ import { createToolHandlerFactory } from '../modules/tool-helpers/tool-handler.js';
63
+ import { TokenCrypto } from '../shared/token-crypto.js';
64
+ import { noopRecoveryAgent, type CorePorts } from './core-ports.js';
65
+
66
+ /**
67
+ * Everything the CORE composition builds: the core services `createCoreServer`
68
+ * mounts, plus the primitives the enterprise overlay's own construction needs
69
+ * (db, event bus, notifier, registries, middlewares, …).
70
+ *
71
+ * The fields under "server-time seams" are deliberately MUTABLE: they are only
72
+ * read when the server is built / at request time, and the enterprise overlay
73
+ * overwrites them after `createCoreServices` returns (its implementations need
74
+ * core services to construct). See {@link CorePorts} for the pattern.
75
+ */
76
+ export interface CoreServices {
77
+ config: CoreConfig;
78
+ db: Database;
79
+ workspaceService: WorkspaceService;
80
+ kbSeedService: KbSeedService;
81
+ spillStore: SpillStore;
82
+ accessControl: AccessControlService;
83
+ creatorAccess: CreatorAccessService;
84
+ sessionOntologyService: SessionOntologyService;
85
+ routineWritePolicy: RoutineWritePolicyService;
86
+ skillService: SkillService;
87
+ toolManualService: ToolManualService;
88
+ authService: AuthService;
89
+ authMiddleware: ReturnType<typeof createAuthMiddleware>;
90
+ accountErasureService: AccountErasureService;
91
+ gitService: GitService;
92
+ pullRequestService: PullRequestService;
93
+ diffService: DiffService;
94
+ reviewWorkflowService: ReviewWorkflowService;
95
+ fileLockService: FileLockService;
96
+ workflowService: WorkflowService;
97
+ eventBus: WorkflowEventBus;
98
+ fileChangeNotifier: FileChangeNotifier;
99
+ pendingCommitsService: PendingCommitsService;
100
+ pendingCommitsWorker: PendingCommitsWorker;
101
+ recoveryBot: AuthUser;
102
+ adminAccess: AdminAccessService;
103
+ secretsVaultService: DbSecretsVaultService;
104
+ externalApiKeyService: ExternalApiKeyService;
105
+ internalTokenService: InternalTokenService;
106
+ mcpService: McpService;
107
+ mcpAuthMiddleware: ReturnType<typeof createMcpAuthMiddleware>;
108
+ mcpOAuthProvider: BevelOAuthProvider;
109
+ toolRegistry: ToolRegistry;
110
+ toolAuthMiddleware: ReturnType<typeof createToolAuthMiddleware>;
111
+ manualAuthMiddleware: ReturnType<typeof createManualAuthMiddleware>;
112
+ toolHandlerFactory: ReturnType<typeof createToolHandlerFactory>;
113
+ // ── Server-time seams (enterprise overwrites after construction) ────────
114
+ /** `start_session` backing — core default {@link UuidSessionSink}. */
115
+ sessionSink: ISessionSink;
116
+ /** Connection-key LLM metering view — core default unmetered. */
117
+ usageMeter: ILlmUsageMeter;
118
+ /** SSO plugins the auth router mounts — same array instance as the port. */
119
+ authProviders: AuthProviderPlugin[];
120
+ }
121
+
122
+ export async function createCoreServices(
123
+ config: CoreConfig,
124
+ ports: CorePorts = {},
125
+ ): Promise<CoreServices> {
126
+ // Fail fast on a runtime whose git is too old for `--no-write-fetch-head`
127
+ // (see `git-version.ts`): every clone, fetch and refresh below depends on it,
128
+ // so an unsupported binary is better surfaced here than at the first merge.
129
+ await assertGitVersion();
130
+ const db = getDb(config.databaseUrl);
131
+ // Migrations must run BEFORE any service that reads or writes a managed
132
+ // table. `PendingCommitsService.startupReconcile` (called later in this
133
+ // function) hits `pending_commits` — on a fresh DB, deferring migrations to
134
+ // after createServices would 42P01 the boot.
135
+ //
136
+ // DIVERGENCE vs bevel-platform's copy of this file (Phase C reconciliation):
137
+ // upstream still calls the legacy single-folder `runMigrations(db)`; this
138
+ // package runs its own squashed idempotent CORE history from the packaged
139
+ // `migrations/` folder, tracked in `__drizzle_migrations_core`. An
140
+ // enterprise overlay runs its own history AFTER this (see migrate.ts).
141
+ await runCoreMigrations(db, coreMigrationsDir());
142
+ const workspaceService = new WorkspaceService(
143
+ config.workspacesRoot,
144
+ config.kbRepoUrl,
145
+ config.kbDirName,
146
+ config.gitUsername,
147
+ );
148
+ // Seed (or top-up) the KB remote before the first clone so the rest of the app
149
+ // can keep assuming the remote already carries the protected branches + base
150
+ // scaffolding — a fresh or partially-populated remote no longer needs to have
151
+ // been forked from the standalone template.
152
+ const kbSeedService = new KbSeedService(
153
+ config.kbRepoUrl,
154
+ config.kbTemplateDir,
155
+ [...PROTECTED_BRANCHES],
156
+ DEFAULT_BRANCH,
157
+ config.seedAdminEmails,
158
+ config.gitUsername,
159
+ );
160
+ workspaceService.setSeedService(kbSeedService);
161
+ // Shared, workspace-independent store for oversized `call_tool_chain` results,
162
+ // read back via `read_file`. Sibling of `workspacesRoot`, never committed.
163
+ const spillStore = new SpillStore(config.spillRoot);
164
+ const accessControl = new AccessControlService(workspaceService, config.kbDirName);
165
+ // Creator read-grant on creation: read is default-deny, so every surface
166
+ // that creates KB files/folders (human routes, agent tools, upload apply)
167
+ // consults this planner to keep creations visible to their creator.
168
+ const creatorAccess = new CreatorAccessService(workspaceService, accessControl, config.kbDirName);
169
+
170
+ // Ontology-session boundary: records each agent run's touched ontologies and
171
+ // blocks writes once a run has crossed ontologies. Postgres-backed so the
172
+ // boundary survives a restart.
173
+ const sessionOntologyService = new SessionOntologyService(db);
174
+ // Per-run write restriction (by file extension). Shared by the workspace tool
175
+ // surface (which enforces it) and the routine runner (which sets it for
176
+ // dashboard-only `watchlist_check` runs). In-memory: a restriction lives only for
177
+ // one run, unlike the Postgres-backed ontology touched-set above.
178
+ const routineWritePolicy = new RoutineWritePolicyService();
179
+ // Skills: discovered from the default-branch workspace only (global catalog).
180
+ const skillService = new SkillService(workspaceService, accessControl, config.kbDirName);
181
+ // Tool manuals: user-authored `*.tool` files under `Tools/` in the default
182
+ // branch — access-controlled like Skills, served to external agents via
183
+ // `GET /api/agent/all-tools` and registered on the MCP proxy's UTCP client.
184
+ const toolManualService = new ToolManualService(workspaceService, accessControl, config.kbDirName);
185
+ // Auth service — resolves identities for login, PR author attribution, and
186
+ // access lookups. (Change requests now store the author email directly, so
187
+ // PullRequestService no longer depends on it for attribution.)
188
+ const authService = new AuthService(db, config);
189
+ const authMiddleware = createAuthMiddleware(authService);
190
+
191
+ // Shared mutex so git and diff operations on the same workspace serialize
192
+ // against each other — a backup-reseed races a concurrent commit otherwise.
193
+ const workspaceMutex = new WorkspaceMutex();
194
+ // Workflow lifecycle hooks — the ONE registry this composition shares
195
+ // between GitService (advisory commit validation), the session-ontology
196
+ // gate (blocking preWrite), and the enterprise composition root, which
197
+ // registers module-owned handlers on `workflowService.hooks` right after
198
+ // this function returns. Core registers none: no commit-time validation
199
+ // (advisory anyway) and no ontology write block.
200
+ const workflowHooks = new WorkflowHooks();
201
+ const gitService = new GitService(
202
+ workspaceService,
203
+ workflowHooks,
204
+ config.kbDirName,
205
+ workspaceMutex,
206
+ accessControl,
207
+ );
208
+ // A fresh clone has already fetched every ref — let the git layer skip the
209
+ // redundant implicit `git fetch` on the first `listBranches` after bootstrap.
210
+ workspaceService.setWorkspaceClonedListener((id) => gitService.noteWorkspaceFetched(id));
211
+ const pullRequestService = new PullRequestService(
212
+ db,
213
+ workspaceService,
214
+ accessControl,
215
+ gitService,
216
+ );
217
+ const diffService = new DiffService(
218
+ workspaceService,
219
+ workspaceMutex,
220
+ config.workspacesRoot,
221
+ config.backupsRoot,
222
+ config.kbDirName,
223
+ );
224
+ // Late-bind the diff service into WorkspaceService so file writes/moves
225
+ // trigger backup updates. (GitService used to take a diffService too — for
226
+ // the post-switch backup reseed — but switchBranch is gone with the
227
+ // per-branch workspace model, so the injection no longer applies.)
228
+ workspaceService.setDiffService(diffService);
229
+ const reviewWorkflowService = new ReviewWorkflowService(
230
+ db,
231
+ accessControl,
232
+ workspaceService,
233
+ gitService,
234
+ );
235
+ // Late-bind the comment enricher so PullRequestService.getPrDetail includes
236
+ // our DB-backed comments without forcing a circular constructor dependency.
237
+ pullRequestService.setDetailEnricher(reviewWorkflowService);
238
+
239
+ // Workflow facade — the abstraction every app consumer should reach for
240
+ // instead of the individual git / PR / review-workflow services. See
241
+ // PLAN.md for the migration story. Today it delegates straight through;
242
+ // implementations migrate into the workflow module over later steps.
243
+ const fileLockService = new FileLockService(db);
244
+ // Background commit queue. The `WorkflowService.releaseLock` path
245
+ // enqueues a row here instead of running commitFile + push inline; the
246
+ // worker drains the queue out of band so a git hiccup can't block the
247
+ // user-visible save path. See `lock-decoupling-plan.md`.
248
+ const pendingCommitsService = new PendingCommitsService(db);
249
+ // Let the git status path tell an expected dirty tree (queued saves still
250
+ // draining) from a genuinely orphaned one before it warns loudly.
251
+ gitService.setPendingCommitsProbe((workspaceId) =>
252
+ pendingCommitsService.hasAnyForWorkspace(workspaceId),
253
+ );
254
+ // In-process event bus: every WorkflowService mutation method that
255
+ // succeeds emits a typed event; the SSE route fans it to connected
256
+ // browser sessions. Single instance per Node process — survives across
257
+ // requests, doesn't survive restarts (clients reconnect + resync).
258
+ const eventBus = new WorkflowEventBus();
259
+ // In-process post-commit hook (distinct from the SSE bus): fires once per
260
+ // committed change (batched paths) so catalogs refresh instantly and the
261
+ // id-repair keeps the id namespace collision-free. Subscribers are registered
262
+ // below, once the reacting services exist.
263
+ const fileChangeNotifier = new FileChangeNotifier();
264
+ const workflowService = new WorkflowService(
265
+ db,
266
+ gitService,
267
+ pullRequestService,
268
+ reviewWorkflowService,
269
+ workspaceService,
270
+ accessControl,
271
+ fileLockService,
272
+ pendingCommitsService,
273
+ config.kbDirName,
274
+ eventBus,
275
+ fileChangeNotifier,
276
+ // Exposed as `workflowService.hooks` — the SAME instance GitService and
277
+ // the session-ontology gate consult, so enterprise registrations against
278
+ // it reach every hook point.
279
+ workflowHooks,
280
+ );
281
+
282
+ // Subscriber A — catalog freshness: a committed change drops the affected
283
+ // caches immediately instead of waiting out their TTLs. The tool/skill
284
+ // catalogs are global but read the DEFAULT branch only, so only
285
+ // default-branch changes under their folders matter. (The kb-graph id-index
286
+ // invalidation that used to live in this subscriber is registered by the
287
+ // enterprise overlay, next to the kb-graph service it belongs to — the
288
+ // caches are independent, so the split preserves behavior.)
289
+ fileChangeNotifier.onFilesChanged(({ branch, paths }) => {
290
+ if (branch !== DEFAULT_BRANCH) return;
291
+ if (paths.some((p) => p.startsWith(`${config.kbDirName}/${TOOLS_DIR}/`))) toolManualService.invalidate();
292
+ if (paths.some((p) => p.startsWith(`${config.kbDirName}/${SKILLS_DIR}/`))) skillService.invalidate();
293
+ });
294
+
295
+ // Admin = `Admin` role in roles.yaml, resolved through the access model on the
296
+ // default branch (no env allow-list).
297
+ const adminAccess = new AdminAccessService(
298
+ accessControl,
299
+ workspaceService,
300
+ DEFAULT_BRANCH,
301
+ // The env bootstrap admin administers accounts/roles even before the KB's
302
+ // roles.yaml lists it (see AdminAccessService).
303
+ config.adminEmail ? [config.adminEmail] : [],
304
+ );
305
+
306
+ // Secrets Vault: the per-user store of credentials (static API keys + OAuth
307
+ // tokens) that back UTCP tool variables (`${FOO_API_KEY}`). Encrypted at rest
308
+ // with the connector-config key; read only by the `bevel-secrets` variable
309
+ // loader at tool-call time. Swap-seam: `ISecretsVaultService` could later be a
310
+ // client's external secret manager without touching the loader or the tools.
311
+ const secretsVaultService = new DbSecretsVaultService(
312
+ db,
313
+ config.secretsEncKey,
314
+ undefined,
315
+ // Scope-driven resolution: a `.tool`'s declared variable scope decides whether
316
+ // `resolve` reads the shared (admin) row or the caller's own per-user row.
317
+ (key) => toolManualService.scopeOfVariable(key),
318
+ );
319
+ // Register the `bevel-secrets` UTCP variable loader against this vault so any
320
+ // UTCP client can resolve `${VAR}` from the caller's secrets at tool-call time.
321
+ registerBevelSecretsVariableLoader(secretsVaultService);
322
+
323
+ // Zero-config OAuth for bare `type: mcp` `.tool`s: when the remote server
324
+ // demands OAuth (MCP authorization spec), discover its authorization server,
325
+ // dynamically register a public PKCE client, and persist the provider as the
326
+ // shared vault row — the tool catalog then decorates the manual with a
327
+ // synthetic per-user sign-in. Setter-injected: this service needs the vault,
328
+ // which is constructed after the tool-manual service.
329
+ const mcpOAuthDiscovery = new McpOAuthDiscoveryService({
330
+ secretsVault: secretsVaultService,
331
+ redirectUri: `${config.publicBackendUrl}/api/secrets/oauth/callback`,
332
+ });
333
+ toolManualService.setMcpAuthDiscovery(mcpOAuthDiscovery);
334
+
335
+ // Least-privilege internal tokens: minted per (workspace, user, scope) by the
336
+ // agents' code-mode clients and by the MCP proxy, verified by the tool-auth
337
+ // middleware below. The signing key is STABLE across processes sharing the
338
+ // deployment env (INTERNAL_TOKEN_SECRET, else derived from JWT_SECRET) so a
339
+ // sibling process — the enterprise routine CLI, a second replica — mints
340
+ // tokens this server verifies. Only a deployment with NO jwtSecret at all
341
+ // falls back to the per-boot random key (single-process smoke setups).
342
+ const internalTokenSecret =
343
+ config.internalTokenSecret ||
344
+ (config.jwtSecret ? deriveInternalTokenSecret(config.jwtSecret) : undefined);
345
+ const internalTokenService = new InternalTokenService({
346
+ prefix: config.internalTokenPrefix,
347
+ secret: internalTokenSecret,
348
+ });
349
+
350
+ // GDPR erasure path: admin-driven user deletion. The core service erases the
351
+ // rows it owns; each module contributes its slice as a participant.
352
+ const accountErasureService = new AccountErasureService(db, ports.erasureParticipants ?? []);
353
+
354
+ // MCP (remote agent access). ExternalApiKeyService handles connection-key
355
+ // lifecycle; McpSessionStore holds per-session userId in memory
356
+ // (single-replica — see docs/mcp-remote-access.md). McpService is now a
357
+ // GENERIC proxy: per session it discovers the UTCP manual at /api/agent/utcp
358
+ // over loopback and re-exposes every tool, dispatching calls back through the
359
+ // REST tool surface (so agent logic + metering live there, once).
360
+ const externalApiKeyService = new ExternalApiKeyService(db, config.externalApiKeyPrefix);
361
+ const mcpSessionStore = new McpSessionStore();
362
+ const mcpService = new McpService(
363
+ mcpSessionStore,
364
+ {
365
+ // Loopback to our own REST tool surface — 127.0.0.1 (not localhost) to pin
366
+ // IPv4 and dodge resolver ambiguity. The proxy authenticates each call with
367
+ // the caller's own connection key.
368
+ loopbackBaseUrl: `http://127.0.0.1:${config.port}`,
369
+ // Manual namespace + UTCP variable prefix (KNOWLEDGE_BASE_API_URL / …).
370
+ manualName: 'KNOWLEDGE_BASE',
371
+ // Oversized chain results spill here too — an external MCP session has no
372
+ // ambient workspace, so it reads the spill back by ref via `read_file`.
373
+ spillStore,
374
+ // For the needs-authorization setup link surfaced to external agents.
375
+ publicFrontendUrl: config.publicFrontendUrl,
376
+ },
377
+ // Pre-dispatch per-user credential check: the vault answers "has this caller
378
+ // set it?" and the manual catalog answers "which user-scoped vars does this
379
+ // tool need?".
380
+ secretsVaultService,
381
+ toolManualService,
382
+ // Loopback bearer mint for OAuth/JWT sessions — their own bearer would
383
+ // 401 at the connection-key/internal-only /api/agent/* hop.
384
+ internalTokenService,
385
+ // Session-grant reset for broken tool sign-ins. A closure because the
386
+ // provider is constructed just below (it needs nothing from McpService;
387
+ // the binding is only dereferenced at call time, long after boot).
388
+ (bearer) => mcpOAuthProvider.revokeByAccessToken(bearer),
389
+ );
390
+ // MCP OAuth 2.1 authorization server (our own AS): lets MCP clients with no
391
+ // pre-shared connection key connect via the standard 401 → discovery → DCR →
392
+ // authorize (PKCE) flow. The authorize step routes the browser to /connect
393
+ // where the user configures their tools; the resulting access token resolves
394
+ // to that user in the MCP auth middleware, like a connection key.
395
+ const mcpOAuthProvider = new BevelOAuthProvider({
396
+ db,
397
+ crypto: config.secretsEncKey ? new TokenCrypto(config.secretsEncKey) : null,
398
+ stateSecret: config.jwtSecret,
399
+ publicFrontendUrl: config.publicFrontendUrl,
400
+ tokenPrefix: config.mcpOAuthTokenPrefix,
401
+ });
402
+ // RFC 9728 pointer carried on every MCP 401 challenge so OAuth-capable
403
+ // clients discover the AS. Single source of truth for the resource id.
404
+ const mcpResourceUrl = new URL('/api/mcp', config.publicBackendUrl);
405
+ const mcpResourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(mcpResourceUrl);
406
+ const mcpAuthMiddleware = createMcpAuthMiddleware(
407
+ authService,
408
+ externalApiKeyService,
409
+ mcpOAuthProvider,
410
+ mcpResourceMetadataUrl,
411
+ );
412
+
413
+ // ── Unified tool surface ──────────────────────────────────────────────
414
+ // ONE catalog of self-describing UTCP tools, served as two manuals (external
415
+ // for CI/MCP; internal for our agent's loopback code-mode client) and hosted
416
+ // by each owning module behind shared auth + context utilities. The core
417
+ // server assembles the router (registers tool defs + mounts endpoints). The
418
+ // `internalTokenService` minted above (shared with the agent factories) is
419
+ // the same one the tool-auth middleware verifies internal tokens against.
420
+ const toolRegistry = new ToolRegistry();
421
+ const resolveToolContext = createToolContextResolver({
422
+ authService,
423
+ workspaceService,
424
+ workflowService,
425
+ events: eventBus,
426
+ kbDirName: config.kbDirName,
427
+ creatorAccess,
428
+ });
429
+ const toolHandlerFactory = createToolHandlerFactory(resolveToolContext);
430
+ const toolAuthMiddleware = createToolAuthMiddleware(externalApiKeyService, internalTokenService);
431
+ // Read-only manual endpoints accept the above PLUS a browser JWT, so a
432
+ // logged-in user can browse the catalog with their session. Execution routes
433
+ // keep `toolAuthMiddleware` (no JWT), so a session can read but not invoke.
434
+ const manualAuthMiddleware = createManualAuthMiddleware(externalApiKeyService, internalTokenService, authService);
435
+
436
+ // Recovery-bot identity: a synthetic `recovery-bot@bevel.local` user
437
+ // backing (a) the recovery agent's commit authorship and (b) the
438
+ // `'system'` feedback notice's user_id FK. Idempotent ensure so this
439
+ // is safe to call on every boot.
440
+ const recoveryBot = await ensureRecoveryBotUser(db);
441
+
442
+ // Background commit worker. Drains `pending_commits` rows that the
443
+ // refactored `releaseLock` enqueues. On terminal failure (transient
444
+ // retry budget AND recovery-agent budget exhausted) the worker emits
445
+ // a `'system'` feedback notice that admins triage out of band.
446
+ //
447
+ // The orphan-startup sweep runs BEFORE start() so any rows that
448
+ // previous deploys left as `running` (process crashed mid-commit) get
449
+ // reset to `pending` before the worker starts claiming. The sweep
450
+ // also enqueues working-tree dirt that pre-dates the queue (the
451
+ // existing `target-company-state` orphans).
452
+ await pendingCommitsService.startupReconcile(
453
+ workspaceService.knownWorkspaces(),
454
+ { scan: (ws) => workspaceService.scanOrphanedPaths(ws.id) },
455
+ { email: recoveryBot.email, name: recoveryBot.name },
456
+ );
457
+ const pendingCommitsWorker = new PendingCommitsWorker({
458
+ service: pendingCommitsService,
459
+ workflow: {
460
+ runPendingCommit: (workspaceId, branch, path, user) =>
461
+ workflowService.runPendingCommit(workspaceId, branch, path, user),
462
+ },
463
+ // Both escalation sinks come through CorePorts: the recovery agent and the
464
+ // notice sink are enterprise-owned (background-agent factory / feedback
465
+ // dashboard); core defaults skip recovery and log to stderr.
466
+ recoveryAgent: ports.recoveryAgent ?? noopRecoveryAgent,
467
+ feedback: ports.systemNotice ?? consoleSystemNoticeSink,
468
+ workspaces: {
469
+ knownWorkspaces: () => workspaceService.knownWorkspaces(),
470
+ },
471
+ recoveryBot: {
472
+ id: recoveryBot.id,
473
+ email: recoveryBot.email,
474
+ name: recoveryBot.name,
475
+ },
476
+ });
477
+ pendingCommitsWorker.start();
478
+
479
+ // SSO providers. The array REFERENCE is shared with the caller's port — an
480
+ // overlay pushes its own plugins into it after construction (they mount when
481
+ // the server is built, later). Core contributes the generic OIDC provider
482
+ // when the env configures one.
483
+ const authProviders = ports.authProviders ?? [];
484
+ if (config.oidcIssuerUrl && config.oidcClientId && config.oidcClientSecret) {
485
+ authProviders.push(
486
+ new OidcAuthProvider({
487
+ issuerUrl: config.oidcIssuerUrl,
488
+ clientId: config.oidcClientId,
489
+ clientSecret: config.oidcClientSecret,
490
+ scopes: config.oidcScopes,
491
+ label: config.oidcProviderLabel,
492
+ publicBackendUrl: config.publicBackendUrl,
493
+ publicFrontendUrl: config.publicFrontendUrl,
494
+ cookieSecure: config.publicBackendUrl.startsWith('https'),
495
+ }),
496
+ );
497
+ }
498
+
499
+ return {
500
+ config,
501
+ db,
502
+ workspaceService,
503
+ kbSeedService,
504
+ spillStore,
505
+ accessControl,
506
+ creatorAccess,
507
+ sessionOntologyService,
508
+ routineWritePolicy,
509
+ skillService,
510
+ toolManualService,
511
+ authService,
512
+ authMiddleware,
513
+ accountErasureService,
514
+ gitService,
515
+ pullRequestService,
516
+ diffService,
517
+ reviewWorkflowService,
518
+ fileLockService,
519
+ workflowService,
520
+ eventBus,
521
+ fileChangeNotifier,
522
+ pendingCommitsService,
523
+ pendingCommitsWorker,
524
+ recoveryBot,
525
+ adminAccess,
526
+ secretsVaultService,
527
+ externalApiKeyService,
528
+ internalTokenService,
529
+ mcpService,
530
+ mcpAuthMiddleware,
531
+ mcpOAuthProvider,
532
+ toolRegistry,
533
+ toolAuthMiddleware,
534
+ manualAuthMiddleware,
535
+ toolHandlerFactory,
536
+ // Server-time seams — defaults here; the enterprise overlay overwrites
537
+ // (or, for the array, pushes into) these after construction.
538
+ sessionSink: ports.sessionSink ?? new UuidSessionSink(),
539
+ usageMeter: ports.usageMeter ?? unmeteredLlmUsage,
540
+ authProviders,
541
+ };
542
+ }