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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +12 -1
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +22 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +34 -1
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +4 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/modules/access/access-control.interface.d.ts +54 -28
  13. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  14. package/dist/modules/access/access-control.service.d.ts +129 -14
  15. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  16. package/dist/modules/access/access-control.service.js +435 -66
  17. package/dist/modules/access/access-control.service.js.map +1 -1
  18. package/dist/modules/access/access-declarations.d.ts.map +1 -1
  19. package/dist/modules/access/access-declarations.js +5 -3
  20. package/dist/modules/access/access-declarations.js.map +1 -1
  21. package/dist/modules/access/access-mutation.service.d.ts +39 -6
  22. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  23. package/dist/modules/access/access-mutation.service.js +78 -18
  24. package/dist/modules/access/access-mutation.service.js.map +1 -1
  25. package/dist/modules/access/access-splice.d.ts +31 -4
  26. package/dist/modules/access/access-splice.d.ts.map +1 -1
  27. package/dist/modules/access/access-splice.js +40 -16
  28. package/dist/modules/access/access-splice.js.map +1 -1
  29. package/dist/modules/access/access.routes.d.ts.map +1 -1
  30. package/dist/modules/access/access.routes.js +204 -82
  31. package/dist/modules/access/access.routes.js.map +1 -1
  32. package/dist/modules/access/admin-locked-commit.d.ts +134 -0
  33. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
  34. package/dist/modules/access/admin-locked-commit.js +277 -0
  35. package/dist/modules/access/admin-locked-commit.js.map +1 -0
  36. package/dist/modules/access/admin-route-helpers.d.ts +32 -0
  37. package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
  38. package/dist/modules/access/admin-route-helpers.js +44 -0
  39. package/dist/modules/access/admin-route-helpers.js.map +1 -0
  40. package/dist/modules/access/capability-registry.d.ts +41 -0
  41. package/dist/modules/access/capability-registry.d.ts.map +1 -0
  42. package/dist/modules/access/capability-registry.js +46 -0
  43. package/dist/modules/access/capability-registry.js.map +1 -0
  44. package/dist/modules/access/directory-sync-bot.d.ts +13 -0
  45. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
  46. package/dist/modules/access/directory-sync-bot.js +64 -0
  47. package/dist/modules/access/directory-sync-bot.js.map +1 -0
  48. package/dist/modules/access/group-files.d.ts +83 -0
  49. package/dist/modules/access/group-files.d.ts.map +1 -0
  50. package/dist/modules/access/group-files.js +167 -0
  51. package/dist/modules/access/group-files.js.map +1 -0
  52. package/dist/modules/access/groups-admin.routes.d.ts +19 -0
  53. package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
  54. package/dist/modules/access/groups-admin.routes.js +98 -0
  55. package/dist/modules/access/groups-admin.routes.js.map +1 -0
  56. package/dist/modules/access/groups-admin.service.d.ts +166 -0
  57. package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
  58. package/dist/modules/access/groups-admin.service.js +442 -0
  59. package/dist/modules/access/groups-admin.service.js.map +1 -0
  60. package/dist/modules/access/groups-edit.d.ts +58 -0
  61. package/dist/modules/access/groups-edit.d.ts.map +1 -0
  62. package/dist/modules/access/groups-edit.js +162 -0
  63. package/dist/modules/access/groups-edit.js.map +1 -0
  64. package/dist/modules/access/reference-scan.d.ts +141 -0
  65. package/dist/modules/access/reference-scan.d.ts.map +1 -0
  66. package/dist/modules/access/reference-scan.js +440 -0
  67. package/dist/modules/access/reference-scan.js.map +1 -0
  68. package/dist/modules/access/roles-admin.service.d.ts +88 -119
  69. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  70. package/dist/modules/access/roles-admin.service.js +230 -384
  71. package/dist/modules/access/roles-admin.service.js.map +1 -1
  72. package/dist/modules/access/roles-edit.d.ts +51 -25
  73. package/dist/modules/access/roles-edit.d.ts.map +1 -1
  74. package/dist/modules/access/roles-edit.js +133 -59
  75. package/dist/modules/access/roles-edit.js.map +1 -1
  76. package/dist/modules/access/synced-groups-committer.d.ts +28 -0
  77. package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
  78. package/dist/modules/access/synced-groups-committer.js +139 -0
  79. package/dist/modules/access/synced-groups-committer.js.map +1 -0
  80. package/dist/modules/access/synced-groups-writer.d.ts +78 -0
  81. package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
  82. package/dist/modules/access/synced-groups-writer.js +219 -0
  83. package/dist/modules/access/synced-groups-writer.js.map +1 -0
  84. package/dist/modules/database/core-schema.d.ts +17 -0
  85. package/dist/modules/database/core-schema.d.ts.map +1 -1
  86. package/dist/modules/database/core-schema.js +9 -0
  87. package/dist/modules/database/core-schema.js.map +1 -1
  88. package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
  89. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  90. package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
  91. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  92. package/dist/modules/mcp/mcp.routes.d.ts +9 -3
  93. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  94. package/dist/modules/mcp/mcp.routes.js +126 -2
  95. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  96. package/dist/modules/mcp/mcp.service.d.ts +14 -0
  97. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  98. package/dist/modules/mcp/mcp.service.js +6 -1
  99. package/dist/modules/mcp/mcp.service.js.map +1 -1
  100. package/dist/modules/workflow/file-lock.service.d.ts +11 -1
  101. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  102. package/dist/modules/workflow/file-lock.service.js +15 -1
  103. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  104. package/dist/modules/workflow/git/git.service.d.ts +34 -11
  105. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  106. package/dist/modules/workflow/git/git.service.js +165 -37
  107. package/dist/modules/workflow/git/git.service.js.map +1 -1
  108. package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
  109. package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
  110. package/dist/modules/workflow/locking-filesystem.js +181 -28
  111. package/dist/modules/workflow/locking-filesystem.js.map +1 -1
  112. package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
  113. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  114. package/dist/modules/workflow/pending-commits.service.js +19 -1
  115. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  116. package/dist/modules/workflow/workflow.service.d.ts +17 -2
  117. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  118. package/dist/modules/workflow/workflow.service.js +113 -19
  119. package/dist/modules/workflow/workflow.service.js.map +1 -1
  120. package/kb-template/AGENTS.md +4 -1
  121. package/migrations/0004_file_lock_mode.sql +2 -0
  122. package/migrations/meta/0004_snapshot.json +1564 -0
  123. package/migrations/meta/_journal.json +7 -0
  124. package/package.json +3 -3
  125. package/src/core/create-core-server.ts +13 -0
  126. package/src/core/create-core-services.ts +68 -0
  127. package/src/index.ts +10 -0
  128. package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
  129. package/src/modules/access/__tests__/access-groups.test.ts +427 -0
  130. package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
  131. package/src/modules/access/__tests__/access-splice.test.ts +65 -0
  132. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
  133. package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
  134. package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
  135. package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
  136. package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
  137. package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
  138. package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
  139. package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
  140. package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
  141. package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
  142. package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
  143. package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
  144. package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
  145. package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
  146. package/src/modules/access/access-control.interface.ts +66 -32
  147. package/src/modules/access/access-control.service.ts +536 -73
  148. package/src/modules/access/access-declarations.ts +5 -2
  149. package/src/modules/access/access-mutation.service.ts +88 -14
  150. package/src/modules/access/access-splice.ts +55 -17
  151. package/src/modules/access/access.routes.ts +227 -93
  152. package/src/modules/access/admin-locked-commit.ts +331 -0
  153. package/src/modules/access/admin-route-helpers.ts +55 -0
  154. package/src/modules/access/capability-registry.ts +74 -0
  155. package/src/modules/access/directory-sync-bot.ts +76 -0
  156. package/src/modules/access/group-files.ts +212 -0
  157. package/src/modules/access/groups-admin.routes.ts +113 -0
  158. package/src/modules/access/groups-admin.service.ts +551 -0
  159. package/src/modules/access/groups-edit.ts +187 -0
  160. package/src/modules/access/reference-scan.ts +513 -0
  161. package/src/modules/access/roles-admin.service.ts +290 -418
  162. package/src/modules/access/roles-edit.ts +134 -61
  163. package/src/modules/access/synced-groups-committer.ts +177 -0
  164. package/src/modules/access/synced-groups-writer.ts +303 -0
  165. package/src/modules/database/core-schema.ts +9 -0
  166. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
  167. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
  168. package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
  169. package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
  170. package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
  171. package/src/modules/mcp/mcp.routes.ts +137 -2
  172. package/src/modules/mcp/mcp.service.ts +6 -1
  173. package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
  174. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
  175. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
  176. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
  177. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
  178. package/src/modules/workflow/file-lock.service.ts +15 -0
  179. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
  180. package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
  181. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
  182. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
  183. package/src/modules/workflow/git/git.service.ts +174 -35
  184. package/src/modules/workflow/locking-filesystem.ts +188 -26
  185. package/src/modules/workflow/pending-commits.service.ts +27 -1
  186. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
  187. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
  188. package/src/modules/workflow/workflow.service.ts +140 -20
@@ -0,0 +1,301 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import type { RequestHandler } from 'express';
3
+ import express from 'express';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
6
+ import { createMcpRoutes } from '../mcp.routes.js';
7
+ import { MCP_LOOPBACK_TOKEN_TTL_MS } from '../mcp.service.js';
8
+ import { InternalTokenService } from '../../tool-auth/internal-token.service.js';
9
+ import { createTokenVerifier } from '../../tool-auth/tool-auth.middleware.js';
10
+ import type { IExternalApiKeyService } from '../../tool-auth/external-api-key.interface.js';
11
+ import type { BevelOAuthProvider } from '../oauth/bevel-oauth-provider.js';
12
+
13
+ /**
14
+ * Coverage for POST /api/mcp/local-token — the OAuth-access-token → internal
15
+ * loopback-token exchange the LOCAL MCP server uses to reach the
16
+ * keys+internal-tokens-only `/api/agent/*` surface. Locks down:
17
+ *
18
+ * - a verified OAuth token mints an internal token that the tool-auth
19
+ * verifier resolves to the SAME identity createSession's loopback bearer
20
+ * gets (right userId, externalProxy → source 'external'), with the shared
21
+ * MCP_LOOPBACK_TOKEN_TTL_MS lifetime;
22
+ * - an expired/revoked OAuth token re-challenges (401 + resource_metadata),
23
+ * mirroring McpAuthMiddleware's OAuth branch — including a grant the
24
+ * provider verifies but reports as out of lifetime, which must never
25
+ * become a 200 carrying a dead token;
26
+ * - every other credential shape (connection key, JWT) is 403 — those need
27
+ * no exchange, so accepting them would mint a second credential from a
28
+ * first;
29
+ * - missing/garbage auth is 401 with the discovery challenge.
30
+ */
31
+
32
+ const RESOURCE_METADATA_URL = 'https://bevel.example/.well-known/oauth-protected-resource/api/mcp';
33
+
34
+ // Stand-in for routes this suite never exercises.
35
+ const fakeAuth: RequestHandler = (req, _res, next) => {
36
+ req.userId = 'user-A';
37
+ next();
38
+ };
39
+
40
+ let httpServer: HttpServer | undefined;
41
+
42
+ afterEach(async () => {
43
+ if (httpServer) {
44
+ await new Promise<void>((resolve) => httpServer!.close(() => resolve()));
45
+ httpServer = undefined;
46
+ }
47
+ vi.restoreAllMocks();
48
+ });
49
+
50
+ function makeOAuthProvider(
51
+ verify?: (t: string) => Promise<{ extra?: Record<string, unknown>; expiresAt?: number }>,
52
+ ) {
53
+ return {
54
+ looksLikeAccessToken: (t: string) => typeof t === 'string' && t.startsWith('bevel-mcp_'),
55
+ verifyAccessToken: vi.fn(
56
+ verify ??
57
+ (async () => {
58
+ throw new InvalidTokenError('unknown token');
59
+ }),
60
+ ),
61
+ } as unknown as BevelOAuthProvider;
62
+ }
63
+
64
+ async function mount(oauth: BevelOAuthProvider): Promise<{
65
+ baseUrl: string;
66
+ internalTokens: InternalTokenService;
67
+ }> {
68
+ const internalTokens = new InternalTokenService({ secret: 'test-secret' });
69
+ const externalApiKeyService = {
70
+ looksLikeExternalApiKey: (t: string) => typeof t === 'string' && t.startsWith('bevel_'),
71
+ } as unknown as IExternalApiKeyService;
72
+ // createMcpRoutes only touches mcpService.onSessionEvicted at construction.
73
+ const mcpService = { onSessionEvicted: () => {} } as never;
74
+ const stub = {} as never;
75
+
76
+ const app = express();
77
+ app.use(express.json());
78
+ app.use(
79
+ '/api',
80
+ createMcpRoutes(
81
+ mcpService,
82
+ externalApiKeyService,
83
+ fakeAuth,
84
+ fakeAuth,
85
+ stub,
86
+ internalTokens,
87
+ oauth,
88
+ RESOURCE_METADATA_URL,
89
+ ),
90
+ );
91
+
92
+ httpServer = await new Promise<HttpServer>((resolve) => {
93
+ const s = app.listen(0, () => resolve(s));
94
+ });
95
+ const port = (httpServer.address() as { port: number }).port;
96
+ return { baseUrl: `http://127.0.0.1:${port}`, internalTokens };
97
+ }
98
+
99
+ async function exchange(baseUrl: string, authorization?: string): Promise<Response> {
100
+ return fetch(`${baseUrl}/api/mcp/local-token`, {
101
+ method: 'POST',
102
+ headers: authorization ? { Authorization: authorization } : {},
103
+ });
104
+ }
105
+
106
+ describe('POST /mcp/local-token', () => {
107
+ it('exchanges a valid OAuth access token for an internal token with the loopback identity + TTL', async () => {
108
+ const oauth = makeOAuthProvider(async () => ({
109
+ extra: { userId: 'user-5', userEmail: 'eve@example.com' },
110
+ // A grant with plenty of life left: the loopback constant is the binding cap.
111
+ expiresAt: Math.floor(Date.now() / 1000) + 24 * 60 * 60,
112
+ }));
113
+ const { baseUrl, internalTokens } = await mount(oauth);
114
+
115
+ const res = await exchange(baseUrl, 'Bearer bevel-mcp_valid123');
116
+
117
+ expect(res.status).toBe(200);
118
+ const body = (await res.json()) as { token: string; expiresInMs: number };
119
+ // Same TTL constant createSession's loopback bearer uses — the grant
120
+ // above outlives it, so the constant is the cap that binds.
121
+ expect(body.expiresInMs).toBe(MCP_LOOPBACK_TOKEN_TTL_MS);
122
+ // The minted token verifies to the resolved user with the externalProxy
123
+ // flag — identical shape to the hosted session's loopback bearer.
124
+ expect(internalTokens.verify(body.token)).toEqual({ userId: 'user-5', externalProxy: true });
125
+ // …and the tool-auth verifier resolves it to source 'external', exactly
126
+ // how /api/agent/* will treat the local server.
127
+ const verify = createTokenVerifier(
128
+ { looksLikeExternalApiKey: () => false } as unknown as IExternalApiKeyService,
129
+ internalTokens,
130
+ );
131
+ await expect(verify(body.token)).resolves.toEqual({
132
+ ok: true,
133
+ auth: { source: 'external', userId: 'user-5', scope: 'write' },
134
+ });
135
+ expect((oauth as any).verifyAccessToken).toHaveBeenCalledWith('bevel-mcp_valid123');
136
+ });
137
+
138
+ /**
139
+ * The binding that keeps the exchange from OUTLIVING its grant: an access
140
+ * token with less life left than the loopback constant caps the minted
141
+ * token's TTL at that remainder — otherwise a nearly-expired OAuth grant
142
+ * would buy five more hours of internal-token access.
143
+ */
144
+ it('caps expiresInMs at the access token\'s remaining lifetime when that is shorter', async () => {
145
+ const remainingSeconds = 90;
146
+ const oauth = makeOAuthProvider(async () => ({
147
+ extra: { userId: 'user-5' },
148
+ expiresAt: Math.floor(Date.now() / 1000) + remainingSeconds,
149
+ }));
150
+ const { baseUrl, internalTokens } = await mount(oauth);
151
+
152
+ const res = await exchange(baseUrl, 'Bearer bevel-mcp_shortlived');
153
+
154
+ expect(res.status).toBe(200);
155
+ const body = (await res.json()) as { token: string; expiresInMs: number };
156
+ // The returned number is the ACTUAL lifetime: at most the remainder,
157
+ // and nowhere near the 5h constant. (A tolerance below, for the
158
+ // seconds-granularity of expiresAt and the time the request takes.)
159
+ expect(body.expiresInMs).toBeLessThanOrEqual(remainingSeconds * 1000);
160
+ expect(body.expiresInMs).toBeGreaterThan((remainingSeconds - 10) * 1000);
161
+ expect(internalTokens.verify(body.token)).toEqual({ userId: 'user-5', externalProxy: true });
162
+ });
163
+
164
+ /**
165
+ * A provider that reports no expiry (the AuthInfo field is optional) falls
166
+ * back to the constant alone — absence must not read as "expires now".
167
+ */
168
+ it('falls back to the loopback constant when the provider reports no expiresAt', async () => {
169
+ const oauth = makeOAuthProvider(async () => ({
170
+ extra: { userId: 'user-5' },
171
+ }));
172
+ const { baseUrl } = await mount(oauth);
173
+
174
+ const res = await exchange(baseUrl, 'Bearer bevel-mcp_noexpiry');
175
+
176
+ expect(res.status).toBe(200);
177
+ const body = (await res.json()) as { expiresInMs: number };
178
+ expect(body.expiresInMs).toBe(MCP_LOOPBACK_TOKEN_TTL_MS);
179
+ });
180
+
181
+ /**
182
+ * The other end of the lifetime binding: a grant the provider verifies but
183
+ * reports as ALREADY out of lifetime must be a 401, not a 200 carrying a
184
+ * token that is dead on arrival — the caller would read that as success and
185
+ * fail somewhere far from the cause.
186
+ */
187
+ it('401s — never 200 with a dead token — when the verified grant has no remaining lifetime', async () => {
188
+ const oauth = makeOAuthProvider(async () => ({
189
+ extra: { userId: 'user-5' },
190
+ expiresAt: Math.floor(Date.now() / 1000) - 60,
191
+ }));
192
+ const { baseUrl } = await mount(oauth);
193
+
194
+ const res = await exchange(baseUrl, 'Bearer bevel-mcp_outlived');
195
+
196
+ expect(res.status).toBe(401);
197
+ expect(res.headers.get('www-authenticate')).toContain(
198
+ `resource_metadata="${RESOURCE_METADATA_URL}"`,
199
+ );
200
+ await expect(res.json()).resolves.toEqual(
201
+ expect.objectContaining({ error: expect.stringMatching(/invalid|expired|revoked/i) }),
202
+ );
203
+ });
204
+
205
+ it('401s on a garbage (non-finite) expiresAt rather than minting with a NaN TTL', async () => {
206
+ const oauth = makeOAuthProvider(async () => ({
207
+ extra: { userId: 'user-5' },
208
+ expiresAt: Number.NaN,
209
+ }));
210
+ const { baseUrl } = await mount(oauth);
211
+
212
+ const res = await exchange(baseUrl, 'Bearer bevel-mcp_garbage-expiry');
213
+
214
+ expect(res.status).toBe(401);
215
+ expect(res.headers.get('www-authenticate')).toContain('resource_metadata=');
216
+ });
217
+
218
+ it('401s with the resource_metadata challenge on an expired/revoked OAuth token', async () => {
219
+ const { baseUrl } = await mount(makeOAuthProvider()); // default verify throws InvalidTokenError
220
+
221
+ const res = await exchange(baseUrl, 'Bearer bevel-mcp_expired');
222
+
223
+ expect(res.status).toBe(401);
224
+ expect(res.headers.get('www-authenticate')).toContain(
225
+ `resource_metadata="${RESOURCE_METADATA_URL}"`,
226
+ );
227
+ await expect(res.json()).resolves.toEqual(
228
+ expect.objectContaining({ error: expect.stringMatching(/invalid|expired|revoked/i) }),
229
+ );
230
+ });
231
+
232
+ it('500s — not 401s — when OAuth verification fails on a backend error', async () => {
233
+ const { baseUrl } = await mount(
234
+ makeOAuthProvider(async () => {
235
+ throw new Error('db down');
236
+ }),
237
+ );
238
+ const err = vi.spyOn(console, 'error').mockImplementation(() => {});
239
+
240
+ const res = await exchange(baseUrl, 'Bearer bevel-mcp_token');
241
+
242
+ expect(res.status).toBe(500);
243
+ err.mockRestore();
244
+ });
245
+
246
+ it('403s a connection key — a key holder needs no exchange', async () => {
247
+ const { baseUrl } = await mount(makeOAuthProvider());
248
+
249
+ const res = await exchange(baseUrl, 'Bearer bevel_connectionkey');
250
+
251
+ expect(res.status).toBe(403);
252
+ await expect(res.json()).resolves.toEqual(
253
+ expect.objectContaining({ error: expect.stringMatching(/OAuth access tokens only/i) }),
254
+ );
255
+ });
256
+
257
+ it('403s a JWT', async () => {
258
+ const { baseUrl } = await mount(makeOAuthProvider());
259
+
260
+ const res = await exchange(baseUrl, 'Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig');
261
+
262
+ expect(res.status).toBe(403);
263
+ await expect(res.json()).resolves.toEqual(
264
+ expect.objectContaining({ error: expect.stringMatching(/OAuth access tokens only/i) }),
265
+ );
266
+ });
267
+
268
+ it('403s an internal token — it IS the exchange output, never its input', async () => {
269
+ const { baseUrl, internalTokens } = await mount(makeOAuthProvider());
270
+
271
+ const res = await exchange(baseUrl, `Bearer ${internalTokens.mint({ userId: 'user-A' })}`);
272
+
273
+ expect(res.status).toBe(403);
274
+ });
275
+
276
+ it('401s with the challenge when the Authorization header is missing', async () => {
277
+ const { baseUrl } = await mount(makeOAuthProvider());
278
+
279
+ const res = await exchange(baseUrl);
280
+
281
+ expect(res.status).toBe(401);
282
+ expect(res.headers.get('www-authenticate')).toContain('resource_metadata=');
283
+ });
284
+
285
+ it('401s with the challenge on a garbage bearer token', async () => {
286
+ const { baseUrl } = await mount(makeOAuthProvider());
287
+
288
+ const res = await exchange(baseUrl, 'Bearer total-garbage');
289
+
290
+ expect(res.status).toBe(401);
291
+ expect(res.headers.get('www-authenticate')).toContain('resource_metadata=');
292
+ });
293
+
294
+ it('401s on a non-Bearer scheme', async () => {
295
+ const { baseUrl } = await mount(makeOAuthProvider());
296
+
297
+ const res = await exchange(baseUrl, 'Basic abc:123');
298
+
299
+ expect(res.status).toBe(401);
300
+ });
301
+ });
@@ -2,6 +2,7 @@ import type { Request, Response, NextFunction } from 'express';
2
2
  import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
3
3
  import type { AuthService } from '../auth/auth.service.js';
4
4
  import type { IExternalApiKeyService } from '../tool-auth/external-api-key.interface.js';
5
+ import type { InternalTokenService } from '../tool-auth/internal-token.service.js';
5
6
  import type { BevelOAuthProvider } from './oauth/bevel-oauth-provider.js';
6
7
  import '../tool-auth/external-api-key.interface.js'; // Express Request augmentation (req.externalApiKeyId)
7
8
 
@@ -13,7 +14,17 @@ import '../tool-auth/external-api-key.interface.js'; // Express Request augmenta
13
14
  * 2. An MCP OAuth access token (Bearer `bevel-mcp_…`) — issued by our own
14
15
  * authorization server after the /connect consent flow, resolved via
15
16
  * BevelOAuthProvider.
16
- * 3. A regular JWT — same shape the web app uses. Lets a logged-in user
17
+ * 3. An internal token (Bearer `<tenant>-int_…`) — minted server-side only
18
+ * (createSession's loopback bearer, the /mcp/local-token exchange). The
19
+ * local MCP server (hexis-mcp) exchanges its OAuth grant for one and
20
+ * then uses it EVERYWHERE a connection key goes — the agent REST surface
21
+ * already accepts it, and refusing it here made OAuth-mode hexis-mcp
22
+ * fail at exactly one hop: registering this endpoint as its remote
23
+ * manual. Accepting it adds no new mint path — only this server creates
24
+ * them, always for an already-verified user. Only the `externalProxy`
25
+ * shape is accepted: a plain in-process internal token is the loopback
26
+ * surface's credential and must not double as an MCP caller.
27
+ * 4. A regular JWT — same shape the web app uses. Lets a logged-in user
17
28
  * hit the MCP endpoint from their browser if we ever need it
18
29
  * (e.g. an in-app debugger). Keeps the surface from forking.
19
30
  *
@@ -30,6 +41,7 @@ export function createMcpAuthMiddleware(
30
41
  externalApiKeyService: IExternalApiKeyService,
31
42
  oauthProvider: BevelOAuthProvider,
32
43
  resourceMetadataUrl: string,
44
+ internalTokens: InternalTokenService,
33
45
  ) {
34
46
  const wwwAuthenticate = `Bearer realm="bevel-mcp", resource_metadata="${resourceMetadataUrl}"`;
35
47
  const unauthorized = (res: Response, error: string) => {
@@ -111,6 +123,54 @@ export function createMcpAuthMiddleware(
111
123
  return;
112
124
  }
113
125
 
126
+ // Internal token — server-minted, shape-routed like the branches above so
127
+ // it never reaches jsonwebtoken. `verify` returns null for invalid or
128
+ // expired ones (a clean 401); the user row is loaded so `req.userEmail`
129
+ // carries the same truth every other branch provides — the tool handlers
130
+ // downstream resolve access against it.
131
+ //
132
+ // ONLY the `externalProxy` shape is admitted: that claim marks the
133
+ // loopback identity of an external caller (createSession's session
134
+ // bearer, the /mcp/local-token exchange), which is the one internal-token
135
+ // kind that has any business arriving here as an MCP client. A plain
136
+ // in-process internal token — the per-run credential the agent factory
137
+ // mints for its own code-mode client — is a loopback-surface credential,
138
+ // and accepting it would let it open an MCP session (createSession would
139
+ // even mint it a fresh externalProxy bearer, upgrading it).
140
+ if (internalTokens.looksLikeInternalToken(token)) {
141
+ // `verify` answers null for the invalid/expired cases it can see coming,
142
+ // but a malformed token of plausible shape can still THROW from inside
143
+ // it (e.g. `timingSafeEqual` on same-length strings whose byte lengths
144
+ // differ). That is the caller's bad token, not a backend failure — 401,
145
+ // never a 500 or an unhandled throw into the error handler.
146
+ let claim: ReturnType<InternalTokenService['verify']>;
147
+ try {
148
+ claim = internalTokens.verify(token);
149
+ } catch {
150
+ claim = null;
151
+ }
152
+ if (!claim || claim.externalProxy !== true) {
153
+ unauthorized(res, 'Invalid or expired internal token');
154
+ return;
155
+ }
156
+ let user;
157
+ try {
158
+ user = await authService.getUserById(claim.userId);
159
+ } catch (err) {
160
+ console.error('[mcp-auth] internal-token user lookup failed:', err);
161
+ res.status(500).json({ error: 'Authentication backend unavailable' });
162
+ return;
163
+ }
164
+ if (!user) {
165
+ unauthorized(res, 'Invalid or expired internal token');
166
+ return;
167
+ }
168
+ req.userId = user.id;
169
+ req.userEmail = user.email;
170
+ next();
171
+ return;
172
+ }
173
+
114
174
  // JWT path — same logic as `createAuthMiddleware` in modules/auth, kept
115
175
  // duplicated rather than imported because that one writes its own 401
116
176
  // body shape and we want the WWW-Authenticate header set.
@@ -1,5 +1,6 @@
1
1
  import express, { type Request, type RequestHandler } from 'express';
2
2
  import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
3
+ import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
3
4
  import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
5
  import type { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
6
  import {
@@ -8,7 +9,9 @@ import {
8
9
  TokenStillActiveError,
9
10
  } from '../tool-auth/external-api-key.errors.js';
10
11
  import type { IExternalApiKeyService } from '../tool-auth/external-api-key.interface.js';
11
- import type { McpService } from './mcp.service.js';
12
+ import type { InternalTokenService } from '../tool-auth/internal-token.service.js';
13
+ import { MCP_LOOPBACK_TOKEN_TTL_MS, type McpService } from './mcp.service.js';
14
+ import type { BevelOAuthProvider } from './oauth/bevel-oauth-provider.js';
12
15
  import type { ILlmUsageMeter } from '../tool-auth/llm-usage-meter.js';
13
16
  import '../tool-auth/external-api-key.interface.js'; // req.externalApiKeyId augmentation
14
17
 
@@ -37,10 +40,14 @@ function extractBearer(req: Request): string {
37
40
  * POST /mcp/external-api-keys — mint a new key (returns plaintext ONCE)
38
41
  * DELETE /mcp/external-api-keys/:id — revoke
39
42
  *
43
+ * POST /mcp/local-token — exchange an MCP OAuth access token
44
+ * for a loopback internal token
45
+ *
40
46
  * The `/mcp` endpoints accept either a connection key or a JWT (see
41
47
  * McpAuthMiddleware). The `/mcp/external-api-keys/*` endpoints accept only the JWT —
42
48
  * minting/revoking via a connection key would let a leaked key roll itself
43
- * over and stay alive forever.
49
+ * over and stay alive forever. `/mcp/local-token` accepts ONLY an MCP OAuth
50
+ * access token — every other credential already opens the surface it bridges to.
44
51
  */
45
52
  export function createMcpRoutes(
46
53
  mcpService: McpService,
@@ -48,6 +55,11 @@ export function createMcpRoutes(
48
55
  mcpAuthMiddleware: RequestHandler,
49
56
  jwtAuthMiddleware: RequestHandler,
50
57
  llmUsageService: ILlmUsageMeter,
58
+ internalTokens: InternalTokenService,
59
+ oauthProvider: BevelOAuthProvider,
60
+ // RFC 9728 pointer carried on this router's own 401 challenges (the
61
+ // local-token exchange), same value McpAuthMiddleware advertises.
62
+ resourceMetadataUrl: string,
51
63
  ): express.Router {
52
64
  const router = express.Router();
53
65
 
@@ -259,5 +271,128 @@ export function createMcpRoutes(
259
271
  }
260
272
  });
261
273
 
274
+ // ── Local-server token exchange (OAuth-access-token-only) ──────────────
275
+
276
+ /**
277
+ * Exchange an MCP OAuth access token for a short-lived internal token.
278
+ *
279
+ * Why it exists: the LOCAL MCP server's REST reads — the all-tools manual,
280
+ * `list_local_tools`, the plugin archive — live on `/api/agent/*`, which
281
+ * accepts connection keys and internal tokens ONLY; an MCP OAuth access
282
+ * token deliberately 401s there. Hosted OAuth sessions cross that gap
283
+ * inside `McpService.createSession`, which mints a loopback internal token
284
+ * for the resolved user. This endpoint is the same exchange for an external
285
+ * caller: the one bridge that lets a local server configured via the
286
+ * deployment's MCP OAuth (instead of a connection key) reach those reads.
287
+ *
288
+ * Why it is NOT a widening of the trust boundary: the caller must present a
289
+ * VERIFIED OAuth grant for this exact user — the same credential that
290
+ * already drives full tool execution through the hosted `/mcp` endpoint.
291
+ * The minted token is identical in shape to createSession's loopback bearer
292
+ * (`{ userId, externalProxy: true }` → resolved as `source: 'external'` by
293
+ * the tool-auth verifier, admitted to the external surface, refused from
294
+ * internal-only tools) and carries the same TTL — CAPPED to the presented
295
+ * access token's remaining lifetime, so the exchange can never mint a
296
+ * credential that outlives its grant. Nothing becomes reachable that the
297
+ * grant did not already reach — only the credential's spelling changes.
298
+ *
299
+ * Auth semantics mirror McpAuthMiddleware's OAuth branch: an
300
+ * invalid/expired/revoked token is a 401 re-challenging with
301
+ * `resource_metadata` (RFC 9728) so the client can re-authorize; a backend
302
+ * failure during verification is a 500. A connection key, internal token,
303
+ * or JWT is a 403 — those credentials need no exchange, so accepting them
304
+ * here would only manufacture a second credential from a first.
305
+ *
306
+ * Response: `{ token, expiresInMs }`.
307
+ */
308
+ router.post('/mcp/local-token', async (req, res) => {
309
+ const wwwAuthenticate = `Bearer realm="bevel-mcp", resource_metadata="${resourceMetadataUrl}"`;
310
+ const unauthorized = (error: string) => {
311
+ res.setHeader('WWW-Authenticate', wwwAuthenticate);
312
+ res.status(401).json({ error });
313
+ };
314
+
315
+ const header = req.headers.authorization;
316
+ if (!header || !header.toLowerCase().startsWith('bearer ')) {
317
+ unauthorized('Missing or invalid Authorization header');
318
+ return;
319
+ }
320
+ const token = extractBearer(req);
321
+
322
+ if (!oauthProvider.looksLikeAccessToken(token)) {
323
+ // A recognizable non-OAuth credential gets an explicit 403: a
324
+ // connection key or internal token already opens `/api/agent/*`
325
+ // directly, and a JWT holder mints a connection key from the settings
326
+ // UI — none of them has anything to exchange.
327
+ if (
328
+ externalApiKeyService.looksLikeExternalApiKey(token) ||
329
+ internalTokens.looksLikeInternalToken(token) ||
330
+ token.startsWith('eyJ')
331
+ ) {
332
+ res.status(403).json({
333
+ error:
334
+ 'This endpoint exchanges MCP OAuth access tokens only. Connection keys, ' +
335
+ 'internal tokens, and JWTs need no exchange — use them directly.',
336
+ });
337
+ return;
338
+ }
339
+ // Unrecognizable bearer — re-challenge so an OAuth-capable client can
340
+ // discover the authorization server and obtain a real access token.
341
+ unauthorized('Invalid access token');
342
+ return;
343
+ }
344
+
345
+ try {
346
+ const info = await oauthProvider.verifyAccessToken(token);
347
+ const userId = String(info.extra?.userId ?? '');
348
+ if (!userId) {
349
+ unauthorized('Invalid access token');
350
+ return;
351
+ }
352
+ // The minted token must never OUTLIVE the grant that authorized it: an
353
+ // OAuth access token revoked-by-expiry would otherwise leave a live
354
+ // internal token behind for the rest of the loopback TTL. Bind the TTL
355
+ // to whichever ends first — the constant, or the access token's own
356
+ // remaining lifetime (AuthInfo.expiresAt is epoch SECONDS, optional; a
357
+ // provider that reports none falls back to the constant alone).
358
+ const grantRemainingMs =
359
+ typeof info.expiresAt === 'number' ? info.expiresAt * 1000 - Date.now() : undefined;
360
+ // A grant with no life left mints NOTHING: a 200 carrying an
361
+ // already-dead token would read as success to the caller, whose first
362
+ // real request then fails somewhere far from the cause. It is the same
363
+ // 401 an expired token gets from the verifier, challenge and all — and
364
+ // a non-finite expiresAt (a provider handing back garbage) is refused
365
+ // the same way rather than turned into a TTL. Deliberately STRICTER
366
+ // than the verifier at the boundary: AuthInfo floors the expiry to
367
+ // whole seconds, so a grant inside its final partial second computes
368
+ // as spent here even though the verifier (which compares the stored
369
+ // millisecond timestamp) just accepted it — but that sub-second
370
+ // remainder could only mint a token that is dead before its first use,
371
+ // and refusing it is exactly this guard's job.
372
+ if (grantRemainingMs !== undefined && !(Number.isFinite(grantRemainingMs) && grantRemainingMs > 0)) {
373
+ unauthorized('Invalid, expired, or revoked access token');
374
+ return;
375
+ }
376
+ const ttlMs =
377
+ grantRemainingMs === undefined
378
+ ? MCP_LOOPBACK_TOKEN_TTL_MS
379
+ : Math.min(MCP_LOOPBACK_TOKEN_TTL_MS, grantRemainingMs);
380
+ const minted = internalTokens.mint({ userId, externalProxy: true }, ttlMs);
381
+ // The ACTUAL lifetime, not the constant — the caller schedules its
382
+ // proactive renewal off this number.
383
+ res.json({ token: minted, expiresInMs: ttlMs });
384
+ } catch (err) {
385
+ // Same split as McpAuthMiddleware: a bad token is a clean 401 with the
386
+ // discovery challenge; a backend failure is a 500 — the credential may
387
+ // be fine, we just can't check it right now.
388
+ if (err instanceof InvalidTokenError) {
389
+ unauthorized('Invalid, expired, or revoked access token');
390
+ } else {
391
+ console.error('[mcp] local-token exchange failed:', err);
392
+ res.status(500).json({ error: 'Authentication backend unavailable' });
393
+ }
394
+ }
395
+ });
396
+
262
397
  return router;
263
398
  }
@@ -76,8 +76,13 @@ const LOOPBACK_TIMEOUT_MS = 15_000;
76
76
  * first thing to die under a session's normal lifecycle. A continuously-active
77
77
  * session CAN outlive it — its tool calls then fail at the loopback and the
78
78
  * client recovers by re-initializing, which mints a fresh token.
79
+ *
80
+ * Exported because `POST /api/mcp/local-token` (mcp.routes.ts) performs the
81
+ * same OAuth-access-token → internal-token exchange for the LOCAL MCP server,
82
+ * and must mint the exact same shape and lifetime — one constant, two
83
+ * consumers, so the two bridges can never drift apart.
79
84
  */
80
- const MCP_LOOPBACK_TOKEN_TTL_MS = 5 * 60 * 60 * 1000;
85
+ export const MCP_LOOPBACK_TOKEN_TTL_MS = 5 * 60 * 60 * 1000;
81
86
 
82
87
  const callTemplateSerializer = new CallTemplateSerializer();
83
88