@harness-fe/mcp-server 4.0.0-next.2 → 4.0.0-next.3
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/dist/bin.d.ts +2 -0
- package/dist/bin.js +15 -0
- package/dist/daemon.d.ts +3 -3
- package/dist/daemon.js +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.js +42 -16
- package/dist/mcpHttp.d.ts +2 -2
- package/dist/mcpHttp.js +8 -2
- package/package.json +5 -7
- package/src/bin.ts +19 -0
- package/src/daemon.ts +3 -3
- package/src/experimental.test.ts +2 -2
- package/src/index.ts +4 -4
- package/src/mcp.ts +44 -20
- package/src/mcpHttp.test.ts +3 -3
- package/src/mcpHttp.ts +10 -4
- package/src/mcpLayer.e2e.test.ts +2 -2
- package/src/newCapabilities.e2e.test.ts +3 -3
- package/dist/auth.d.ts +0 -53
- package/dist/auth.js +0 -212
- package/dist/bridge.d.ts +0 -323
- package/dist/bridge.js +0 -1618
- package/dist/cli.d.ts +0 -18
- package/dist/cli.js +0 -293
- package/dist/dashboardApi.d.ts +0 -40
- package/dist/dashboardApi.js +0 -142
- package/dist/dashboardSpa.d.ts +0 -18
- package/dist/dashboardSpa.js +0 -180
- package/dist/dashboardUrl.d.ts +0 -13
- package/dist/dashboardUrl.js +0 -18
- package/dist/eventsHandler.d.ts +0 -24
- package/dist/eventsHandler.js +0 -114
- package/dist/identity.d.ts +0 -90
- package/dist/identity.js +0 -123
- package/dist/openBrowser.d.ts +0 -33
- package/dist/openBrowser.js +0 -63
- package/dist/remoteBridge.d.ts +0 -61
- package/dist/remoteBridge.js +0 -307
- package/dist/replayCreate.d.ts +0 -36
- package/dist/replayCreate.js +0 -156
- package/dist/replayViewer.d.ts +0 -20
- package/dist/replayViewer.js +0 -168
- package/dist/sessionRouter.d.ts +0 -45
- package/dist/sessionRouter.js +0 -88
- package/dist/store/JsonMemoryStore.d.ts +0 -52
- package/dist/store/JsonMemoryStore.js +0 -119
- package/dist/store/JsonTaskStore.d.ts +0 -21
- package/dist/store/JsonTaskStore.js +0 -53
- package/dist/store/JsonlStore.d.ts +0 -128
- package/dist/store/JsonlStore.js +0 -1172
- package/dist/store/MemoryEventStore.d.ts +0 -47
- package/dist/store/MemoryEventStore.js +0 -111
- package/dist/store/WriteQueue.d.ts +0 -51
- package/dist/store/WriteQueue.js +0 -142
- package/dist/store/index.d.ts +0 -6
- package/dist/store/index.js +0 -5
- package/dist/store/types.d.ts +0 -427
- package/dist/store/types.js +0 -19
- package/dist/visitorTimeline.d.ts +0 -24
- package/dist/visitorTimeline.js +0 -68
- package/src/auth.test.ts +0 -90
- package/src/auth.ts +0 -248
- package/src/bridge-auth.test.ts +0 -196
- package/src/bridge.test.ts +0 -1708
- package/src/bridge.ts +0 -1854
- package/src/cli.ts +0 -338
- package/src/dashboardApi.test.ts +0 -235
- package/src/dashboardApi.ts +0 -184
- package/src/dashboardSpa.test.ts +0 -239
- package/src/dashboardSpa.ts +0 -195
- package/src/dashboardUrl.test.ts +0 -46
- package/src/dashboardUrl.ts +0 -28
- package/src/eventsHandler.test.ts +0 -247
- package/src/eventsHandler.ts +0 -136
- package/src/identity.test.ts +0 -109
- package/src/identity.ts +0 -137
- package/src/openBrowser.test.ts +0 -103
- package/src/openBrowser.ts +0 -81
- package/src/remoteBridge.test.ts +0 -119
- package/src/remoteBridge.ts +0 -404
- package/src/replay.test.ts +0 -271
- package/src/replayCreate.ts +0 -194
- package/src/replayViewer.ts +0 -173
- package/src/sessionRouter.ts +0 -119
- package/src/store/JsonMemoryStore.test.ts +0 -175
- package/src/store/JsonMemoryStore.ts +0 -128
- package/src/store/JsonTaskStore.test.ts +0 -212
- package/src/store/JsonTaskStore.ts +0 -59
- package/src/store/JsonlStore.test.ts +0 -1538
- package/src/store/JsonlStore.ts +0 -1325
- package/src/store/MemoryEventStore.test.ts +0 -119
- package/src/store/MemoryEventStore.ts +0 -151
- package/src/store/WriteQueue.ts +0 -165
- package/src/store/identityTagging.test.ts +0 -67
- package/src/store/index.ts +0 -29
- package/src/store/types.ts +0 -532
- package/src/visitorTimeline.test.ts +0 -197
- package/src/visitorTimeline.ts +0 -89
package/src/dashboardUrl.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Compose dashboard URLs the user (or agent) should hit.
|
|
3
|
-
*
|
|
4
|
-
* Carries the configured auth token in the query string so the browser
|
|
5
|
-
* lands pre-authenticated. On loopback hosts with no token configured,
|
|
6
|
-
* the URL is left bare.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { IBridge } from './bridge.js';
|
|
10
|
-
|
|
11
|
-
export interface DashboardUrlOptions {
|
|
12
|
-
/** When provided, deep-link to a specific session detail page. */
|
|
13
|
-
sessionId?: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function buildDashboardUrl(
|
|
17
|
-
bridge: IBridge,
|
|
18
|
-
opts: DashboardUrlOptions = {},
|
|
19
|
-
): string | undefined {
|
|
20
|
-
const base = bridge.getViewerBaseUrl();
|
|
21
|
-
if (!base) return undefined;
|
|
22
|
-
const path = opts.sessionId
|
|
23
|
-
? `/dashboard/sessions/${encodeURIComponent(opts.sessionId)}`
|
|
24
|
-
: '/dashboard/';
|
|
25
|
-
const token = bridge.getAuthToken();
|
|
26
|
-
const qs = token ? `?token=${encodeURIComponent(token)}` : '';
|
|
27
|
-
return `${base}${path}${qs}`;
|
|
28
|
-
}
|
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for createEventsHandler — POST /events, GET /events/ping, CORS.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
|
6
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
7
|
-
import { PROTOCOL_VERSION } from '@harness-fe/protocol';
|
|
8
|
-
import { createEventsHandler } from './eventsHandler.js';
|
|
9
|
-
|
|
10
|
-
// ─── Minimal IncomingMessage / ServerResponse mocks ──────────────────────────
|
|
11
|
-
|
|
12
|
-
function makeReq(opts: {
|
|
13
|
-
url: string;
|
|
14
|
-
method: string;
|
|
15
|
-
host?: string;
|
|
16
|
-
body?: string;
|
|
17
|
-
}): IncomingMessage {
|
|
18
|
-
const { Readable } = require('node:stream') as typeof import('node:stream');
|
|
19
|
-
const stream = new Readable({ read() {} });
|
|
20
|
-
const req = stream as unknown as IncomingMessage;
|
|
21
|
-
req.url = opts.url;
|
|
22
|
-
req.method = opts.method;
|
|
23
|
-
req.headers = opts.host ? { host: opts.host } : {};
|
|
24
|
-
if (opts.body !== undefined) {
|
|
25
|
-
process.nextTick(() => {
|
|
26
|
-
stream.push(Buffer.from(opts.body!));
|
|
27
|
-
stream.push(null);
|
|
28
|
-
});
|
|
29
|
-
} else {
|
|
30
|
-
process.nextTick(() => stream.push(null));
|
|
31
|
-
}
|
|
32
|
-
return req;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface MockRes {
|
|
36
|
-
statusCode: number;
|
|
37
|
-
headers: Record<string, string>;
|
|
38
|
-
body: string;
|
|
39
|
-
setHeader(name: string, value: string): void;
|
|
40
|
-
end(data?: string): void;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function makeRes(): MockRes {
|
|
44
|
-
const res: MockRes = {
|
|
45
|
-
statusCode: 200,
|
|
46
|
-
headers: {},
|
|
47
|
-
body: '',
|
|
48
|
-
setHeader(name, value) { this.headers[name.toLowerCase()] = value; },
|
|
49
|
-
end(data) { if (data) this.body = data; },
|
|
50
|
-
};
|
|
51
|
-
return res;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// ─── Bridge stub ─────────────────────────────────────────────────────────────
|
|
55
|
-
|
|
56
|
-
function makeBridge(onBatch?: (hello: unknown, events: unknown[]) => void) {
|
|
57
|
-
return {
|
|
58
|
-
handleHttpBatch: vi.fn((hello: unknown, events: unknown[]) => {
|
|
59
|
-
onBatch?.(hello, events);
|
|
60
|
-
}),
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
65
|
-
|
|
66
|
-
describe('eventsHandler', () => {
|
|
67
|
-
describe('GET /events/ping', () => {
|
|
68
|
-
it('returns 200 with ok + version', async () => {
|
|
69
|
-
const bridge = makeBridge();
|
|
70
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
71
|
-
const handler = createEventsHandler(bridge as any);
|
|
72
|
-
const req = makeReq({ url: '/events/ping', method: 'GET' });
|
|
73
|
-
const res = makeRes();
|
|
74
|
-
const handled = await handler(req, res as unknown as ServerResponse);
|
|
75
|
-
expect(handled).toBe(true);
|
|
76
|
-
expect(res.statusCode).toBe(200);
|
|
77
|
-
const payload = JSON.parse(res.body) as { ok: boolean; version: string };
|
|
78
|
-
expect(payload.ok).toBe(true);
|
|
79
|
-
expect(payload.version).toBe(PROTOCOL_VERSION);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('does not set CORS when host is not loopback', async () => {
|
|
83
|
-
const bridge = makeBridge();
|
|
84
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
85
|
-
const handler = createEventsHandler(bridge as any);
|
|
86
|
-
const req = makeReq({ url: '/events/ping', method: 'GET', host: 'example.com' });
|
|
87
|
-
const res = makeRes();
|
|
88
|
-
await handler(req, res as unknown as ServerResponse);
|
|
89
|
-
expect(res.headers['access-control-allow-origin']).toBeUndefined();
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('sets CORS when host is localhost', async () => {
|
|
93
|
-
const bridge = makeBridge();
|
|
94
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
95
|
-
const handler = createEventsHandler(bridge as any);
|
|
96
|
-
const req = makeReq({ url: '/events/ping', method: 'GET', host: 'localhost:47729' });
|
|
97
|
-
const res = makeRes();
|
|
98
|
-
await handler(req, res as unknown as ServerResponse);
|
|
99
|
-
expect(res.headers['access-control-allow-origin']).toBe('*');
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('sets CORS when host is 127.0.0.1', async () => {
|
|
103
|
-
const bridge = makeBridge();
|
|
104
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
105
|
-
const handler = createEventsHandler(bridge as any);
|
|
106
|
-
const req = makeReq({ url: '/events/ping', method: 'GET', host: '127.0.0.1:47729' });
|
|
107
|
-
const res = makeRes();
|
|
108
|
-
await handler(req, res as unknown as ServerResponse);
|
|
109
|
-
expect(res.headers['access-control-allow-origin']).toBe('*');
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
describe('OPTIONS preflight', () => {
|
|
114
|
-
it('returns 204 for /events', async () => {
|
|
115
|
-
const bridge = makeBridge();
|
|
116
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
117
|
-
const handler = createEventsHandler(bridge as any);
|
|
118
|
-
const req = makeReq({ url: '/events', method: 'OPTIONS', host: 'localhost:47729' });
|
|
119
|
-
const res = makeRes();
|
|
120
|
-
const handled = await handler(req, res as unknown as ServerResponse);
|
|
121
|
-
expect(handled).toBe(true);
|
|
122
|
-
expect(res.statusCode).toBe(204);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('returns 204 for /events/ping', async () => {
|
|
126
|
-
const bridge = makeBridge();
|
|
127
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
128
|
-
const handler = createEventsHandler(bridge as any);
|
|
129
|
-
const req = makeReq({ url: '/events/ping', method: 'OPTIONS', host: 'localhost:47729' });
|
|
130
|
-
const res = makeRes();
|
|
131
|
-
const handled = await handler(req, res as unknown as ServerResponse);
|
|
132
|
-
expect(handled).toBe(true);
|
|
133
|
-
expect(res.statusCode).toBe(204);
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
describe('POST /events', () => {
|
|
138
|
-
it('returns 204 and calls handleHttpBatch with valid body', async () => {
|
|
139
|
-
const bridge = makeBridge();
|
|
140
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
141
|
-
const handler = createEventsHandler(bridge as any);
|
|
142
|
-
const body = JSON.stringify({
|
|
143
|
-
hello: {
|
|
144
|
-
role: 'node-runtime',
|
|
145
|
-
projectId: 'test-proj',
|
|
146
|
-
sessionId: 'sess-abc',
|
|
147
|
-
buildId: 'build-1',
|
|
148
|
-
},
|
|
149
|
-
events: [
|
|
150
|
-
{ id: 'e1', name: 'server-err', ts: 1000, payload: { message: 'boom' } },
|
|
151
|
-
{ id: 'e2', name: 'server-log', ts: 1001, payload: { level: 'info' } },
|
|
152
|
-
],
|
|
153
|
-
});
|
|
154
|
-
const req = makeReq({ url: '/events', method: 'POST', host: 'localhost:47729', body });
|
|
155
|
-
const res = makeRes();
|
|
156
|
-
const handled = await handler(req, res as unknown as ServerResponse);
|
|
157
|
-
expect(handled).toBe(true);
|
|
158
|
-
expect(res.statusCode).toBe(204);
|
|
159
|
-
expect(bridge.handleHttpBatch).toHaveBeenCalledOnce();
|
|
160
|
-
const [hello, events] = bridge.handleHttpBatch.mock.calls[0] as [unknown, unknown[]];
|
|
161
|
-
expect((hello as { projectId: string }).projectId).toBe('test-proj');
|
|
162
|
-
expect(events).toHaveLength(2);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it('returns 400 for invalid JSON', async () => {
|
|
166
|
-
const bridge = makeBridge();
|
|
167
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
168
|
-
const handler = createEventsHandler(bridge as any);
|
|
169
|
-
const req = makeReq({ url: '/events', method: 'POST', host: 'localhost:47729', body: '{bad json' });
|
|
170
|
-
const res = makeRes();
|
|
171
|
-
const handled = await handler(req, res as unknown as ServerResponse);
|
|
172
|
-
expect(handled).toBe(true);
|
|
173
|
-
expect(res.statusCode).toBe(400);
|
|
174
|
-
expect(bridge.handleHttpBatch).not.toHaveBeenCalled();
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it('returns 400 when body fails schema validation (bad role)', async () => {
|
|
178
|
-
const bridge = makeBridge();
|
|
179
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
180
|
-
const handler = createEventsHandler(bridge as any);
|
|
181
|
-
const body = JSON.stringify({
|
|
182
|
-
hello: {
|
|
183
|
-
role: 'runtime-client', // wrong role
|
|
184
|
-
projectId: 'test',
|
|
185
|
-
},
|
|
186
|
-
events: [],
|
|
187
|
-
});
|
|
188
|
-
const req = makeReq({ url: '/events', method: 'POST', host: 'localhost:47729', body });
|
|
189
|
-
const res = makeRes();
|
|
190
|
-
await handler(req, res as unknown as ServerResponse);
|
|
191
|
-
expect(res.statusCode).toBe(400);
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it('returns 400 when hello.projectId is missing', async () => {
|
|
195
|
-
const bridge = makeBridge();
|
|
196
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
197
|
-
const handler = createEventsHandler(bridge as any);
|
|
198
|
-
const body = JSON.stringify({
|
|
199
|
-
hello: { role: 'node-runtime' },
|
|
200
|
-
events: [],
|
|
201
|
-
});
|
|
202
|
-
const req = makeReq({ url: '/events', method: 'POST', host: 'localhost:47729', body });
|
|
203
|
-
const res = makeRes();
|
|
204
|
-
await handler(req, res as unknown as ServerResponse);
|
|
205
|
-
expect(res.statusCode).toBe(400);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it('returns 500 when handleHttpBatch throws', async () => {
|
|
209
|
-
const bridge = {
|
|
210
|
-
handleHttpBatch: vi.fn(() => { throw new Error('store error'); }),
|
|
211
|
-
};
|
|
212
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
213
|
-
const handler = createEventsHandler(bridge as any);
|
|
214
|
-
const body = JSON.stringify({
|
|
215
|
-
hello: { role: 'node-runtime', projectId: 'test', sessionId: 's1' },
|
|
216
|
-
events: [],
|
|
217
|
-
});
|
|
218
|
-
const req = makeReq({ url: '/events', method: 'POST', host: 'localhost:47729', body });
|
|
219
|
-
const res = makeRes();
|
|
220
|
-
const handled = await handler(req, res as unknown as ServerResponse);
|
|
221
|
-
expect(handled).toBe(true);
|
|
222
|
-
expect(res.statusCode).toBe(500);
|
|
223
|
-
});
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
describe('fall-through for unmatched routes', () => {
|
|
227
|
-
it('returns false for /other', async () => {
|
|
228
|
-
const bridge = makeBridge();
|
|
229
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
230
|
-
const handler = createEventsHandler(bridge as any);
|
|
231
|
-
const req = makeReq({ url: '/other', method: 'GET' });
|
|
232
|
-
const res = makeRes();
|
|
233
|
-
const handled = await handler(req, res as unknown as ServerResponse);
|
|
234
|
-
expect(handled).toBe(false);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it('returns false for /events/unknown-sub-path', async () => {
|
|
238
|
-
const bridge = makeBridge();
|
|
239
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
240
|
-
const handler = createEventsHandler(bridge as any);
|
|
241
|
-
const req = makeReq({ url: '/events/unknown', method: 'GET' });
|
|
242
|
-
const res = makeRes();
|
|
243
|
-
const handled = await handler(req, res as unknown as ServerResponse);
|
|
244
|
-
expect(handled).toBe(false);
|
|
245
|
-
});
|
|
246
|
-
});
|
|
247
|
-
});
|
package/src/eventsHandler.ts
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTTP POST /events handler — stateless alternative to the WebSocket path.
|
|
3
|
-
*
|
|
4
|
-
* Accepts a JSON body matching `httpBatchSchema`:
|
|
5
|
-
* { hello: { role: 'node-runtime', projectId, sessionId, ... },
|
|
6
|
-
* events: [ { id, name, ts, payload, ... }, ... ] }
|
|
7
|
-
*
|
|
8
|
-
* Each POST is treated as a one-shot hello+events sequence:
|
|
9
|
-
* 1. Register (or re-register) the peer via the same bridge internals.
|
|
10
|
-
* 2. Persist every event to the right session timeline.
|
|
11
|
-
* 3. Respond 204 No Content.
|
|
12
|
-
*
|
|
13
|
-
* Also handles:
|
|
14
|
-
* GET /events/ping → 200 { ok: true, version }
|
|
15
|
-
* OPTIONS /events → 204 CORS preflight
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
19
|
-
import { randomUUID } from 'node:crypto';
|
|
20
|
-
import { PROTOCOL_VERSION, httpBatchSchema } from '@harness-fe/protocol';
|
|
21
|
-
import type { Bridge } from './bridge.js';
|
|
22
|
-
|
|
23
|
-
// ─── CORS helpers ────────────────────────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
function isLoopback(req: IncomingMessage): boolean {
|
|
26
|
-
const host = req.headers['host'] ?? '';
|
|
27
|
-
// Match 127.0.0.1:* and localhost:*
|
|
28
|
-
return /^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function setCorsHeaders(res: ServerResponse): void {
|
|
32
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
33
|
-
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
|
34
|
-
res.setHeader('Access-Control-Allow-Headers', 'content-type');
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// ─── Body reader ─────────────────────────────────────────────────────────────
|
|
38
|
-
|
|
39
|
-
function readBody(req: IncomingMessage): Promise<string> {
|
|
40
|
-
return new Promise((resolve, reject) => {
|
|
41
|
-
const chunks: Buffer[] = [];
|
|
42
|
-
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
43
|
-
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
44
|
-
req.on('error', reject);
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// ─── Factory ─────────────────────────────────────────────────────────────────
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Returns a handler that matches /events/* routes and returns `true` when it
|
|
52
|
-
* handled the request (so the bridge can short-circuit its 404 fallback).
|
|
53
|
-
*/
|
|
54
|
-
export function createEventsHandler(
|
|
55
|
-
bridge: Bridge,
|
|
56
|
-
): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
|
|
57
|
-
return async (req, res): Promise<boolean> => {
|
|
58
|
-
const url = req.url ?? '';
|
|
59
|
-
const method = req.method ?? 'GET';
|
|
60
|
-
|
|
61
|
-
// Only intercept /events and /events/ping
|
|
62
|
-
if (url !== '/events' && url !== '/events/ping') return false;
|
|
63
|
-
|
|
64
|
-
// CORS: only emit headers when request comes from loopback
|
|
65
|
-
if (isLoopback(req)) {
|
|
66
|
-
setCorsHeaders(res);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Preflight for both routes
|
|
70
|
-
if (method === 'OPTIONS') {
|
|
71
|
-
res.statusCode = 204;
|
|
72
|
-
res.end();
|
|
73
|
-
return true;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// GET /events/ping
|
|
77
|
-
if (url === '/events/ping' && method === 'GET') {
|
|
78
|
-
res.statusCode = 200;
|
|
79
|
-
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
80
|
-
res.end(JSON.stringify({ ok: true, version: PROTOCOL_VERSION }));
|
|
81
|
-
return true;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// POST /events
|
|
85
|
-
if (url === '/events' && method === 'POST') {
|
|
86
|
-
let rawBody: string;
|
|
87
|
-
try {
|
|
88
|
-
rawBody = await readBody(req);
|
|
89
|
-
} catch {
|
|
90
|
-
res.statusCode = 400;
|
|
91
|
-
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
92
|
-
res.end(JSON.stringify({ error: 'failed to read request body' }));
|
|
93
|
-
return true;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
let parsed: unknown;
|
|
97
|
-
try {
|
|
98
|
-
parsed = JSON.parse(rawBody);
|
|
99
|
-
} catch {
|
|
100
|
-
res.statusCode = 400;
|
|
101
|
-
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
102
|
-
res.end(JSON.stringify({ error: 'invalid JSON' }));
|
|
103
|
-
return true;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const validated = httpBatchSchema.safeParse(parsed);
|
|
107
|
-
if (!validated.success) {
|
|
108
|
-
res.statusCode = 400;
|
|
109
|
-
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
110
|
-
res.end(JSON.stringify({ error: validated.error.message }));
|
|
111
|
-
return true;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const { hello, events } = validated.data;
|
|
115
|
-
|
|
116
|
-
try {
|
|
117
|
-
bridge.handleHttpBatch(hello, events);
|
|
118
|
-
} catch (err) {
|
|
119
|
-
res.statusCode = 500;
|
|
120
|
-
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
121
|
-
res.end(JSON.stringify({
|
|
122
|
-
error: err instanceof Error ? err.message : 'internal error',
|
|
123
|
-
}));
|
|
124
|
-
return true;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
res.statusCode = 204;
|
|
128
|
-
res.end();
|
|
129
|
-
return true;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
return false;
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export type EventsHandler = ReturnType<typeof createEventsHandler>;
|
package/src/identity.test.ts
DELETED
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import type { IncomingMessage } from 'node:http';
|
|
3
|
-
import {
|
|
4
|
-
HOST_PRINCIPAL,
|
|
5
|
-
LOCAL_PRINCIPAL,
|
|
6
|
-
canSee,
|
|
7
|
-
identifyPrincipal,
|
|
8
|
-
resolvePrincipal,
|
|
9
|
-
tokenPrincipalId,
|
|
10
|
-
} from './identity.js';
|
|
11
|
-
|
|
12
|
-
function fakeReq(init: { headers?: Record<string, string>; url?: string } = {}): IncomingMessage {
|
|
13
|
-
return {
|
|
14
|
-
headers: init.headers ?? {},
|
|
15
|
-
url: init.url ?? '/',
|
|
16
|
-
} as unknown as IncomingMessage;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
describe('identity: tokenPrincipalId', () => {
|
|
20
|
-
it('is stable + prefixed + hashed (never the raw token)', () => {
|
|
21
|
-
const id = tokenPrincipalId('super-secret');
|
|
22
|
-
expect(id).toMatch(/^token:[0-9a-f]{12}$/);
|
|
23
|
-
expect(id).toBe(tokenPrincipalId('super-secret'));
|
|
24
|
-
expect(id).not.toContain('super-secret');
|
|
25
|
-
});
|
|
26
|
-
it('different tokens → different ids', () => {
|
|
27
|
-
expect(tokenPrincipalId('a')).not.toBe(tokenPrincipalId('b'));
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
describe('identity: resolvePrincipal', () => {
|
|
32
|
-
it('loopback (auth disabled) → LOCAL_PRINCIPAL', () => {
|
|
33
|
-
expect(resolvePrincipal(fakeReq(), {})).toBe(LOCAL_PRINCIPAL);
|
|
34
|
-
expect(resolvePrincipal(fakeReq(), { token: '' })).toBe(LOCAL_PRINCIPAL);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('token mode: matching token → token principal', () => {
|
|
38
|
-
const req = fakeReq({ headers: { authorization: 'Bearer s3cr3t' } });
|
|
39
|
-
const p = resolvePrincipal(req, { token: 's3cr3t' });
|
|
40
|
-
expect(p).toEqual({ id: tokenPrincipalId('s3cr3t'), kind: 'token' });
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('token mode: missing/wrong token → null (mirrors deny)', () => {
|
|
44
|
-
expect(resolvePrincipal(fakeReq(), { token: 's3cr3t' })).toBeNull();
|
|
45
|
-
expect(
|
|
46
|
-
resolvePrincipal(fakeReq({ headers: { authorization: 'Bearer nope' } }), { token: 's3cr3t' }),
|
|
47
|
-
).toBeNull();
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('custom authorize: accept → HOST_PRINCIPAL, reject → null', () => {
|
|
51
|
-
expect(resolvePrincipal(fakeReq(), { authorize: () => true })).toBe(HOST_PRINCIPAL);
|
|
52
|
-
expect(resolvePrincipal(fakeReq(), { authorize: () => false })).toBeNull();
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('authorize wins over token (same precedence as isAuthorized)', () => {
|
|
56
|
-
const req = fakeReq({ headers: { authorization: 'Bearer wrong' } });
|
|
57
|
-
expect(resolvePrincipal(req, { token: 'right', authorize: () => true })).toBe(HOST_PRINCIPAL);
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
describe('identity: identifyPrincipal (P4 — identify, not authorize)', () => {
|
|
62
|
-
it('no auth → local', () => {
|
|
63
|
-
expect(identifyPrincipal({ authorization: 'Bearer x' }, {})).toBe(LOCAL_PRINCIPAL);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('stdio (no headers) → local even when a token is configured', () => {
|
|
67
|
-
expect(identifyPrincipal(undefined, { token: 's3cr3t' })).toBe(LOCAL_PRINCIPAL);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('token mode: names the caller from the Authorization header', () => {
|
|
71
|
-
const p = identifyPrincipal({ authorization: 'Bearer s3cr3t' }, { token: 's3cr3t' });
|
|
72
|
-
expect(p).toEqual({ id: tokenPrincipalId('s3cr3t'), kind: 'token' });
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('token mode: handles array-valued headers', () => {
|
|
76
|
-
const p = identifyPrincipal({ authorization: ['Bearer s3cr3t'] }, { token: 's3cr3t' });
|
|
77
|
-
expect(p.id).toBe(tokenPrincipalId('s3cr3t'));
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('token mode without a bearer header → local (already past auth wrapper)', () => {
|
|
81
|
-
expect(identifyPrincipal({}, { token: 's3cr3t' })).toBe(LOCAL_PRINCIPAL);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('authorize mode → host', () => {
|
|
85
|
-
expect(identifyPrincipal({ authorization: 'Bearer x' }, { authorize: () => true })).toBe(HOST_PRINCIPAL);
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
describe('identity: canSee (P3 tenant visibility)', () => {
|
|
90
|
-
const tokenA = { id: 'token:aaa', kind: 'token' as const };
|
|
91
|
-
const tokenB = { id: 'token:bbb', kind: 'token' as const };
|
|
92
|
-
|
|
93
|
-
it('local sees everything (zero behaviour change for solo dev)', () => {
|
|
94
|
-
expect(canSee(LOCAL_PRINCIPAL, 'token:aaa')).toBe(true);
|
|
95
|
-
expect(canSee(LOCAL_PRINCIPAL, undefined)).toBe(true);
|
|
96
|
-
expect(canSee(LOCAL_PRINCIPAL, null)).toBe(true);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('unowned data (no createdBy) is visible to everyone', () => {
|
|
100
|
-
expect(canSee(tokenA, undefined)).toBe(true);
|
|
101
|
-
expect(canSee(tokenA, null)).toBe(true);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('named principal sees only its own owned data', () => {
|
|
105
|
-
expect(canSee(tokenA, 'token:aaa')).toBe(true);
|
|
106
|
-
expect(canSee(tokenA, 'token:bbb')).toBe(false);
|
|
107
|
-
expect(canSee(tokenB, 'token:aaa')).toBe(false);
|
|
108
|
-
});
|
|
109
|
-
});
|
package/src/identity.ts
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Caller identity (4.0 · P1) — turns the auth boundary from a plain
|
|
3
|
-
* allow/deny into "allow/deny + *who*".
|
|
4
|
-
*
|
|
5
|
-
* Phase 1 scope: this module only *establishes* and *carries* a Principal,
|
|
6
|
-
* and the bridge *tags* writes with it (createdBy). It deliberately does NOT
|
|
7
|
-
* filter reads by owner — that's P3 (tenant isolation). Keeping the two apart
|
|
8
|
-
* means identity plumbing lands with zero behaviour change: loopback solo dev
|
|
9
|
-
* stays a single implicit `local` principal, and an authorized caller sees
|
|
10
|
-
* everything exactly as before.
|
|
11
|
-
*
|
|
12
|
-
* Why a separate module from auth.ts: `isAuthorized` answers a boolean and is
|
|
13
|
-
* consumed on the hot path of every HTTP route / WS upgrade. `resolvePrincipal`
|
|
14
|
-
* is the richer, additive view layered on top — it reuses the same primitives
|
|
15
|
-
* (isAuthEnabled / extractToken / verifyToken) so the two can never disagree on
|
|
16
|
-
* who is allowed in.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { createHash } from 'node:crypto';
|
|
20
|
-
import type { IncomingMessage } from 'node:http';
|
|
21
|
-
|
|
22
|
-
import { extractToken, isAuthEnabled, verifyToken, type AuthOptions } from './auth.js';
|
|
23
|
-
|
|
24
|
-
export type PrincipalKind = 'local' | 'token' | 'host';
|
|
25
|
-
|
|
26
|
-
export interface Principal {
|
|
27
|
-
/** Stable id for this caller. Loopback / stdio solo dev → `local`. */
|
|
28
|
-
id: string;
|
|
29
|
-
/** How the identity was established. */
|
|
30
|
-
kind: PrincipalKind;
|
|
31
|
-
/** Optional human-readable label (for dashboards / audit). */
|
|
32
|
-
displayName?: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* The implicit single principal for loopback and stdio solo dev. The daemon
|
|
37
|
-
* trusts everything that can reach the loopback socket, so there is one
|
|
38
|
-
* caller and it owns everything — exactly today's behaviour, now named.
|
|
39
|
-
*/
|
|
40
|
-
export const LOCAL_PRINCIPAL: Principal = Object.freeze({
|
|
41
|
-
id: 'local',
|
|
42
|
-
kind: 'local',
|
|
43
|
-
displayName: 'local',
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Principal for the custom-`authorize` path. Hosts that embed the daemon own
|
|
48
|
-
* their own user model; until `authorize` can return a richer identity
|
|
49
|
-
* (future work), an authorized host caller maps to this single principal.
|
|
50
|
-
*/
|
|
51
|
-
export const HOST_PRINCIPAL: Principal = Object.freeze({
|
|
52
|
-
id: 'host',
|
|
53
|
-
kind: 'host',
|
|
54
|
-
displayName: 'host',
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Derive a stable principal id from a bearer token. One token = one principal
|
|
59
|
-
* in 4.0's trusted-team model. We hash so the raw secret never becomes an id
|
|
60
|
-
* that could leak into stored `createdBy` tags or audit logs.
|
|
61
|
-
*/
|
|
62
|
-
export function tokenPrincipalId(token: string): string {
|
|
63
|
-
return `token:${createHash('sha256').update(token).digest('hex').slice(0, 12)}`;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Resolve the caller behind a request.
|
|
68
|
-
*
|
|
69
|
-
* Returns `null` when auth is enabled and the request is NOT authorized — this
|
|
70
|
-
* mirrors `isAuthorized(req) === false` exactly, so callers can treat a null
|
|
71
|
-
* principal as "reject" without a second auth check.
|
|
72
|
-
*
|
|
73
|
-
* - auth disabled (loopback): {@link LOCAL_PRINCIPAL}
|
|
74
|
-
* - custom `authorize`: {@link HOST_PRINCIPAL} when it accepts, else `null`
|
|
75
|
-
* - token: a {@link tokenPrincipalId}-derived principal when it matches, else `null`
|
|
76
|
-
*/
|
|
77
|
-
export function resolvePrincipal(req: IncomingMessage, opts: AuthOptions): Principal | null {
|
|
78
|
-
if (!isAuthEnabled(opts)) return LOCAL_PRINCIPAL;
|
|
79
|
-
if (opts.authorize) return opts.authorize(req) ? HOST_PRINCIPAL : null;
|
|
80
|
-
const token = extractToken(req, opts);
|
|
81
|
-
if (!verifyToken(token, opts.token!)) return null;
|
|
82
|
-
return { id: tokenPrincipalId(token!), kind: 'token' };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
type HeaderBag = Record<string, string | string[] | undefined>;
|
|
86
|
-
|
|
87
|
-
function bearerFromHeaders(headers: HeaderBag): string | undefined {
|
|
88
|
-
const raw = headers['authorization'] ?? headers['Authorization'];
|
|
89
|
-
const v = Array.isArray(raw) ? raw[0] : raw;
|
|
90
|
-
if (typeof v === 'string' && v.startsWith('Bearer ')) {
|
|
91
|
-
const t = v.slice(7).trim();
|
|
92
|
-
if (t) return t;
|
|
93
|
-
}
|
|
94
|
-
return undefined;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Identify (not authorize) the caller behind an MCP tool call (4.0 · P4).
|
|
99
|
-
*
|
|
100
|
-
* The HTTP MCP request has already cleared the bridge's auth wrapper by the
|
|
101
|
-
* time a tool runs, so this only needs to *name* the caller — never to
|
|
102
|
-
* re-check them. Pass the per-request headers from the MCP SDK's
|
|
103
|
-
* `extra.requestInfo`; stdio calls have no requestInfo and resolve to `local`
|
|
104
|
-
* (the daemon trusts its local stdio agent).
|
|
105
|
-
*
|
|
106
|
-
* - auth disabled, or no headers (stdio) → {@link LOCAL_PRINCIPAL}
|
|
107
|
-
* - custom authorize → {@link HOST_PRINCIPAL}
|
|
108
|
-
* - token mode → token principal from the Authorization header (LOCAL if absent)
|
|
109
|
-
*/
|
|
110
|
-
export function identifyPrincipal(headers: HeaderBag | undefined, opts: AuthOptions): Principal {
|
|
111
|
-
if (!isAuthEnabled(opts)) return LOCAL_PRINCIPAL;
|
|
112
|
-
if (opts.authorize) return HOST_PRINCIPAL;
|
|
113
|
-
if (!headers) return LOCAL_PRINCIPAL;
|
|
114
|
-
const token = bearerFromHeaders(headers);
|
|
115
|
-
return token ? { id: tokenPrincipalId(token), kind: 'token' } : LOCAL_PRINCIPAL;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Tenant-isolation visibility check (4.0 · P3). Decides whether `principal`
|
|
120
|
-
* may see a record tagged with `createdBy`.
|
|
121
|
-
*
|
|
122
|
-
* - `local` (loopback / stdio solo / no-auth) → sees everything. This keeps
|
|
123
|
-
* solo dev's behaviour completely unchanged.
|
|
124
|
-
* - unowned data (`createdBy` null/undefined — legacy rows from before P1, or
|
|
125
|
-
* records the daemon never tagged) → visible to everyone (backward compat).
|
|
126
|
-
* - otherwise → visible only to the principal that created it.
|
|
127
|
-
*
|
|
128
|
-
* Note: in the current single-token / loopback reality the data creator
|
|
129
|
-
* (plugin / runtime client) and the querying agent share one principal, so
|
|
130
|
-
* this is exact. A full `project → agent` binding (creator ≠ consumer, once
|
|
131
|
-
* P6 splits write/read scopes) is deferred to P6.
|
|
132
|
-
*/
|
|
133
|
-
export function canSee(principal: Principal, createdBy: string | null | undefined): boolean {
|
|
134
|
-
if (principal.kind === 'local') return true;
|
|
135
|
-
if (createdBy == null) return true;
|
|
136
|
-
return createdBy === principal.id;
|
|
137
|
-
}
|