@bevel-software/platform-core-backend 0.1.1 → 0.2.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 (62) 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/migrations/0001_user_password_hash.sql +4 -0
  44. package/migrations/meta/_journal.json +20 -13
  45. package/package.json +2 -2
  46. package/src/core/create-core-server.ts +352 -338
  47. package/src/core/create-core-services.ts +542 -514
  48. package/src/core-config.ts +55 -2
  49. package/src/modules/admin/admin-access.service.ts +10 -0
  50. package/src/modules/auth/__tests__/account.routes.test.ts +98 -0
  51. package/src/modules/auth/__tests__/auth.routes.test.ts +91 -0
  52. package/src/modules/auth/__tests__/auth.service.test.ts +217 -0
  53. package/src/modules/auth/__tests__/oidc-auth-provider.test.ts +174 -0
  54. package/src/modules/auth/__tests__/password-hash.test.ts +53 -0
  55. package/src/modules/auth/__tests__/rate-limit.test.ts +36 -0
  56. package/src/modules/auth/account.routes.ts +59 -0
  57. package/src/modules/auth/auth.routes.ts +68 -10
  58. package/src/modules/auth/auth.service.ts +151 -32
  59. package/src/modules/auth/oidc-auth-provider.ts +229 -0
  60. package/src/modules/auth/password-hash.ts +80 -0
  61. package/src/modules/auth/rate-limit.ts +45 -0
  62. package/src/modules/database/core-schema.ts +437 -431
@@ -1,338 +1,352 @@
1
- import express from 'express';
2
- import cors from 'cors';
3
- import path from 'node:path';
4
- import type { Router, RequestHandler } from 'express';
5
- import { createAuthRoutes } from '../modules/auth/auth.routes.js';
6
- import { createWorkspaceRoutes } from '../modules/workspace/workspace.routes.js';
7
- import { createDiffRoutes } from '../modules/diff/diff.routes.js';
8
- import { createWorkflowRoutes } from '../modules/workflow/workflow.routes.js';
9
- import { createEventsRoutes } from '../modules/workflow/events.routes.js';
10
- import { createAccessRoutes } from '../modules/access/access.routes.js';
11
- import { createMcpRoutes } from '../modules/mcp/mcp.routes.js';
12
- import { createOAuthConsentRoutes } from '../modules/mcp/oauth/oauth-consent.routes.js';
13
- import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
14
- import { createManualRoutes } from '../modules/tool-registry/manual.routes.js';
15
- import {
16
- createToolManualsAgentRoutes,
17
- createToolManualsBrowserRoutes,
18
- registerToolManualsTools,
19
- } from '../modules/tool-manuals/index.js';
20
- import { registerWorkflowTools } from '../modules/workflow/agent-tools/workflow.tools.js';
21
- import { registerWorkspaceTools } from '../modules/workspace/workspace.tools.js';
22
- import { RECOVERY_BOT_EMAIL } from '../modules/workflow/recovery-bot.js';
23
- import { registerSkillsTools, createSkillsRoutes } from '../modules/skills/index.js';
24
- import type { SessionOntologyGate } from '../modules/workspace/session-ontology.gate.js';
25
- import {
26
- createSecretsVaultRoutes,
27
- createSecretsVaultPublicRoutes,
28
- } from '../modules/secrets-vault/index.js';
29
- import { createAdminAccessRoutes } from '../modules/admin/admin-access.routes.js';
30
- import { GIT_SHA } from '../version.js';
31
- import type { CoreServices } from './create-core-services.js';
32
-
33
- type ExpressApp = ReturnType<typeof express>;
34
-
35
- /**
36
- * The context handed to {@link ServerExtensions.tools}: everything an overlay
37
- * needs to register its own tool defs + endpoint routes on the unified tool
38
- * surface, exactly like the core modules do.
39
- */
40
- export interface ToolSurfaceCtx {
41
- registry: CoreServices['toolRegistry'];
42
- router: Router;
43
- toolAuth: RequestHandler;
44
- toolHandler: CoreServices['toolHandlerFactory'];
45
- /** Shared ontology-session boundary gate config (file tools + graph tools). */
46
- sessionOntologyGate: SessionOntologyGate;
47
- core: CoreServices;
48
- }
49
-
50
- /**
51
- * The hook points an overlay (the enterprise app) uses to add its surfaces to
52
- * the core server. Each hook maps to a fixed slot in the LOAD-BEARING mount
53
- * order encoded in {@link createCoreServer} — the order comments there explain
54
- * why each slot sits where it does (Express 5 fires outer middleware before
55
- * route matching, so pre-JWT surfaces MUST mount before the JWT `/api` mounts).
56
- */
57
- export interface ServerExtensions {
58
- /**
59
- * Request paths the global 10 MB JSON body parser must skip because the
60
- * overlay's own router installs a larger parser for them (body-parser
61
- * ignores a second parse once the first has run — see the comment at the
62
- * parser below).
63
- */
64
- jsonParserExemptPaths?: string[];
65
- /**
66
- * Boot-time side effects that belong to the server lifecycle (startup
67
- * reconciles, periodic sweeps). Runs after health/cors/json are installed,
68
- * before any routes mount.
69
- */
70
- onBoot?(core: CoreServices): Promise<void> | void;
71
- /** Un-authed overlay surfaces (e.g. OAuth callbacks hit by provider redirects). */
72
- preAuth?(app: ExpressApp, core: CoreServices): void;
73
- /**
74
- * Overlay tool registrations on the unified tool surface. Runs after the
75
- * core registrations and BEFORE the manual routes mount, preserving the
76
- * "manual snapshot stays complete" invariant.
77
- */
78
- tools?(ctx: ToolSurfaceCtx): void;
79
- /**
80
- * Non-JWT overlay surfaces that historically mount AFTER the tools router
81
- * but before the JWT-protected `/api` routes (LLM proxy, embed, upload).
82
- * A separate phase from `preAuth` purely to preserve today's exact order.
83
- */
84
- postTools?(app: ExpressApp, core: CoreServices): void;
85
- /** JWT-protected overlay routes; mounted after every core authed route. */
86
- authed?(app: ExpressApp, core: CoreServices): void;
87
- }
88
-
89
- export async function createCoreServer(
90
- core: CoreServices,
91
- ext: ServerExtensions = {},
92
- opts: { staticDir?: string } = {},
93
- ): Promise<ExpressApp> {
94
- const app = express();
95
-
96
- // `credentials: true` so EventSource (`withCredentials: true`) can carry
97
- // the `bevel_token` cookie set at login — the only auth path the
98
- // EventSource API supports. `origin: true` reflects the request origin
99
- // back in `Access-Control-Allow-Origin`, which the spec requires when
100
- // credentials are in play (the wildcard `*` is refused alongside
101
- // credentials). Cookie itself is `SameSite=Lax`, so a hostile cross-site
102
- // page still can't read events on the user's behalf.
103
- //
104
- // `exposedHeaders` lets a BROWSER-based MCP client (e.g. MCP Inspector) read
105
- // the Streamable-HTTP session header off the `initialize` response — custom
106
- // response headers are hidden from browser JS unless exposed, so without this
107
- // the client can't send `Mcp-Session-Id` back and every follow-up 400s with
108
- // "session id does not match any active session". `WWW-Authenticate` is
109
- // exposed so a browser client can read the 401 challenge and start the OAuth
110
- // discovery flow. (Native clients like Claude Code aren't subject to CORS.)
111
- app.use(
112
- cors({
113
- origin: true,
114
- credentials: true,
115
- exposedHeaders: ['Mcp-Session-Id', 'Mcp-Protocol-Version', 'WWW-Authenticate'],
116
- }),
117
- );
118
- // Global JSON body parser. Some overlay routes carry a whole document dump
119
- // (e.g. the onboarding import), so their routers install their own larger
120
- // parser — skip the global one for those paths (body-parser ignores a second
121
- // parse once the first has run, so without this the 10 MB global limit would
122
- // shadow the route's larger limit and 413 a large-but-valid upload before it
123
- // ever reaches the route).
124
- const jsonExemptPaths = new Set(ext.jsonParserExemptPaths ?? []);
125
- const globalJson = express.json({ limit: '10mb' });
126
- app.use((req, res, next) => {
127
- if (jsonExemptPaths.has(req.path)) return next();
128
- return globalJson(req, res, next);
129
- });
130
-
131
- // Health check. `sha` is the git commit this build was produced from
132
- // (see version.ts) so the deploy pipeline can confirm a staging/production
133
- // rollout matches the merged commit before smoke-testing it.
134
- app.get('/api/health', (_req, res) => {
135
- res.json({ status: 'ok', sha: GIT_SHA, timestamp: Date.now() });
136
- });
137
-
138
- // Overlay boot-time side effects (startup reconciles, periodic sweeps).
139
- await ext.onBoot?.(core);
140
-
141
- // Auth routes (unprotected — login endpoint must be accessible)
142
- app.use(
143
- '/api',
144
- createAuthRoutes(
145
- core.authService,
146
- core.authMiddleware,
147
- core.authProviders,
148
- core.config.loginPasswordEnabled,
149
- ),
150
- );
151
-
152
- // MCP routes must mount BEFORE every `app.use('/api', authMiddleware, …)`
153
- // below: Express 5 fires the outer JWT middleware before deciding whether
154
- // its router has a matching route, so a request to `POST /api/mcp` carrying
155
- // a connection-key (non-JWT) bearer would otherwise be 401'd by the first
156
- // protected mount before this router ever saw it.
157
- app.use('/api', createMcpRoutes(
158
- core.mcpService,
159
- core.externalApiKeyService,
160
- core.mcpAuthMiddleware,
161
- core.authMiddleware,
162
- core.usageMeter,
163
- ));
164
-
165
- // MCP OAuth 2.1 authorization server: /authorize, /token, /register,
166
- // /revoke + the /.well-known metadata documents (the SDK requires an
167
- // app-root mount for those paths). Un-authed by design — these endpoints
168
- // are hit by MCP clients with no credentials yet and by bare browser
169
- // navigations; the user attaches to the flow at the JWT/cookie-authed
170
- // consent routes below. Must sit before the prod SPA catch-all or the
171
- // .well-known documents would be swallowed by index.html.
172
- app.use(mcpAuthRouter({
173
- provider: core.mcpOAuthProvider,
174
- issuerUrl: new URL(core.config.publicBackendUrl),
175
- resourceServerUrl: new URL('/api/mcp', core.config.publicBackendUrl),
176
- scopesSupported: ['mcp'],
177
- resourceName: 'Bevel MCP',
178
- }));
179
-
180
- // Un-authed overlay surfaces (e.g. connector OAuth callbacks hit by a
181
- // provider's browser redirect with no Authorization header).
182
- ext.preAuth?.(app, core);
183
-
184
- // Secrets Vault OAuth callback — also hit by a provider browser redirect with
185
- // no Authorization header, so it mounts UN-AUTHED here, recovering the caller
186
- // from the signed `state`. The authed CRUD/start routes mount later.
187
- const secretsVaultRoutesDeps = {
188
- secretsVault: core.secretsVaultService,
189
- toolManualService: core.toolManualService,
190
- accessControl: core.accessControl,
191
- stateSecret: core.config.jwtSecret,
192
- publicBackendUrl: core.config.publicBackendUrl,
193
- publicFrontendUrl: core.config.publicFrontendUrl,
194
- };
195
- app.use('/api', createSecretsVaultPublicRoutes(secretsVaultRoutesDeps));
196
-
197
- // Unified tool surface — the ONE catalog, served as two UTCP manuals + each
198
- // tool's module-hosted endpoint, all behind the shared `toolAuth` (connection
199
- // key OR internal token). Each module registers its tool defs + hosts its
200
- // routes on this one router. Mount BEFORE the JWT `/api` mounts — connection-
201
- // key / internal-token bearers would be 401'd by the outer JWT middleware
202
- // otherwise. Manuals are added LAST so they snapshot the full catalog:
203
- // core registrations first, then the overlay's (`ext.tools`), then the
204
- // manual routes.
205
- const toolsRouter = express.Router();
206
- const ta = core.toolAuthMiddleware;
207
- const th = core.toolHandlerFactory;
208
- // Shared ontology-session boundary gate config, consumed by every tool
209
- // surface that touches the KB (file tools + graph tools). The gate's
210
- // blocking decision runs through the workflow hooks: core registers none
211
- // (tracking only); the enterprise root registers the ontology block on
212
- // `workflowService.hooks` before this server is built.
213
- const sessionOntologyGate = {
214
- service: core.sessionOntologyService,
215
- enabled: core.config.ontologySessionBlock,
216
- kbDirName: core.config.kbDirName,
217
- recoveryBotEmail: RECOVERY_BOT_EMAIL,
218
- hooks: core.workflowService.hooks,
219
- };
220
- registerWorkflowTools(core.toolRegistry, toolsRouter, ta, th);
221
- registerWorkspaceTools(core.toolRegistry, toolsRouter, ta, th, core.spillStore, core.accessControl, core.config.kbDirName, sessionOntologyGate, core.routineWritePolicy, core.sessionSink);
222
- registerSkillsTools(core.toolRegistry, toolsRouter, ta, th, core.skillService);
223
- registerToolManualsTools(core.toolRegistry, toolsRouter, ta, th, core.toolManualService, {
224
- accessControl: core.accessControl,
225
- // The vault satisfies the module's local VariableStatusPort — `list_tool_setup`
226
- // reports configuration booleans only; secret values never ride through tools.
227
- variableStatus: core.secretsVaultService,
228
- });
229
- // Overlay tool registrations (defs + module-hosted endpoints).
230
- ext.tools?.({
231
- registry: core.toolRegistry,
232
- router: toolsRouter,
233
- toolAuth: ta,
234
- toolHandler: th,
235
- sessionOntologyGate,
236
- core,
237
- });
238
- toolsRouter.use(createManualRoutes(
239
- core.toolRegistry,
240
- core.manualAuthMiddleware,
241
- async (userId) => (await core.authService.getUserById(userId))?.email,
242
- ));
243
- // The aggregated manual list (KB + the caller's accessible `.tool`s) + inline
244
- // `.tool` sub-manuals. Mounted on the tools router so they share `manualAuth`
245
- // and sit before the JWT `/api` mounts.
246
- toolsRouter.use(createToolManualsAgentRoutes(
247
- core.toolManualService,
248
- core.manualAuthMiddleware,
249
- async (userId) => (await core.authService.getUserById(userId))?.email,
250
- ));
251
- app.use('/api', toolsRouter);
252
-
253
- // Non-JWT overlay surfaces that sit between the tools router and the
254
- // JWT-protected `/api` routes (LLM proxy, embed, upload — see the phase
255
- // doc on ServerExtensions.postTools).
256
- ext.postTools?.(app, core);
257
-
258
- // Protected routes
259
- app.use('/api', core.authMiddleware, createWorkspaceRoutes(
260
- core.workspaceService,
261
- core.authService,
262
- core.workflowService,
263
- core.eventBus,
264
- core.accessControl,
265
- core.config.kbDirName,
266
- core.creatorAccess,
267
- ));
268
- // Workflow is the only branches / changes / change-request surface. The
269
- // former /git/*, /pr/*, /pr/:n/* routes are gone — every consumer goes
270
- // through /workflow/* now.
271
- app.use('/api', core.authMiddleware, createWorkflowRoutes(
272
- core.workflowService,
273
- core.workspaceService,
274
- core.authService,
275
- core.eventBus,
276
- ));
277
- // SSE event-bus surface. The route owns its own auth gating via the
278
- // injected middleware (which accepts both Bearer and the `bevel_token`
279
- // cookie — the cookie path is what makes EventSource work, since the
280
- // browser API can't set headers). Mounted as its own app.use so it
281
- // doesn't share an outer middleware chain with the JSON routes above.
282
- app.use('/api', createEventsRoutes(core.eventBus, core.authMiddleware));
283
- app.use('/api', core.authMiddleware, createDiffRoutes(
284
- core.diffService,
285
- core.authService,
286
- core.workflowService,
287
- core.accessControl,
288
- core.config.kbDirName,
289
- ));
290
- app.use('/api', core.authMiddleware, createAccessRoutes(
291
- core.accessControl,
292
- core.workspaceService,
293
- core.authService,
294
- core.workflowService,
295
- core.eventBus,
296
- core.db,
297
- core.config.kbDirName,
298
- ));
299
- app.use('/api', core.authMiddleware, createSkillsRoutes(core.skillService));
300
- // Admin-status resolver (CORE — see the note in admin-access.routes.ts;
301
- // the full admin router is an enterprise `ext.authed` extension).
302
- app.use('/api', core.authMiddleware, createAdminAccessRoutes(core.adminAccess));
303
- app.use('/api', core.authMiddleware, createToolManualsBrowserRoutes(core.toolManualService));
304
- app.use('/api', core.authMiddleware, createSecretsVaultRoutes(secretsVaultRoutesDeps));
305
- // The authed tail of the MCP OAuth flow: /connect calls these to describe
306
- // the pending authorization and, on Finish, to mint the one-time code. The
307
- // browser arrives via redirect with no Authorization header, so the JWT
308
- // middleware's bevel_token-cookie fallback is what identifies the user.
309
- app.use('/api', core.authMiddleware, createOAuthConsentRoutes({
310
- provider: core.mcpOAuthProvider,
311
- stateSecret: core.config.jwtSecret,
312
- }));
313
-
314
- // JWT-protected overlay routes.
315
- ext.authed?.(app, core);
316
-
317
- // In production, serve the frontend static build
318
- if (opts.staticDir) {
319
- const frontendDist = opts.staticDir;
320
- app.use(express.static(frontendDist));
321
- app.get('{*path}', (req, res) => {
322
- // Non-SPA namespaces must NOT resolve to index.html. In particular, an
323
- // OAuth well-known metadata URL the mcpAuthRouter doesn't serve (a variant
324
- // the client probes, e.g. the root protected-resource or a path-suffixed
325
- // authorization-server doc) would otherwise return `200 + HTML`, which an
326
- // MCP client parses as JSON → "expected object, received undefined" and
327
- // the whole connection fails. A clean 404 lets discovery fall through to
328
- // the variant we do serve. Same reasoning for stray `/api/*` gets.
329
- if (req.path.startsWith('/.well-known/') || req.path.startsWith('/api/')) {
330
- res.status(404).json({ error: 'Not found' });
331
- return;
332
- }
333
- res.sendFile(path.join(frontendDist, 'index.html'));
334
- });
335
- }
336
-
337
- return app;
338
- }
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import path from 'node:path';
4
+ import type { Router, RequestHandler } from 'express';
5
+ import { createAuthRoutes } from '../modules/auth/auth.routes.js';
6
+ import { createWorkspaceRoutes } from '../modules/workspace/workspace.routes.js';
7
+ import { createDiffRoutes } from '../modules/diff/diff.routes.js';
8
+ import { createWorkflowRoutes } from '../modules/workflow/workflow.routes.js';
9
+ import { createEventsRoutes } from '../modules/workflow/events.routes.js';
10
+ import { createAccessRoutes } from '../modules/access/access.routes.js';
11
+ import { createMcpRoutes } from '../modules/mcp/mcp.routes.js';
12
+ import { createOAuthConsentRoutes } from '../modules/mcp/oauth/oauth-consent.routes.js';
13
+ import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
14
+ import { createManualRoutes } from '../modules/tool-registry/manual.routes.js';
15
+ import {
16
+ createToolManualsAgentRoutes,
17
+ createToolManualsBrowserRoutes,
18
+ registerToolManualsTools,
19
+ } from '../modules/tool-manuals/index.js';
20
+ import { registerWorkflowTools } from '../modules/workflow/agent-tools/workflow.tools.js';
21
+ import { registerWorkspaceTools } from '../modules/workspace/workspace.tools.js';
22
+ import { RECOVERY_BOT_EMAIL } from '../modules/workflow/recovery-bot.js';
23
+ import { registerSkillsTools, createSkillsRoutes } from '../modules/skills/index.js';
24
+ import type { SessionOntologyGate } from '../modules/workspace/session-ontology.gate.js';
25
+ import {
26
+ createSecretsVaultRoutes,
27
+ createSecretsVaultPublicRoutes,
28
+ } from '../modules/secrets-vault/index.js';
29
+ import { createAdminAccessRoutes } from '../modules/admin/admin-access.routes.js';
30
+ import { createAccountRoutes } from '../modules/auth/account.routes.js';
31
+ import { GIT_SHA } from '../version.js';
32
+ import type { CoreServices } from './create-core-services.js';
33
+
34
+ type ExpressApp = ReturnType<typeof express>;
35
+
36
+ /**
37
+ * The context handed to {@link ServerExtensions.tools}: everything an overlay
38
+ * needs to register its own tool defs + endpoint routes on the unified tool
39
+ * surface, exactly like the core modules do.
40
+ */
41
+ export interface ToolSurfaceCtx {
42
+ registry: CoreServices['toolRegistry'];
43
+ router: Router;
44
+ toolAuth: RequestHandler;
45
+ toolHandler: CoreServices['toolHandlerFactory'];
46
+ /** Shared ontology-session boundary gate config (file tools + graph tools). */
47
+ sessionOntologyGate: SessionOntologyGate;
48
+ core: CoreServices;
49
+ }
50
+
51
+ /**
52
+ * The hook points an overlay (the enterprise app) uses to add its surfaces to
53
+ * the core server. Each hook maps to a fixed slot in the LOAD-BEARING mount
54
+ * order encoded in {@link createCoreServer} — the order comments there explain
55
+ * why each slot sits where it does (Express 5 fires outer middleware before
56
+ * route matching, so pre-JWT surfaces MUST mount before the JWT `/api` mounts).
57
+ */
58
+ export interface ServerExtensions {
59
+ /**
60
+ * Request paths the global 10 MB JSON body parser must skip because the
61
+ * overlay's own router installs a larger parser for them (body-parser
62
+ * ignores a second parse once the first has run — see the comment at the
63
+ * parser below).
64
+ */
65
+ jsonParserExemptPaths?: string[];
66
+ /**
67
+ * Boot-time side effects that belong to the server lifecycle (startup
68
+ * reconciles, periodic sweeps). Runs after health/cors/json are installed,
69
+ * before any routes mount.
70
+ */
71
+ onBoot?(core: CoreServices): Promise<void> | void;
72
+ /** Un-authed overlay surfaces (e.g. OAuth callbacks hit by provider redirects). */
73
+ preAuth?(app: ExpressApp, core: CoreServices): void;
74
+ /**
75
+ * Overlay tool registrations on the unified tool surface. Runs after the
76
+ * core registrations and BEFORE the manual routes mount, preserving the
77
+ * "manual snapshot stays complete" invariant.
78
+ */
79
+ tools?(ctx: ToolSurfaceCtx): void;
80
+ /**
81
+ * Non-JWT overlay surfaces that historically mount AFTER the tools router
82
+ * but before the JWT-protected `/api` routes (LLM proxy, embed, upload).
83
+ * A separate phase from `preAuth` purely to preserve today's exact order.
84
+ */
85
+ postTools?(app: ExpressApp, core: CoreServices): void;
86
+ /** JWT-protected overlay routes; mounted after every core authed route. */
87
+ authed?(app: ExpressApp, core: CoreServices): void;
88
+ }
89
+
90
+ export async function createCoreServer(
91
+ core: CoreServices,
92
+ ext: ServerExtensions = {},
93
+ opts: { staticDir?: string } = {},
94
+ ): Promise<ExpressApp> {
95
+ const app = express();
96
+
97
+ // Honor forwarded client addresses ONLY when the deployment declares its
98
+ // proxy topology (TRUST_PROXY = hop count or CIDR list — see CoreConfig).
99
+ // Without it, `req.ip` is the socket peer: safe when directly exposed, but
100
+ // behind a proxy every client would share the proxy's address (pooling the
101
+ // per-IP login rate limit). A number is the hop count; anything else is
102
+ // passed through as Express's address/CIDR list form.
103
+ if (core.config.trustProxy) {
104
+ const raw = core.config.trustProxy;
105
+ app.set('trust proxy', /^\d+$/.test(raw) ? Number(raw) : raw);
106
+ }
107
+
108
+ // `credentials: true` so EventSource (`withCredentials: true`) can carry
109
+ // the `bevel_token` cookie set at login — the only auth path the
110
+ // EventSource API supports. `origin: true` reflects the request origin
111
+ // back in `Access-Control-Allow-Origin`, which the spec requires when
112
+ // credentials are in play (the wildcard `*` is refused alongside
113
+ // credentials). Cookie itself is `SameSite=Lax`, so a hostile cross-site
114
+ // page still can't read events on the user's behalf.
115
+ //
116
+ // `exposedHeaders` lets a BROWSER-based MCP client (e.g. MCP Inspector) read
117
+ // the Streamable-HTTP session header off the `initialize` response — custom
118
+ // response headers are hidden from browser JS unless exposed, so without this
119
+ // the client can't send `Mcp-Session-Id` back and every follow-up 400s with
120
+ // "session id does not match any active session". `WWW-Authenticate` is
121
+ // exposed so a browser client can read the 401 challenge and start the OAuth
122
+ // discovery flow. (Native clients like Claude Code aren't subject to CORS.)
123
+ app.use(
124
+ cors({
125
+ origin: true,
126
+ credentials: true,
127
+ exposedHeaders: ['Mcp-Session-Id', 'Mcp-Protocol-Version', 'WWW-Authenticate'],
128
+ }),
129
+ );
130
+ // Global JSON body parser. Some overlay routes carry a whole document dump
131
+ // (e.g. the onboarding import), so their routers install their own larger
132
+ // parser — skip the global one for those paths (body-parser ignores a second
133
+ // parse once the first has run, so without this the 10 MB global limit would
134
+ // shadow the route's larger limit and 413 a large-but-valid upload before it
135
+ // ever reaches the route).
136
+ const jsonExemptPaths = new Set(ext.jsonParserExemptPaths ?? []);
137
+ const globalJson = express.json({ limit: '10mb' });
138
+ app.use((req, res, next) => {
139
+ if (jsonExemptPaths.has(req.path)) return next();
140
+ return globalJson(req, res, next);
141
+ });
142
+
143
+ // Health check. `sha` is the git commit this build was produced from
144
+ // (see version.ts) so the deploy pipeline can confirm a staging/production
145
+ // rollout matches the merged commit before smoke-testing it.
146
+ app.get('/api/health', (_req, res) => {
147
+ res.json({ status: 'ok', sha: GIT_SHA, timestamp: Date.now() });
148
+ });
149
+
150
+ // Overlay boot-time side effects (startup reconciles, periodic sweeps).
151
+ await ext.onBoot?.(core);
152
+
153
+ // Auth routes (unprotected — login endpoint must be accessible)
154
+ app.use(
155
+ '/api',
156
+ createAuthRoutes(
157
+ core.authService,
158
+ core.authMiddleware,
159
+ core.authProviders,
160
+ core.config.loginPasswordEnabled,
161
+ ),
162
+ );
163
+
164
+ // MCP routes must mount BEFORE every `app.use('/api', authMiddleware, …)`
165
+ // below: Express 5 fires the outer JWT middleware before deciding whether
166
+ // its router has a matching route, so a request to `POST /api/mcp` carrying
167
+ // a connection-key (non-JWT) bearer would otherwise be 401'd by the first
168
+ // protected mount before this router ever saw it.
169
+ app.use('/api', createMcpRoutes(
170
+ core.mcpService,
171
+ core.externalApiKeyService,
172
+ core.mcpAuthMiddleware,
173
+ core.authMiddleware,
174
+ core.usageMeter,
175
+ ));
176
+
177
+ // MCP OAuth 2.1 authorization server: /authorize, /token, /register,
178
+ // /revoke + the /.well-known metadata documents (the SDK requires an
179
+ // app-root mount for those paths). Un-authed by design — these endpoints
180
+ // are hit by MCP clients with no credentials yet and by bare browser
181
+ // navigations; the user attaches to the flow at the JWT/cookie-authed
182
+ // consent routes below. Must sit before the prod SPA catch-all or the
183
+ // .well-known documents would be swallowed by index.html.
184
+ app.use(mcpAuthRouter({
185
+ provider: core.mcpOAuthProvider,
186
+ issuerUrl: new URL(core.config.publicBackendUrl),
187
+ resourceServerUrl: new URL('/api/mcp', core.config.publicBackendUrl),
188
+ scopesSupported: ['mcp'],
189
+ resourceName: 'Bevel MCP',
190
+ }));
191
+
192
+ // Un-authed overlay surfaces (e.g. connector OAuth callbacks hit by a
193
+ // provider's browser redirect with no Authorization header).
194
+ ext.preAuth?.(app, core);
195
+
196
+ // Secrets Vault OAuth callback — also hit by a provider browser redirect with
197
+ // no Authorization header, so it mounts UN-AUTHED here, recovering the caller
198
+ // from the signed `state`. The authed CRUD/start routes mount later.
199
+ const secretsVaultRoutesDeps = {
200
+ secretsVault: core.secretsVaultService,
201
+ toolManualService: core.toolManualService,
202
+ accessControl: core.accessControl,
203
+ stateSecret: core.config.jwtSecret,
204
+ publicBackendUrl: core.config.publicBackendUrl,
205
+ publicFrontendUrl: core.config.publicFrontendUrl,
206
+ };
207
+ app.use('/api', createSecretsVaultPublicRoutes(secretsVaultRoutesDeps));
208
+
209
+ // Unified tool surface — the ONE catalog, served as two UTCP manuals + each
210
+ // tool's module-hosted endpoint, all behind the shared `toolAuth` (connection
211
+ // key OR internal token). Each module registers its tool defs + hosts its
212
+ // routes on this one router. Mount BEFORE the JWT `/api` mounts — connection-
213
+ // key / internal-token bearers would be 401'd by the outer JWT middleware
214
+ // otherwise. Manuals are added LAST so they snapshot the full catalog:
215
+ // core registrations first, then the overlay's (`ext.tools`), then the
216
+ // manual routes.
217
+ const toolsRouter = express.Router();
218
+ const ta = core.toolAuthMiddleware;
219
+ const th = core.toolHandlerFactory;
220
+ // Shared ontology-session boundary gate config, consumed by every tool
221
+ // surface that touches the KB (file tools + graph tools). The gate's
222
+ // blocking decision runs through the workflow hooks: core registers none
223
+ // (tracking only); the enterprise root registers the ontology block on
224
+ // `workflowService.hooks` before this server is built.
225
+ const sessionOntologyGate = {
226
+ service: core.sessionOntologyService,
227
+ enabled: core.config.ontologySessionBlock,
228
+ kbDirName: core.config.kbDirName,
229
+ recoveryBotEmail: RECOVERY_BOT_EMAIL,
230
+ hooks: core.workflowService.hooks,
231
+ };
232
+ registerWorkflowTools(core.toolRegistry, toolsRouter, ta, th);
233
+ registerWorkspaceTools(core.toolRegistry, toolsRouter, ta, th, core.spillStore, core.accessControl, core.config.kbDirName, sessionOntologyGate, core.routineWritePolicy, core.sessionSink);
234
+ registerSkillsTools(core.toolRegistry, toolsRouter, ta, th, core.skillService);
235
+ registerToolManualsTools(core.toolRegistry, toolsRouter, ta, th, core.toolManualService, {
236
+ accessControl: core.accessControl,
237
+ // The vault satisfies the module's local VariableStatusPort — `list_tool_setup`
238
+ // reports configuration booleans only; secret values never ride through tools.
239
+ variableStatus: core.secretsVaultService,
240
+ });
241
+ // Overlay tool registrations (defs + module-hosted endpoints).
242
+ ext.tools?.({
243
+ registry: core.toolRegistry,
244
+ router: toolsRouter,
245
+ toolAuth: ta,
246
+ toolHandler: th,
247
+ sessionOntologyGate,
248
+ core,
249
+ });
250
+ toolsRouter.use(createManualRoutes(
251
+ core.toolRegistry,
252
+ core.manualAuthMiddleware,
253
+ async (userId) => (await core.authService.getUserById(userId))?.email,
254
+ ));
255
+ // The aggregated manual list (KB + the caller's accessible `.tool`s) + inline
256
+ // `.tool` sub-manuals. Mounted on the tools router so they share `manualAuth`
257
+ // and sit before the JWT `/api` mounts.
258
+ toolsRouter.use(createToolManualsAgentRoutes(
259
+ core.toolManualService,
260
+ core.manualAuthMiddleware,
261
+ async (userId) => (await core.authService.getUserById(userId))?.email,
262
+ ));
263
+ app.use('/api', toolsRouter);
264
+
265
+ // Non-JWT overlay surfaces that sit between the tools router and the
266
+ // JWT-protected `/api` routes (LLM proxy, embed, upload — see the phase
267
+ // doc on ServerExtensions.postTools).
268
+ ext.postTools?.(app, core);
269
+
270
+ // Protected routes
271
+ app.use('/api', core.authMiddleware, createWorkspaceRoutes(
272
+ core.workspaceService,
273
+ core.authService,
274
+ core.workflowService,
275
+ core.eventBus,
276
+ core.accessControl,
277
+ core.config.kbDirName,
278
+ core.creatorAccess,
279
+ ));
280
+ // Workflow is the only branches / changes / change-request surface. The
281
+ // former /git/*, /pr/*, /pr/:n/* routes are gone — every consumer goes
282
+ // through /workflow/* now.
283
+ app.use('/api', core.authMiddleware, createWorkflowRoutes(
284
+ core.workflowService,
285
+ core.workspaceService,
286
+ core.authService,
287
+ core.eventBus,
288
+ ));
289
+ // SSE event-bus surface. The route owns its own auth gating via the
290
+ // injected middleware (which accepts both Bearer and the `bevel_token`
291
+ // cookie — the cookie path is what makes EventSource work, since the
292
+ // browser API can't set headers). Mounted as its own app.use so it
293
+ // doesn't share an outer middleware chain with the JSON routes above.
294
+ app.use('/api', createEventsRoutes(core.eventBus, core.authMiddleware));
295
+ app.use('/api', core.authMiddleware, createDiffRoutes(
296
+ core.diffService,
297
+ core.authService,
298
+ core.workflowService,
299
+ core.accessControl,
300
+ core.config.kbDirName,
301
+ ));
302
+ app.use('/api', core.authMiddleware, createAccessRoutes(
303
+ core.accessControl,
304
+ core.workspaceService,
305
+ core.authService,
306
+ core.workflowService,
307
+ core.eventBus,
308
+ core.db,
309
+ core.config.kbDirName,
310
+ ));
311
+ app.use('/api', core.authMiddleware, createSkillsRoutes(core.skillService));
312
+ // Admin-status resolver (CORE — see the note in admin-access.routes.ts;
313
+ // the full admin router is an enterprise `ext.authed` extension).
314
+ app.use('/api', core.authMiddleware, createAdminAccessRoutes(core.adminAccess));
315
+ // Account management (list/create password accounts) — admin-gated inside.
316
+ app.use('/api', core.authMiddleware, createAccountRoutes(core.authService, core.adminAccess));
317
+ app.use('/api', core.authMiddleware, createToolManualsBrowserRoutes(core.toolManualService));
318
+ app.use('/api', core.authMiddleware, createSecretsVaultRoutes(secretsVaultRoutesDeps));
319
+ // The authed tail of the MCP OAuth flow: /connect calls these to describe
320
+ // the pending authorization and, on Finish, to mint the one-time code. The
321
+ // browser arrives via redirect with no Authorization header, so the JWT
322
+ // middleware's bevel_token-cookie fallback is what identifies the user.
323
+ app.use('/api', core.authMiddleware, createOAuthConsentRoutes({
324
+ provider: core.mcpOAuthProvider,
325
+ stateSecret: core.config.jwtSecret,
326
+ }));
327
+
328
+ // JWT-protected overlay routes.
329
+ ext.authed?.(app, core);
330
+
331
+ // In production, serve the frontend static build
332
+ if (opts.staticDir) {
333
+ const frontendDist = opts.staticDir;
334
+ app.use(express.static(frontendDist));
335
+ app.get('{*path}', (req, res) => {
336
+ // Non-SPA namespaces must NOT resolve to index.html. In particular, an
337
+ // OAuth well-known metadata URL the mcpAuthRouter doesn't serve (a variant
338
+ // the client probes, e.g. the root protected-resource or a path-suffixed
339
+ // authorization-server doc) would otherwise return `200 + HTML`, which an
340
+ // MCP client parses as JSON → "expected object, received undefined" and
341
+ // the whole connection fails. A clean 404 lets discovery fall through to
342
+ // the variant we do serve. Same reasoning for stray `/api/*` gets.
343
+ if (req.path.startsWith('/.well-known/') || req.path.startsWith('/api/')) {
344
+ res.status(404).json({ error: 'Not found' });
345
+ return;
346
+ }
347
+ res.sendFile(path.join(frontendDist, 'index.html'));
348
+ });
349
+ }
350
+
351
+ return app;
352
+ }