@bevel-software/platform-core-backend 0.13.5 → 0.13.6

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.
@@ -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
+ });