@bevel-software/platform-core-backend 0.13.5 → 0.14.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.
- package/THIRD-PARTY-NOTICES.md +639 -433
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +6 -3
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +2 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +9 -0
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +12 -5
- package/dist/core-config.js.map +1 -1
- package/dist/modules/access-model/kb-read-filter.d.ts +12 -0
- package/dist/modules/access-model/kb-read-filter.d.ts.map +1 -1
- package/dist/modules/access-model/kb-read-filter.js +15 -0
- package/dist/modules/access-model/kb-read-filter.js.map +1 -1
- package/dist/modules/connection-probe/connection-probe.contract.d.ts +43 -0
- package/dist/modules/connection-probe/connection-probe.contract.d.ts.map +1 -0
- package/dist/modules/connection-probe/connection-probe.contract.js +2 -0
- package/dist/modules/connection-probe/connection-probe.contract.js.map +1 -0
- package/dist/modules/connection-probe/connection-probe.service.d.ts +94 -0
- package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -0
- package/dist/modules/connection-probe/connection-probe.service.js +684 -0
- package/dist/modules/connection-probe/connection-probe.service.js.map +1 -0
- package/dist/modules/connection-probe/index.d.ts +3 -0
- package/dist/modules/connection-probe/index.d.ts.map +1 -0
- package/dist/modules/connection-probe/index.js +3 -0
- package/dist/modules/connection-probe/index.js.map +1 -0
- package/dist/modules/diff/diff.routes.d.ts.map +1 -1
- package/dist/modules/diff/diff.routes.js +3 -5
- package/dist/modules/diff/diff.routes.js.map +1 -1
- package/dist/modules/kb-fs/mutex.d.ts +37 -0
- package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
- package/dist/modules/kb-fs/mutex.js +48 -5
- package/dist/modules/kb-fs/mutex.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +95 -13
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +6 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +31 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +10 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +10 -4
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +83 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +9 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +181 -13
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +18 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +98 -6
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts +2 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +98 -9
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +81 -8
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +220 -43
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +2 -5
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +5 -1
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +21 -4
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/shared/token-crypto.d.ts +17 -2
- package/dist/shared/token-crypto.d.ts.map +1 -1
- package/dist/shared/token-crypto.js +25 -8
- package/dist/shared/token-crypto.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/core-config.admin.test.ts +21 -0
- package/src/core/create-core-server.ts +7 -2
- package/src/core/create-core-services.ts +11 -0
- package/src/core-config.ts +14 -7
- package/src/modules/access-model/kb-read-filter.ts +22 -0
- package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +685 -0
- package/src/modules/connection-probe/connection-probe.contract.ts +44 -0
- package/src/modules/connection-probe/connection-probe.service.ts +734 -0
- package/src/modules/connection-probe/index.ts +2 -0
- package/src/modules/diff/diff.routes.ts +9 -5
- package/src/modules/kb-fs/__tests__/mutex.test.ts +107 -0
- package/src/modules/kb-fs/mutex.ts +50 -5
- package/src/modules/mcp/__tests__/mcp-routes-harness.ts +89 -0
- package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -34
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +8 -40
- package/src/modules/mcp/__tests__/mcp.routes.session.test.ts +226 -0
- package/src/modules/mcp/mcp.routes.ts +482 -398
- package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +12 -0
- package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +10 -3
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +26 -0
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +1 -1
- package/src/modules/secrets-vault/secrets-vault.routes.ts +35 -1
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +11 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.health-check.test.ts +104 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +56 -0
- package/src/modules/tool-manuals/mcp-json-discovery.ts +13 -1
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +13 -4
- package/src/modules/tool-manuals/tool-manuals.contract.ts +89 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +196 -14
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +139 -0
- package/src/modules/workflow/__tests__/workflow.service.branch-in-use.test.ts +337 -0
- package/src/modules/workflow/__tests__/workflow.service.deleted-branch-sweep.test.ts +7 -1
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +95 -8
- package/src/modules/workflow/git/__tests__/git.service.history-guards.test.ts +86 -0
- package/src/modules/workflow/git/git.service.ts +115 -7
- package/src/modules/workflow/workflow.routes.ts +104 -4
- package/src/modules/workflow/workflow.service.ts +245 -55
- package/src/modules/workspace/workspace.routes.ts +8 -4
- package/src/modules/workspace/workspace.service.ts +21 -3
- package/src/shared/__tests__/token-crypto.test.ts +34 -0
- package/src/shared/token-crypto.ts +28 -10
|
@@ -3,7 +3,7 @@ import type { AuthUser, IWorkflowService } from '@bevel-software/platform-shared
|
|
|
3
3
|
import type { IDiffService } from './diff.interface.js';
|
|
4
4
|
import type { AuthService } from '../auth/auth.service.js';
|
|
5
5
|
import type { IAccessControl } from '../access/access-control.interface.js';
|
|
6
|
-
import { toKbRelative, resolveReadableMap } from '../access-model/kb-read-filter.js';
|
|
6
|
+
import { canReadWorkspacePath, toKbRelative, resolveReadableMap } from '../access-model/kb-read-filter.js';
|
|
7
7
|
import { WorkflowDomainError, WorkflowValidationError } from '../../shared/domain-errors.js';
|
|
8
8
|
import { LockingFilesystem } from '../kb-fs/locking-filesystem.js';
|
|
9
9
|
import { branchForWorkspaceId } from '../../shared/workspace-id.js';
|
|
@@ -138,10 +138,14 @@ export function createDiffRoutes(
|
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
/** Read gate for a single change path (mirrors GET /review/file). Returns true to proceed. */
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
141
|
+
function canReadPath(workspaceId: string, email: string, pathParam: string): Promise<boolean> {
|
|
142
|
+
return canReadWorkspacePath(
|
|
143
|
+
(w, e, p) => accessControl.canRead(w, e, p),
|
|
144
|
+
workspaceId,
|
|
145
|
+
email,
|
|
146
|
+
kbDirName,
|
|
147
|
+
pathParam,
|
|
148
|
+
);
|
|
145
149
|
}
|
|
146
150
|
|
|
147
151
|
router.get('/workspace/:id/review', async (req, res) => {
|
|
@@ -8,6 +8,113 @@ function deferred<T = void>() {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
describe('WorkspaceMutex', () => {
|
|
11
|
+
describe('runAll', () => {
|
|
12
|
+
/**
|
|
13
|
+
* The property nesting cannot provide: `run(a, () => run(b, fn))` releases
|
|
14
|
+
* nothing, but between taking `a` and taking `b` the second key is free,
|
|
15
|
+
* so a single-key task can slip in, run to completion, and be gone before
|
|
16
|
+
* the pair operation's body starts. `runAll` reserves the set in one
|
|
17
|
+
* synchronous step, so it cannot be interleaved that way.
|
|
18
|
+
*/
|
|
19
|
+
it('reserves every key before any of them can be taken by another task', async () => {
|
|
20
|
+
const mtx = new WorkspaceMutex();
|
|
21
|
+
const order: string[] = [];
|
|
22
|
+
const release = deferred();
|
|
23
|
+
|
|
24
|
+
// Something already holds 'a', so the pair operation cannot start yet —
|
|
25
|
+
// this is the window a nested acquisition would leave 'b' open in.
|
|
26
|
+
const holder = mtx.run('a', async () => {
|
|
27
|
+
order.push('holder-start');
|
|
28
|
+
await release.promise;
|
|
29
|
+
order.push('holder-end');
|
|
30
|
+
});
|
|
31
|
+
await new Promise((r) => setImmediate(r));
|
|
32
|
+
|
|
33
|
+
const pair = mtx.runAll(['a', 'b'], async () => {
|
|
34
|
+
order.push('pair');
|
|
35
|
+
});
|
|
36
|
+
// Submitted while the pair is still waiting on 'a'. If 'b' were taken
|
|
37
|
+
// only after 'a' were granted, this would run first.
|
|
38
|
+
const single = mtx.run('b', async () => {
|
|
39
|
+
order.push('single-b');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
release.resolve();
|
|
43
|
+
await Promise.all([holder, pair, single]);
|
|
44
|
+
expect(order).toEqual(['holder-start', 'holder-end', 'pair', 'single-b']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('lets a task on an unrelated key run in parallel', async () => {
|
|
48
|
+
const mtx = new WorkspaceMutex();
|
|
49
|
+
const order: string[] = [];
|
|
50
|
+
const release = deferred();
|
|
51
|
+
|
|
52
|
+
const holder = mtx.run('a', async () => {
|
|
53
|
+
await release.promise;
|
|
54
|
+
order.push('holder');
|
|
55
|
+
});
|
|
56
|
+
await new Promise((r) => setImmediate(r));
|
|
57
|
+
|
|
58
|
+
const pair = mtx.runAll(['a', 'b'], async () => void order.push('pair'));
|
|
59
|
+
const other = mtx.run('c', async () => void order.push('other'));
|
|
60
|
+
|
|
61
|
+
await other;
|
|
62
|
+
expect(order).toEqual(['other']);
|
|
63
|
+
release.resolve();
|
|
64
|
+
await Promise.all([holder, pair]);
|
|
65
|
+
expect(order).toEqual(['other', 'holder', 'pair']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A failing task on ONE key must not release the others. `Promise.all`
|
|
70
|
+
* settles on the first rejection rather than waiting for the rest, so
|
|
71
|
+
* combining the predecessors without catching each one first would start
|
|
72
|
+
* this task while another key was still held.
|
|
73
|
+
*/
|
|
74
|
+
it('waits for every predecessor even when one of them fails first', async () => {
|
|
75
|
+
const mtx = new WorkspaceMutex();
|
|
76
|
+
const order: string[] = [];
|
|
77
|
+
const failA = deferred();
|
|
78
|
+
const finishB = deferred();
|
|
79
|
+
|
|
80
|
+
const failing = mtx.run('a', async () => {
|
|
81
|
+
await failA.promise;
|
|
82
|
+
throw new Error('a-failed');
|
|
83
|
+
});
|
|
84
|
+
failing.catch(() => undefined); // handled below; keep the rejection quiet
|
|
85
|
+
const running = mtx.run('b', async () => {
|
|
86
|
+
await finishB.promise;
|
|
87
|
+
order.push('b-end');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Queued while BOTH predecessors are still pending.
|
|
91
|
+
const pair = mtx.runAll(['a', 'b'], async () => void order.push('pair'));
|
|
92
|
+
|
|
93
|
+
failA.resolve();
|
|
94
|
+
await expect(failing).rejects.toThrow('a-failed');
|
|
95
|
+
await new Promise((r) => setImmediate(r));
|
|
96
|
+
// 'a' has failed, but 'b' still holds its key — the pair must wait.
|
|
97
|
+
expect(order).toEqual([]);
|
|
98
|
+
|
|
99
|
+
finishB.resolve();
|
|
100
|
+
await Promise.all([running, pair]);
|
|
101
|
+
expect(order).toEqual(['b-end', 'pair']);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('releases every key when the body throws', async () => {
|
|
105
|
+
const mtx = new WorkspaceMutex();
|
|
106
|
+
await expect(mtx.runAll(['a', 'b'], async () => { throw new Error('boom'); })).rejects.toThrow('boom');
|
|
107
|
+
// Both keys must be usable again, or one failure wedges them forever.
|
|
108
|
+
await expect(mtx.run('a', async () => 'a-ok')).resolves.toBe('a-ok');
|
|
109
|
+
await expect(mtx.run('b', async () => 'b-ok')).resolves.toBe('b-ok');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('collapses a duplicate key instead of waiting on itself', async () => {
|
|
113
|
+
const mtx = new WorkspaceMutex();
|
|
114
|
+
await expect(mtx.runAll(['a', 'a'], async () => 'done')).resolves.toBe('done');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
11
118
|
it('serializes tasks for the same key in submission order', async () => {
|
|
12
119
|
const mtx = new WorkspaceMutex();
|
|
13
120
|
const order: string[] = [];
|
|
@@ -8,14 +8,59 @@ export class WorkspaceMutex {
|
|
|
8
8
|
private readonly tails = new Map<string, Promise<unknown>>();
|
|
9
9
|
|
|
10
10
|
async run<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
return this.runAll([key], fn);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Reserve SEVERAL keys for one operation, atomically.
|
|
16
|
+
*
|
|
17
|
+
* Nesting `run(a, () => run(b, fn))` does NOT do this: between taking `a`
|
|
18
|
+
* and taking `b` there is a gap in which another operation can take `b`,
|
|
19
|
+
* finish, and release it — so an operation holding both keys can still be
|
|
20
|
+
* interleaved with one holding just the second. For branch lifecycle that
|
|
21
|
+
* gap is the whole bug: a change request being opened `Y -> X` would hold
|
|
22
|
+
* one branch's key while the other was deleted out from under it.
|
|
23
|
+
*
|
|
24
|
+
* This reserves the set in ONE synchronous step. The tails are read and
|
|
25
|
+
* rewritten without an await between, so no other call can observe a
|
|
26
|
+
* half-taken set: a concurrent `run` on any of these keys either already
|
|
27
|
+
* sits in `prev` (it goes first) or chains onto `next` (it goes after).
|
|
28
|
+
* Within one call nothing is ever held while waiting for something else,
|
|
29
|
+
* so callers that do not nest need no ordering discipline and cannot
|
|
30
|
+
* deadlock.
|
|
31
|
+
*
|
|
32
|
+
* NESTING is the one way to reintroduce hold-and-wait, so never nest
|
|
33
|
+
* `run`/`runAll` calls on the same instance. An inner call chains behind
|
|
34
|
+
* everything already queued on its keys, which can include a `runAll` that
|
|
35
|
+
* is itself waiting for the outer call to finish: `run(b, () => run(a, …))`
|
|
36
|
+
* was harmless when every operation took a single key, but against a
|
|
37
|
+
* concurrent `runAll([a, b])` it deadlocks — outer waits inner, inner
|
|
38
|
+
* waits the pair, the pair waits outer. No caller nests today; keep it
|
|
39
|
+
* that way, or hand the full key set to one `runAll` at the top.
|
|
40
|
+
*
|
|
41
|
+
* Duplicate keys collapse; the operation waits on each distinct key once.
|
|
42
|
+
*
|
|
43
|
+
* Each predecessor is caught INDIVIDUALLY, before the `Promise.all`. A bare
|
|
44
|
+
* `Promise.all` settles on the first rejection rather than waiting for the
|
|
45
|
+
* rest, so one key's task failing early would start this one while another
|
|
46
|
+
* key was still held — a failure on any key would punch a hole in the
|
|
47
|
+
* mutual exclusion of every key beside it. Catching first makes the
|
|
48
|
+
* combined promise wait for all of them to settle, however each ends.
|
|
49
|
+
*/
|
|
50
|
+
async runAll<T>(keys: string[], fn: () => Promise<T>): Promise<T> {
|
|
51
|
+
const distinct = [...new Set(keys)];
|
|
52
|
+
const prev = Promise.all(
|
|
53
|
+
distinct.map((k) => (this.tails.get(k) ?? Promise.resolve()).catch(() => undefined)),
|
|
54
|
+
);
|
|
55
|
+
const next = prev.then(fn);
|
|
56
|
+
for (const k of distinct) this.tails.set(k, next);
|
|
14
57
|
try {
|
|
15
58
|
return await next;
|
|
16
59
|
} finally {
|
|
17
|
-
|
|
18
|
-
this.tails.
|
|
60
|
+
for (const k of distinct) {
|
|
61
|
+
if (this.tails.get(k) === next) {
|
|
62
|
+
this.tails.delete(k);
|
|
63
|
+
}
|
|
19
64
|
}
|
|
20
65
|
}
|
|
21
66
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Server as HttpServer } from 'node:http';
|
|
2
|
+
import type { RequestHandler } from 'express';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { createMcpRoutes } from '../mcp.routes.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The shared mount for every `createMcpRoutes` suite.
|
|
8
|
+
*
|
|
9
|
+
* Three suites exercise different slices of this router — the transport's
|
|
10
|
+
* session routing, the connection-key routes, the local-token exchange — and
|
|
11
|
+
* each had grown its own copy of the same scaffolding: a `fakeAuth` stand-in,
|
|
12
|
+
* an `app.listen(0)` plus `afterEach` close, and the eight-argument
|
|
13
|
+
* `createMcpRoutes` call with stubs in every position the suite does not care
|
|
14
|
+
* about. Three copies meant a signature change had three places to patch, and
|
|
15
|
+
* they had already drifted from one another.
|
|
16
|
+
*
|
|
17
|
+
* Not collected by vitest: the include pattern is `**\/*.test.ts`, and this is
|
|
18
|
+
* not a test file.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Auth stand-in binding the user named by `x-test-user`, defaulting to user-A.
|
|
23
|
+
*
|
|
24
|
+
* The header lets a suite act as a second user (the cross-user checks need
|
|
25
|
+
* one); suites that never send it see the single fixed user their assertions
|
|
26
|
+
* were written against.
|
|
27
|
+
*/
|
|
28
|
+
export const fakeAuth: RequestHandler = (req, _res, next) => {
|
|
29
|
+
req.userId = (req.headers['x-test-user'] as string | undefined) ?? 'user-A';
|
|
30
|
+
next();
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The collaborators a suite actually cares about. Anything omitted is stubbed:
|
|
35
|
+
* `createMcpRoutes` only dereferences a dependency inside the handler that
|
|
36
|
+
* needs it, so a suite that never calls that route never touches the stub.
|
|
37
|
+
*/
|
|
38
|
+
export interface McpRoutesDeps {
|
|
39
|
+
mcpService?: unknown;
|
|
40
|
+
externalApiKeyService?: unknown;
|
|
41
|
+
llmUsageService?: unknown;
|
|
42
|
+
internalTokens?: unknown;
|
|
43
|
+
oauthProvider?: unknown;
|
|
44
|
+
resourceMetadataUrl?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Servers opened by `mountMcpRoutes`, closed together by `closeMountedRoutes`. */
|
|
48
|
+
const openServers = new Set<HttpServer>();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Mount the router on an ephemeral port; returns its base URL. Every mount is
|
|
52
|
+
* tracked, so a suite's teardown is one `closeMountedRoutes()` regardless of
|
|
53
|
+
* how many it opened.
|
|
54
|
+
*/
|
|
55
|
+
export async function mountMcpRoutes(deps: McpRoutesDeps = {}): Promise<string> {
|
|
56
|
+
const stub = {} as never;
|
|
57
|
+
const app = express();
|
|
58
|
+
app.use(express.json());
|
|
59
|
+
app.use(
|
|
60
|
+
'/api',
|
|
61
|
+
createMcpRoutes(
|
|
62
|
+
// `createMcpRoutes` calls `onSessionEvicted` at CONSTRUCTION, so unlike
|
|
63
|
+
// the others this one needs a real function even when unused.
|
|
64
|
+
(deps.mcpService ?? { onSessionEvicted: () => {} }) as never,
|
|
65
|
+
(deps.externalApiKeyService ?? stub) as never,
|
|
66
|
+
fakeAuth,
|
|
67
|
+
fakeAuth,
|
|
68
|
+
(deps.llmUsageService ?? stub) as never,
|
|
69
|
+
(deps.internalTokens ?? stub) as never,
|
|
70
|
+
(deps.oauthProvider ?? stub) as never,
|
|
71
|
+
deps.resourceMetadataUrl ?? '',
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
const server = await new Promise<HttpServer>((resolve) => {
|
|
75
|
+
const s = app.listen(0, () => resolve(s));
|
|
76
|
+
});
|
|
77
|
+
openServers.add(server);
|
|
78
|
+
const { port } = server.address() as { port: number };
|
|
79
|
+
return `http://127.0.0.1:${port}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Close every server this harness opened. Safe to call when none are open. */
|
|
83
|
+
export async function closeMountedRoutes(): Promise<void> {
|
|
84
|
+
const servers = [...openServers];
|
|
85
|
+
openServers.clear();
|
|
86
|
+
await Promise.all(
|
|
87
|
+
servers.map((s) => new Promise<void>((resolve) => s.close(() => resolve()))),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import type { Server as HttpServer } from 'node:http';
|
|
2
|
-
import type { RequestHandler } from 'express';
|
|
3
|
-
import express from 'express';
|
|
4
1
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
-
import { createMcpRoutes } from '../mcp.routes.js';
|
|
6
2
|
import { TokenNotFoundError, TokenStillActiveError } from '../../tool-auth/external-api-key.errors.js';
|
|
7
3
|
import type { IExternalApiKeyService } from '../../tool-auth/external-api-key.interface.js';
|
|
4
|
+
import { closeMountedRoutes, mountMcpRoutes } from './mcp-routes-harness.js';
|
|
8
5
|
|
|
9
6
|
/**
|
|
10
7
|
* Focused coverage for the status mapping of
|
|
@@ -14,19 +11,8 @@ import type { IExternalApiKeyService } from '../../tool-auth/external-api-key.in
|
|
|
14
11
|
* HTTP status codes (404 / 409 / 500) and the success shape.
|
|
15
12
|
*/
|
|
16
13
|
|
|
17
|
-
// Stand-in auth: bind a user like the real jwtAuthMiddleware does.
|
|
18
|
-
const fakeAuth: RequestHandler = (req, _res, next) => {
|
|
19
|
-
req.userId = 'user-A';
|
|
20
|
-
next();
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
let httpServer: HttpServer | undefined;
|
|
24
|
-
|
|
25
14
|
afterEach(async () => {
|
|
26
|
-
|
|
27
|
-
await new Promise<void>((resolve) => httpServer!.close(() => resolve()));
|
|
28
|
-
httpServer = undefined;
|
|
29
|
-
}
|
|
15
|
+
await closeMountedRoutes();
|
|
30
16
|
vi.restoreAllMocks();
|
|
31
17
|
});
|
|
32
18
|
|
|
@@ -35,24 +21,10 @@ async function mountWithRemove(
|
|
|
35
21
|
): Promise<{ baseUrl: string; remove: ReturnType<typeof vi.fn> }> {
|
|
36
22
|
const spy = vi.fn(remove);
|
|
37
23
|
const externalApiKeyService = { remove: spy } as unknown as IExternalApiKeyService;
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
const app = express();
|
|
43
|
-
app.use(express.json());
|
|
44
|
-
// local-token deps (internal tokens / OAuth provider / metadata URL) are only
|
|
45
|
-
// dereferenced by that route's handler — stubs suffice here.
|
|
46
|
-
app.use(
|
|
47
|
-
'/api',
|
|
48
|
-
createMcpRoutes(mcpService, externalApiKeyService, fakeAuth, fakeAuth, stub, stub, stub, ''),
|
|
49
|
-
);
|
|
50
|
-
|
|
51
|
-
httpServer = await new Promise<HttpServer>((resolve) => {
|
|
52
|
-
const s = app.listen(0, () => resolve(s));
|
|
53
|
-
});
|
|
54
|
-
const port = (httpServer.address() as { port: number }).port;
|
|
55
|
-
return { baseUrl: `http://127.0.0.1:${port}`, remove: spy };
|
|
24
|
+
// The local-token deps (internal tokens / OAuth provider / metadata URL) are
|
|
25
|
+
// only dereferenced by that route's handler, so the harness's stubs suffice.
|
|
26
|
+
const baseUrl = await mountMcpRoutes({ externalApiKeyService });
|
|
27
|
+
return { baseUrl, remove: spy };
|
|
56
28
|
}
|
|
57
29
|
|
|
58
30
|
async function del(baseUrl: string, id: string): Promise<Response> {
|
|
@@ -1,14 +1,11 @@
|
|
|
1
|
-
import type { Server as HttpServer } from 'node:http';
|
|
2
|
-
import type { RequestHandler } from 'express';
|
|
3
|
-
import express from 'express';
|
|
4
1
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
2
|
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
|
6
|
-
import { createMcpRoutes } from '../mcp.routes.js';
|
|
7
3
|
import { MCP_LOOPBACK_TOKEN_TTL_MS } from '../mcp.service.js';
|
|
8
4
|
import { InternalTokenService } from '../../tool-auth/internal-token.service.js';
|
|
9
5
|
import { createTokenVerifier } from '../../tool-auth/tool-auth.middleware.js';
|
|
10
6
|
import type { IExternalApiKeyService } from '../../tool-auth/external-api-key.interface.js';
|
|
11
7
|
import type { BevelOAuthProvider } from '../oauth/bevel-oauth-provider.js';
|
|
8
|
+
import { closeMountedRoutes, mountMcpRoutes } from './mcp-routes-harness.js';
|
|
12
9
|
|
|
13
10
|
/**
|
|
14
11
|
* Coverage for POST /api/mcp/local-token — the OAuth-access-token → internal
|
|
@@ -31,19 +28,8 @@ import type { BevelOAuthProvider } from '../oauth/bevel-oauth-provider.js';
|
|
|
31
28
|
|
|
32
29
|
const RESOURCE_METADATA_URL = 'https://bevel.example/.well-known/oauth-protected-resource/api/mcp';
|
|
33
30
|
|
|
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
31
|
afterEach(async () => {
|
|
43
|
-
|
|
44
|
-
await new Promise<void>((resolve) => httpServer!.close(() => resolve()));
|
|
45
|
-
httpServer = undefined;
|
|
46
|
-
}
|
|
32
|
+
await closeMountedRoutes();
|
|
47
33
|
vi.restoreAllMocks();
|
|
48
34
|
});
|
|
49
35
|
|
|
@@ -69,31 +55,13 @@ async function mount(oauth: BevelOAuthProvider): Promise<{
|
|
|
69
55
|
const externalApiKeyService = {
|
|
70
56
|
looksLikeExternalApiKey: (t: string) => typeof t === 'string' && t.startsWith('bevel_'),
|
|
71
57
|
} as unknown as IExternalApiKeyService;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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));
|
|
58
|
+
const baseUrl = await mountMcpRoutes({
|
|
59
|
+
externalApiKeyService,
|
|
60
|
+
internalTokens,
|
|
61
|
+
oauthProvider: oauth,
|
|
62
|
+
resourceMetadataUrl: RESOURCE_METADATA_URL,
|
|
94
63
|
});
|
|
95
|
-
|
|
96
|
-
return { baseUrl: `http://127.0.0.1:${port}`, internalTokens };
|
|
64
|
+
return { baseUrl, internalTokens };
|
|
97
65
|
}
|
|
98
66
|
|
|
99
67
|
async function exchange(baseUrl: string, authorization?: string): Promise<Response> {
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { Request as ExpressRequest, Response as ExpressResponse } from 'express';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { closeMountedRoutes, mountMcpRoutes } from './mcp-routes-harness.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Session routing on the MCP transport routes — how `/api/mcp` answers a
|
|
7
|
+
* request whose `Mcp-Session-Id` it cannot resolve.
|
|
8
|
+
*
|
|
9
|
+
* The distinction is load-bearing rather than cosmetic. Streamable HTTP makes
|
|
10
|
+
* HTTP 404 on a request bearing a session id the signal a client re-initializes
|
|
11
|
+
* on; 400 means "you sent something malformed", which has no recovery. These
|
|
12
|
+
* routes used to answer both misses — no session id, and a session id from a
|
|
13
|
+
* session that no longer exists — with one 400 on POST and one 404 on
|
|
14
|
+
* GET/DELETE, so a client whose session died with the server (a restart wipes
|
|
15
|
+
* the in-process map) was told its request was malformed and stayed stranded.
|
|
16
|
+
*
|
|
17
|
+
* Locked down here:
|
|
18
|
+
* - the two misses map to distinct, spec-correct codes on every verb;
|
|
19
|
+
* - both bodies are real JSON-RPC error objects, since this router answers
|
|
20
|
+
* before the SDK transport that would otherwise have shaped them;
|
|
21
|
+
* - an `initialize` carrying a STALE session id still opens a new session —
|
|
22
|
+
* the recovery path itself, which the old "no session id" guard blocked;
|
|
23
|
+
* - the live-session and cross-user branches are untouched.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The id the FIRST session minted in a test gets; `openSession` establishes it. */
|
|
27
|
+
const SESSION_ID = 'sess-1';
|
|
28
|
+
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
await closeMountedRoutes();
|
|
31
|
+
vi.restoreAllMocks();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A createSession stub shaped like the real one: it hands back a transport
|
|
36
|
+
* whose `handleRequest` reports the session id it served, and announces the
|
|
37
|
+
* session through `onSessionInitialized` from INSIDE that first call — which
|
|
38
|
+
* is when the SDK transport really fires it, and the only ordering the route's
|
|
39
|
+
* `active.set` closure can observe without a TDZ error.
|
|
40
|
+
*
|
|
41
|
+
* Each call mints a DISTINCT id (`sess-1`, `sess-2`, …) rather than a constant.
|
|
42
|
+
* With a constant, a route that wrongly minted a second session answered with
|
|
43
|
+
* a body identical to the correct one, so "no second session" could only ever
|
|
44
|
+
* be asserted through the call count. Numbering them puts the mistake in the
|
|
45
|
+
* response itself: a reply naming `sess-2` IS the extra session.
|
|
46
|
+
*/
|
|
47
|
+
function makeMcpService() {
|
|
48
|
+
let minted = 0;
|
|
49
|
+
const createSession = vi.fn(
|
|
50
|
+
async (
|
|
51
|
+
userId: string,
|
|
52
|
+
_tokenId: string | null,
|
|
53
|
+
_bearer: string,
|
|
54
|
+
onSessionInitialized: (sessionId: string) => void,
|
|
55
|
+
) => {
|
|
56
|
+
minted += 1;
|
|
57
|
+
const sessionId = `sess-${minted}`;
|
|
58
|
+
let announced = false;
|
|
59
|
+
const transport = {
|
|
60
|
+
sessionId,
|
|
61
|
+
onclose: undefined as undefined | (() => void),
|
|
62
|
+
handleRequest: vi.fn(async (_req: ExpressRequest, res: ExpressResponse) => {
|
|
63
|
+
if (!announced) {
|
|
64
|
+
announced = true;
|
|
65
|
+
onSessionInitialized(sessionId);
|
|
66
|
+
}
|
|
67
|
+
res.status(200).json({ forwarded: true, sessionId, userId });
|
|
68
|
+
}),
|
|
69
|
+
close: vi.fn(async () => {}),
|
|
70
|
+
};
|
|
71
|
+
const server = { connect: vi.fn(async () => {}), close: vi.fn(async () => {}) };
|
|
72
|
+
return { transport, server };
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
return { onSessionEvicted: () => {}, createSession };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function mount(): Promise<{ baseUrl: string; createSession: ReturnType<typeof vi.fn> }> {
|
|
79
|
+
const mcpService = makeMcpService();
|
|
80
|
+
const baseUrl = await mountMcpRoutes({ mcpService });
|
|
81
|
+
return { baseUrl, createSession: mcpService.createSession };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const INITIALIZE = {
|
|
85
|
+
jsonrpc: '2.0',
|
|
86
|
+
id: 0,
|
|
87
|
+
method: 'initialize',
|
|
88
|
+
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '0' } },
|
|
89
|
+
};
|
|
90
|
+
const TOOL_CALL = { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} };
|
|
91
|
+
|
|
92
|
+
async function send(
|
|
93
|
+
baseUrl: string,
|
|
94
|
+
opts: { method?: string; sessionId?: string; body?: unknown; user?: string } = {},
|
|
95
|
+
): Promise<Response> {
|
|
96
|
+
const { method = 'POST', sessionId, body, user } = opts;
|
|
97
|
+
return fetch(`${baseUrl}/api/mcp`, {
|
|
98
|
+
method,
|
|
99
|
+
headers: {
|
|
100
|
+
'Content-Type': 'application/json',
|
|
101
|
+
Accept: 'application/json, text/event-stream',
|
|
102
|
+
...(sessionId ? { 'mcp-session-id': sessionId } : {}),
|
|
103
|
+
...(user ? { 'x-test-user': user } : {}),
|
|
104
|
+
},
|
|
105
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Open a real session so `active` holds SESSION_ID. */
|
|
110
|
+
async function openSession(baseUrl: string): Promise<void> {
|
|
111
|
+
const res = await send(baseUrl, { body: INITIALIZE });
|
|
112
|
+
expect(res.status).toBe(200);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe('POST /mcp session routing', () => {
|
|
116
|
+
it('answers an unknown session id with 404 / -32001, the re-initialize signal', async () => {
|
|
117
|
+
const { baseUrl } = await mount();
|
|
118
|
+
const res = await send(baseUrl, { sessionId: 'sess-from-before-the-restart', body: TOOL_CALL });
|
|
119
|
+
expect(res.status).toBe(404);
|
|
120
|
+
await expect(res.json()).resolves.toEqual({
|
|
121
|
+
jsonrpc: '2.0',
|
|
122
|
+
error: { code: -32001, message: 'Session not found' },
|
|
123
|
+
id: null,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('answers a missing session id with 400 / -32000 — a different code from the miss above', async () => {
|
|
128
|
+
const { baseUrl } = await mount();
|
|
129
|
+
const res = await send(baseUrl, { body: TOOL_CALL });
|
|
130
|
+
expect(res.status).toBe(400);
|
|
131
|
+
await expect(res.json()).resolves.toEqual({
|
|
132
|
+
jsonrpc: '2.0',
|
|
133
|
+
error: { code: -32000, message: 'Bad Request: Mcp-Session-Id header is required' },
|
|
134
|
+
id: null,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('opens a new session for an initialize that still carries a STALE session id', async () => {
|
|
139
|
+
const { baseUrl, createSession } = await mount();
|
|
140
|
+
// Exactly what a client does after a server restart: it re-initializes
|
|
141
|
+
// before it has any reason to drop the id it was given last time.
|
|
142
|
+
const res = await send(baseUrl, { sessionId: 'sess-from-before-the-restart', body: INITIALIZE });
|
|
143
|
+
expect(res.status).toBe(200);
|
|
144
|
+
await expect(res.json()).resolves.toMatchObject({ forwarded: true, sessionId: SESSION_ID });
|
|
145
|
+
expect(createSession).toHaveBeenCalledTimes(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('opens a new session for an initialize with no session id', async () => {
|
|
149
|
+
const { baseUrl, createSession } = await mount();
|
|
150
|
+
const res = await send(baseUrl, { body: INITIALIZE });
|
|
151
|
+
expect(res.status).toBe(200);
|
|
152
|
+
expect(createSession).toHaveBeenCalledTimes(1);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('forwards to the live transport once a session exists', async () => {
|
|
156
|
+
const { baseUrl, createSession } = await mount();
|
|
157
|
+
await openSession(baseUrl);
|
|
158
|
+
const res = await send(baseUrl, { sessionId: SESSION_ID, body: TOOL_CALL });
|
|
159
|
+
expect(res.status).toBe(200);
|
|
160
|
+
await expect(res.json()).resolves.toMatchObject({ forwarded: true, sessionId: SESSION_ID });
|
|
161
|
+
// No second session: the live branch was taken, not the initialize branch.
|
|
162
|
+
expect(createSession).toHaveBeenCalledTimes(1);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('routes an initialize over a LIVE session to that session, minting no second one', async () => {
|
|
166
|
+
// The guard this pins is branch ORDER, and only this change made it
|
|
167
|
+
// load-bearing: `initialize` used to be gated on `!sessionIdHeader`, so
|
|
168
|
+
// the live-session branch and the initialize branch were mutually
|
|
169
|
+
// exclusive and their order did not matter. Without that gate, an
|
|
170
|
+
// initialize check placed first would match here too — and silently mint
|
|
171
|
+
// a second session over a working one, which is the failure the route
|
|
172
|
+
// comment and the PR both promise cannot happen.
|
|
173
|
+
//
|
|
174
|
+
// What is asserted is what this ROUTER owes: the request reached the
|
|
175
|
+
// existing transport and no new session was created. Rejecting a
|
|
176
|
+
// re-initialize is the SDK transport's own job (`-32600 Server already
|
|
177
|
+
// initialized`), and asserting that here would be asserting against the
|
|
178
|
+
// stub rather than against the code under test.
|
|
179
|
+
const { baseUrl, createSession } = await mount();
|
|
180
|
+
await openSession(baseUrl); // mints sess-1
|
|
181
|
+
const res = await send(baseUrl, { sessionId: SESSION_ID, body: INITIALIZE });
|
|
182
|
+
expect(res.status).toBe(200);
|
|
183
|
+
// sess-1, not sess-2: the live transport answered, not a fresh one.
|
|
184
|
+
await expect(res.json()).resolves.toMatchObject({ forwarded: true, sessionId: SESSION_ID });
|
|
185
|
+
expect(createSession).toHaveBeenCalledTimes(1);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('still refuses another user’s live session with 403', async () => {
|
|
189
|
+
const { baseUrl } = await mount();
|
|
190
|
+
await openSession(baseUrl); // owned by user-A
|
|
191
|
+
const res = await send(baseUrl, { sessionId: SESSION_ID, body: TOOL_CALL, user: 'user-B' });
|
|
192
|
+
expect(res.status).toBe(403);
|
|
193
|
+
// JSON-RPC like every other answer this router gives before the SDK
|
|
194
|
+
// transport — a client parsing /api/mcp bodies as JSON-RPC must not hit a
|
|
195
|
+
// bare `{ error }` on this one branch.
|
|
196
|
+
await expect(res.json()).resolves.toEqual({
|
|
197
|
+
jsonrpc: '2.0',
|
|
198
|
+
error: { code: -32003, message: 'Session does not belong to this user' },
|
|
199
|
+
id: null,
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe.each(['GET', 'DELETE'])('%s /mcp session routing', (method) => {
|
|
205
|
+
it('answers an unknown session id with 404 / -32001', async () => {
|
|
206
|
+
const { baseUrl } = await mount();
|
|
207
|
+
const res = await send(baseUrl, { method, sessionId: 'sess-from-before-the-restart' });
|
|
208
|
+
expect(res.status).toBe(404);
|
|
209
|
+
await expect(res.json()).resolves.toEqual({
|
|
210
|
+
jsonrpc: '2.0',
|
|
211
|
+
error: { code: -32001, message: 'Session not found' },
|
|
212
|
+
id: null,
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('answers a missing session id with 400 / -32000', async () => {
|
|
217
|
+
const { baseUrl } = await mount();
|
|
218
|
+
const res = await send(baseUrl, { method });
|
|
219
|
+
expect(res.status).toBe(400);
|
|
220
|
+
await expect(res.json()).resolves.toEqual({
|
|
221
|
+
jsonrpc: '2.0',
|
|
222
|
+
error: { code: -32000, message: 'Bad Request: Mcp-Session-Id header is required' },
|
|
223
|
+
id: null,
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|