@harness-fe/mcp-server 4.0.0-next.1 → 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 +49 -15
- 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 +51 -19
- 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 -74
- package/dist/identity.js +0 -101
- 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 -86
- package/src/identity.ts +0 -116
- 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/dashboardApi.ts
DELETED
|
@@ -1,184 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* JSON API surface consumed by `@harness-fe/dashboard-ui` (the React SPA).
|
|
3
|
-
*
|
|
4
|
-
* The shape mirrors what the legacy server-rendered dashboard.ts displayed,
|
|
5
|
-
* but as JSON so the SPA can render it with proper components and live
|
|
6
|
-
* updates. Routes live under `/api/*` to keep them clearly separated from
|
|
7
|
-
* SPA assets (`/dashboard/*`) and the replay viewer (`/replay/*`).
|
|
8
|
-
*
|
|
9
|
-
* Reuses `createReplayExport` from replayCreate.ts for the replay POST —
|
|
10
|
-
* same logic the legacy dashboard's form submission ran through, just
|
|
11
|
-
* returns JSON instead of redirecting.
|
|
12
|
-
*
|
|
13
|
-
* Auth is already enforced by `isAuthorized` in bridge.ts before this
|
|
14
|
-
* handler runs, so we never need to check tokens here.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
18
|
-
import type {
|
|
19
|
-
IStore,
|
|
20
|
-
ProjectMeta,
|
|
21
|
-
RecordingChunkSummary,
|
|
22
|
-
ReplayExportMeta,
|
|
23
|
-
SessionMeta,
|
|
24
|
-
SessionSummary,
|
|
25
|
-
StoreEvent,
|
|
26
|
-
} from './store/types.js';
|
|
27
|
-
import { createReplayExport, type ReplayCreateResult } from './replayCreate.js';
|
|
28
|
-
|
|
29
|
-
const TIMELINE_DEFAULT_TAIL = 100;
|
|
30
|
-
const SESSIONS_PER_PROJECT = 10;
|
|
31
|
-
|
|
32
|
-
export interface ProjectListEntry {
|
|
33
|
-
project: ProjectMeta;
|
|
34
|
-
recentSessions: SessionMeta[];
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface SessionDetailResponse {
|
|
38
|
-
session: SessionMeta;
|
|
39
|
-
summary: SessionSummary;
|
|
40
|
-
chunks: RecordingChunkSummary[];
|
|
41
|
-
timeline: StoreEvent[];
|
|
42
|
-
exports: ReplayExportMeta[];
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface ReplayCreateBody {
|
|
46
|
-
tabId?: string;
|
|
47
|
-
ts?: number;
|
|
48
|
-
windowMs?: number;
|
|
49
|
-
since?: number;
|
|
50
|
-
until?: number;
|
|
51
|
-
label?: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function createDashboardApiHandler(
|
|
55
|
-
store: IStore,
|
|
56
|
-
getBaseUrl: () => string | undefined,
|
|
57
|
-
onExportCreated?: (input: { sessionId: string; projectId?: string }) => void,
|
|
58
|
-
): (req: IncomingMessage, res: ServerResponse) => boolean | Promise<boolean> {
|
|
59
|
-
return async (req, res) => {
|
|
60
|
-
if (!req.url) return false;
|
|
61
|
-
const url = new URL(req.url, 'http://localhost');
|
|
62
|
-
const path = url.pathname;
|
|
63
|
-
if (!path.startsWith('/api/')) return false;
|
|
64
|
-
const method = req.method ?? 'GET';
|
|
65
|
-
|
|
66
|
-
// GET /api/projects
|
|
67
|
-
if (method === 'GET' && path === '/api/projects') {
|
|
68
|
-
const projects = store.listProjects();
|
|
69
|
-
const entries: ProjectListEntry[] = projects.map((project) => {
|
|
70
|
-
const recentSessions = store.listSessions({
|
|
71
|
-
projectId: project.id,
|
|
72
|
-
limit: SESSIONS_PER_PROJECT,
|
|
73
|
-
});
|
|
74
|
-
return { project, recentSessions };
|
|
75
|
-
});
|
|
76
|
-
sendJson(res, 200, { projects: entries });
|
|
77
|
-
return true;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// GET /api/sessions?projectId=&tabId=&buildId=&limit=
|
|
81
|
-
if (method === 'GET' && path === '/api/sessions') {
|
|
82
|
-
const sessions = store.listSessions({
|
|
83
|
-
projectId: url.searchParams.get('projectId') ?? undefined,
|
|
84
|
-
tabId: url.searchParams.get('tabId') ?? undefined,
|
|
85
|
-
buildId: url.searchParams.get('buildId') ?? undefined,
|
|
86
|
-
limit: parseIntOr(url.searchParams.get('limit'), 50),
|
|
87
|
-
});
|
|
88
|
-
sendJson(res, 200, { sessions });
|
|
89
|
-
return true;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// /api/sessions/:id and /api/sessions/:id/replay
|
|
93
|
-
const sessionMatch = path.match(/^\/api\/sessions\/([^/]+)(\/replay)?$/);
|
|
94
|
-
if (sessionMatch) {
|
|
95
|
-
const sessionId = decodeURIComponent(sessionMatch[1]!);
|
|
96
|
-
const isReplay = !!sessionMatch[2];
|
|
97
|
-
|
|
98
|
-
if (isReplay && method === 'POST') {
|
|
99
|
-
let body: ReplayCreateBody;
|
|
100
|
-
try {
|
|
101
|
-
body = await readJsonBody(req);
|
|
102
|
-
} catch (err) {
|
|
103
|
-
sendJson(res, 400, { error: `invalid JSON body: ${(err as Error).message}` });
|
|
104
|
-
return true;
|
|
105
|
-
}
|
|
106
|
-
const result: ReplayCreateResult = createReplayExport(store, getBaseUrl(), {
|
|
107
|
-
sessionId,
|
|
108
|
-
tabId: body.tabId,
|
|
109
|
-
ts: body.ts,
|
|
110
|
-
windowMs: body.windowMs,
|
|
111
|
-
since: body.since,
|
|
112
|
-
until: body.until,
|
|
113
|
-
label: body.label,
|
|
114
|
-
});
|
|
115
|
-
const status = result.error ? 400 : 200;
|
|
116
|
-
if (!result.error && result.exportId && onExportCreated) {
|
|
117
|
-
// Find the session's project so subscribers can filter.
|
|
118
|
-
const projectId = store.getSession(sessionId)?.participants[0]?.projectId;
|
|
119
|
-
onExportCreated({ sessionId, projectId });
|
|
120
|
-
}
|
|
121
|
-
sendJson(res, status, result);
|
|
122
|
-
return true;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (method === 'GET') {
|
|
126
|
-
const session = store.getSession(sessionId);
|
|
127
|
-
if (!session) {
|
|
128
|
-
sendJson(res, 404, { error: 'session not found', sessionId });
|
|
129
|
-
return true;
|
|
130
|
-
}
|
|
131
|
-
const summary = store.summary(sessionId);
|
|
132
|
-
const chunks = store.listRecordings(sessionId);
|
|
133
|
-
const tailN = parseIntOr(url.searchParams.get('timeline'), TIMELINE_DEFAULT_TAIL);
|
|
134
|
-
const timeline = store.tail(sessionId, { n: tailN });
|
|
135
|
-
// Exports for the session's owning project (filter by sessionId).
|
|
136
|
-
const projectId = session.participants[0]?.projectId ?? '';
|
|
137
|
-
const exports = projectId
|
|
138
|
-
? store.listExports(projectId, 50).filter((e) => e.sessionId === sessionId)
|
|
139
|
-
: [];
|
|
140
|
-
const body: SessionDetailResponse = {
|
|
141
|
-
session,
|
|
142
|
-
summary,
|
|
143
|
-
chunks,
|
|
144
|
-
timeline,
|
|
145
|
-
exports,
|
|
146
|
-
};
|
|
147
|
-
sendJson(res, 200, body);
|
|
148
|
-
return true;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Any other /api/ path → 404 (consumed so the legacy handler doesn't try).
|
|
153
|
-
sendJson(res, 404, { error: 'not found', path });
|
|
154
|
-
return true;
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
159
|
-
res.statusCode = status;
|
|
160
|
-
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
161
|
-
res.setHeader('cache-control', 'no-store');
|
|
162
|
-
res.end(JSON.stringify(body));
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
async function readJsonBody(req: IncomingMessage): Promise<ReplayCreateBody> {
|
|
166
|
-
const chunks: Buffer[] = [];
|
|
167
|
-
let total = 0;
|
|
168
|
-
const MAX = 1024 * 1024; // 1 MB — replay create bodies are tiny; cap to defend against DoS
|
|
169
|
-
for await (const chunk of req) {
|
|
170
|
-
const buf = chunk as Buffer;
|
|
171
|
-
total += buf.length;
|
|
172
|
-
if (total > MAX) throw new Error(`request body exceeds ${MAX} bytes`);
|
|
173
|
-
chunks.push(buf);
|
|
174
|
-
}
|
|
175
|
-
if (chunks.length === 0) return {};
|
|
176
|
-
const text = Buffer.concat(chunks).toString('utf-8');
|
|
177
|
-
return JSON.parse(text) as ReplayCreateBody;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function parseIntOr(raw: string | null, fallback: number): number {
|
|
181
|
-
if (raw == null) return fallback;
|
|
182
|
-
const n = Number.parseInt(raw, 10);
|
|
183
|
-
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
184
|
-
}
|
package/src/dashboardSpa.test.ts
DELETED
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the dashboard SPA static handler.
|
|
3
|
-
*
|
|
4
|
-
* These tests don't exercise the React app itself (that lives in
|
|
5
|
-
* `@harness-fe/dashboard-ui`); they verify mcp-server can find the
|
|
6
|
-
* built dist, serves index.html with the right headers, and falls back
|
|
7
|
-
* to index.html for arbitrary client-side routes.
|
|
8
|
-
*/
|
|
9
|
-
import { afterEach, describe, expect, it } from 'vitest';
|
|
10
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
11
|
-
import { tmpdir } from 'node:os';
|
|
12
|
-
import { join } from 'node:path';
|
|
13
|
-
import { Bridge } from './bridge.js';
|
|
14
|
-
import { JsonlStore } from './store/index.js';
|
|
15
|
-
|
|
16
|
-
const tempDirs: string[] = [];
|
|
17
|
-
function mkTmp(): string {
|
|
18
|
-
const dir = mkdtempSync(join(tmpdir(), 'harness-spa-test-'));
|
|
19
|
-
tempDirs.push(dir);
|
|
20
|
-
return dir;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
afterEach(async () => {
|
|
24
|
-
while (tempDirs.length) {
|
|
25
|
-
const d = tempDirs.pop()!;
|
|
26
|
-
try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
27
|
-
}
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
async function bootBridge() {
|
|
31
|
-
const dir = mkTmp();
|
|
32
|
-
const store = new JsonlStore(dir);
|
|
33
|
-
const bridge = new Bridge({ port: 0, host: '127.0.0.1', store, taskStore: null, memoryStore: null });
|
|
34
|
-
await bridge.start();
|
|
35
|
-
const port = bridge.getBoundPort();
|
|
36
|
-
if (!port) throw new Error('no port');
|
|
37
|
-
return { bridge, store, port };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
describe('Dashboard SPA handler — token handoff', () => {
|
|
41
|
-
async function bootBridgeWithAuth() {
|
|
42
|
-
const dir = mkTmp();
|
|
43
|
-
const store = new JsonlStore(dir);
|
|
44
|
-
const bridge = new Bridge({
|
|
45
|
-
port: 0,
|
|
46
|
-
host: '127.0.0.1',
|
|
47
|
-
store,
|
|
48
|
-
taskStore: null,
|
|
49
|
-
memoryStore: null,
|
|
50
|
-
auth: { token: 'secret-token' },
|
|
51
|
-
});
|
|
52
|
-
await bridge.start();
|
|
53
|
-
const port = bridge.getBoundPort();
|
|
54
|
-
if (!port) throw new Error('no port');
|
|
55
|
-
return { bridge, port };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
it('first /dashboard/?token=… visit sets the cookie and redirects to a clean URL', async () => {
|
|
59
|
-
const { bridge, port } = await bootBridgeWithAuth();
|
|
60
|
-
try {
|
|
61
|
-
const resp = await fetch(
|
|
62
|
-
`http://127.0.0.1:${port}/dashboard/?token=secret-token`,
|
|
63
|
-
{ redirect: 'manual' },
|
|
64
|
-
);
|
|
65
|
-
expect(resp.status).toBe(302);
|
|
66
|
-
expect(resp.headers.get('location')).toBe('/dashboard/');
|
|
67
|
-
const cookie = resp.headers.get('set-cookie') ?? '';
|
|
68
|
-
expect(cookie).toContain('harness_fe_token=secret-token');
|
|
69
|
-
expect(cookie).toMatch(/Path=\//);
|
|
70
|
-
expect(cookie).toMatch(/SameSite=Lax/i);
|
|
71
|
-
} finally {
|
|
72
|
-
await bridge.stop();
|
|
73
|
-
}
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('subsequent SPA fetch with the cookie alone is authorized', async () => {
|
|
77
|
-
const { bridge, port } = await bootBridgeWithAuth();
|
|
78
|
-
try {
|
|
79
|
-
const denied = await fetch(`http://127.0.0.1:${port}/dashboard/`);
|
|
80
|
-
expect(denied.status).toBe(401);
|
|
81
|
-
const ok = await fetch(`http://127.0.0.1:${port}/dashboard/`, {
|
|
82
|
-
headers: { cookie: 'harness_fe_token=secret-token' },
|
|
83
|
-
});
|
|
84
|
-
expect(ok.status).toBe(200);
|
|
85
|
-
} finally {
|
|
86
|
-
await bridge.stop();
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('does not loop-redirect when the cookie is already present', async () => {
|
|
91
|
-
const { bridge, port } = await bootBridgeWithAuth();
|
|
92
|
-
try {
|
|
93
|
-
const resp = await fetch(
|
|
94
|
-
`http://127.0.0.1:${port}/dashboard/?token=secret-token`,
|
|
95
|
-
{
|
|
96
|
-
redirect: 'manual',
|
|
97
|
-
headers: { cookie: 'harness_fe_token=secret-token' },
|
|
98
|
-
},
|
|
99
|
-
);
|
|
100
|
-
expect(resp.status).toBe(200);
|
|
101
|
-
} finally {
|
|
102
|
-
await bridge.stop();
|
|
103
|
-
}
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
describe('Dashboard SPA handler', () => {
|
|
108
|
-
it('GET / redirects to /dashboard/ preserving query (legacy root)', async () => {
|
|
109
|
-
const { bridge, port } = await bootBridge();
|
|
110
|
-
try {
|
|
111
|
-
const resp = await fetch(`http://127.0.0.1:${port}/?token=xyz`, { redirect: 'manual' });
|
|
112
|
-
expect(resp.status).toBe(302);
|
|
113
|
-
expect(resp.headers.get('location')).toBe('/dashboard/?token=xyz');
|
|
114
|
-
} finally {
|
|
115
|
-
await bridge.stop();
|
|
116
|
-
}
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('GET /sessions/:id redirects to /dashboard/sessions/:id (legacy bookmark)', async () => {
|
|
120
|
-
const { bridge, port } = await bootBridge();
|
|
121
|
-
try {
|
|
122
|
-
const resp = await fetch(
|
|
123
|
-
`http://127.0.0.1:${port}/sessions/abc-123?token=xyz`,
|
|
124
|
-
{ redirect: 'manual' },
|
|
125
|
-
);
|
|
126
|
-
expect(resp.status).toBe(302);
|
|
127
|
-
expect(resp.headers.get('location')).toBe('/dashboard/sessions/abc-123?token=xyz');
|
|
128
|
-
} finally {
|
|
129
|
-
await bridge.stop();
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it('GET /dashboard?token=… redirects to /dashboard/ and sets the cookie in one hop', async () => {
|
|
134
|
-
// Now that token handoff fires before the trailing-slash redirect,
|
|
135
|
-
// a single 302 should both swap the token to a cookie AND add the
|
|
136
|
-
// canonical trailing slash. This avoids a double-redirect chain.
|
|
137
|
-
const dir = mkTmp();
|
|
138
|
-
const store = new JsonlStore(dir);
|
|
139
|
-
const bridge = new Bridge({
|
|
140
|
-
port: 0, host: '127.0.0.1', store, taskStore: null, memoryStore: null,
|
|
141
|
-
auth: { token: 'abc' },
|
|
142
|
-
});
|
|
143
|
-
await bridge.start();
|
|
144
|
-
const port = bridge.getBoundPort();
|
|
145
|
-
try {
|
|
146
|
-
const resp = await fetch(`http://127.0.0.1:${port}/dashboard?token=abc`, { redirect: 'manual' });
|
|
147
|
-
expect(resp.status).toBe(302);
|
|
148
|
-
expect(resp.headers.get('location')).toBe('/dashboard/');
|
|
149
|
-
expect(resp.headers.get('set-cookie') ?? '').toContain('harness_fe_token=abc');
|
|
150
|
-
} finally {
|
|
151
|
-
await bridge.stop();
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('GET /dashboard (no token, no cookie, auth disabled) still redirects to /dashboard/', async () => {
|
|
156
|
-
// Backwards-compat: when auth is disabled, /dashboard still gets
|
|
157
|
-
// canonicalized — preserves the original behavior the test guarded.
|
|
158
|
-
const { bridge, port } = await bootBridge();
|
|
159
|
-
try {
|
|
160
|
-
const resp = await fetch(`http://127.0.0.1:${port}/dashboard`, { redirect: 'manual' });
|
|
161
|
-
expect(resp.status).toBe(302);
|
|
162
|
-
expect(resp.headers.get('location')).toBe('/dashboard/');
|
|
163
|
-
} finally {
|
|
164
|
-
await bridge.stop();
|
|
165
|
-
}
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it('GET /dashboard/ serves index.html as text/html', async () => {
|
|
169
|
-
const { bridge, port } = await bootBridge();
|
|
170
|
-
try {
|
|
171
|
-
const resp = await fetch(`http://127.0.0.1:${port}/dashboard/`);
|
|
172
|
-
expect(resp.status).toBe(200);
|
|
173
|
-
const ct = resp.headers.get('content-type') ?? '';
|
|
174
|
-
expect(ct).toMatch(/text\/html/);
|
|
175
|
-
const html = await resp.text();
|
|
176
|
-
expect(html).toContain('<div id="root">');
|
|
177
|
-
} finally {
|
|
178
|
-
await bridge.stop();
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it('GET /dashboard/sessions/some-id falls back to index.html (SPA routing)', async () => {
|
|
183
|
-
const { bridge, port } = await bootBridge();
|
|
184
|
-
try {
|
|
185
|
-
const resp = await fetch(`http://127.0.0.1:${port}/dashboard/sessions/abc`);
|
|
186
|
-
expect(resp.status).toBe(200);
|
|
187
|
-
const ct = resp.headers.get('content-type') ?? '';
|
|
188
|
-
expect(ct).toMatch(/text\/html/);
|
|
189
|
-
const html = await resp.text();
|
|
190
|
-
expect(html).toContain('<div id="root">');
|
|
191
|
-
} finally {
|
|
192
|
-
await bridge.stop();
|
|
193
|
-
}
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it('GET /dashboard/../etc/passwd is rejected with 403 (path traversal defense)', async () => {
|
|
197
|
-
const { bridge, port } = await bootBridge();
|
|
198
|
-
try {
|
|
199
|
-
// node's fetch resolves `..` on its own before sending, so we need
|
|
200
|
-
// to construct the URL such that the server still receives the dots.
|
|
201
|
-
const resp = await fetch(`http://127.0.0.1:${port}/dashboard/%2e%2e/etc/passwd`);
|
|
202
|
-
// Either the URL is rejected (403) or it falls back to index.html
|
|
203
|
-
// because the resolved path is inside dist (anything matching the
|
|
204
|
-
// SPA prefix is safe). Both are acceptable; the failure mode we
|
|
205
|
-
// care about is "leaks a file outside dist", which would be 200
|
|
206
|
-
// with non-HTML content.
|
|
207
|
-
if (resp.status === 200) {
|
|
208
|
-
const ct = resp.headers.get('content-type') ?? '';
|
|
209
|
-
expect(ct).toMatch(/text\/html/);
|
|
210
|
-
} else {
|
|
211
|
-
expect([403, 404]).toContain(resp.status);
|
|
212
|
-
}
|
|
213
|
-
} finally {
|
|
214
|
-
await bridge.stop();
|
|
215
|
-
}
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it('SPA assets carry immutable cache-control; index.html is no-store', async () => {
|
|
219
|
-
const { bridge, port } = await bootBridge();
|
|
220
|
-
try {
|
|
221
|
-
const idx = await fetch(`http://127.0.0.1:${port}/dashboard/`);
|
|
222
|
-
expect(idx.headers.get('cache-control')).toBe('no-store');
|
|
223
|
-
// Find a hashed asset by parsing the HTML for a /dashboard/assets/...
|
|
224
|
-
const html = await idx.text();
|
|
225
|
-
const m = html.match(/\/dashboard\/(assets\/[^"]+)/);
|
|
226
|
-
if (!m) {
|
|
227
|
-
// No hashed assets discovered — skip the cache assertion in
|
|
228
|
-
// this environment (e.g. a brand-new install where dist hasn't
|
|
229
|
-
// built). The first assertion is what matters most.
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
const asset = await fetch(`http://127.0.0.1:${port}/dashboard/${m[1]}`);
|
|
233
|
-
expect(asset.status).toBe(200);
|
|
234
|
-
expect(asset.headers.get('cache-control') ?? '').toMatch(/immutable/);
|
|
235
|
-
} finally {
|
|
236
|
-
await bridge.stop();
|
|
237
|
-
}
|
|
238
|
-
});
|
|
239
|
-
});
|
package/src/dashboardSpa.ts
DELETED
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTTP handler that serves the React SPA built by `@harness-fe/dashboard-ui`.
|
|
3
|
-
*
|
|
4
|
-
* Routing rules (after the `isAuthorized` middleware in bridge.ts):
|
|
5
|
-
* - GET / → 302 to /dashboard/?token=<preserved> (legacy root)
|
|
6
|
-
* - GET /sessions/:id → 302 to /dashboard/sessions/:id?token=… (legacy bookmarks)
|
|
7
|
-
* - GET /dashboard → 302 to /dashboard/?token=<preserved>
|
|
8
|
-
* - GET /dashboard/ → serve index.html (SPA shell)
|
|
9
|
-
* - GET /dashboard/<asset.ext> → serve that file from dist/ (if it exists)
|
|
10
|
-
* - GET /dashboard/<other-path> → serve index.html (SPA client-side routing)
|
|
11
|
-
*
|
|
12
|
-
* The dist directory is resolved at module load via `require.resolve()` on
|
|
13
|
-
* the dashboard-ui package — same trick `replayViewer.ts` uses for
|
|
14
|
-
* rrweb-player. No copy step needed; pnpm workspace symlinks just work in
|
|
15
|
-
* dev, and `pnpm deploy` bundles the dist into the published tarball.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { createRequire } from 'node:module';
|
|
19
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
20
|
-
import { dirname, extname, join, resolve as resolvePath } from 'node:path';
|
|
21
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
22
|
-
import { DEFAULT_COOKIE_NAME } from './auth.js';
|
|
23
|
-
|
|
24
|
-
const require = createRequire(import.meta.url);
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* If a request arrived with `?token=` AND no harness_fe_token cookie yet,
|
|
28
|
-
* stamp the cookie and 302 back to the same path without the query.
|
|
29
|
-
*
|
|
30
|
-
* Why this matters: the SPA bundle is loaded by the browser as
|
|
31
|
-
* `<script src="/dashboard/assets/index-XXX.js">` — relative paths without
|
|
32
|
-
* the token query — which would 401 against the upstream auth middleware.
|
|
33
|
-
* Setting the cookie on the first HTML request means every subsequent
|
|
34
|
-
* same-origin fetch (assets, /api/*, WebSocket upgrade) is automatically
|
|
35
|
-
* authenticated, and the visible URL stays clean.
|
|
36
|
-
*/
|
|
37
|
-
function maybeHandleTokenHandoff(req: IncomingMessage, res: ServerResponse, url: URL): boolean {
|
|
38
|
-
const tokenInQuery = url.searchParams.get('token');
|
|
39
|
-
if (!tokenInQuery) return false;
|
|
40
|
-
// If the cookie's already set with the same value, nothing to do —
|
|
41
|
-
// pass through (the browser will follow links without ?token=).
|
|
42
|
-
const cookies = req.headers.cookie ?? '';
|
|
43
|
-
if (cookies.includes(`${DEFAULT_COOKIE_NAME}=`)) return false;
|
|
44
|
-
// Strip token from the URL and 302 back to the canonical path so the
|
|
45
|
-
// browser bookmarks / shares clean URLs. The cookie carries auth from
|
|
46
|
-
// here on. Also normalize `/dashboard` → `/dashboard/` in the same hop
|
|
47
|
-
// so we don't waste a second redirect chasing the trailing slash.
|
|
48
|
-
url.searchParams.delete('token');
|
|
49
|
-
const canonicalPath = url.pathname === '/dashboard' ? '/dashboard/' : url.pathname;
|
|
50
|
-
const remaining = url.searchParams.toString();
|
|
51
|
-
const clean = `${canonicalPath}${remaining ? '?' + remaining : ''}`;
|
|
52
|
-
const cookie =
|
|
53
|
-
`${DEFAULT_COOKIE_NAME}=${encodeURIComponent(tokenInQuery)}; ` +
|
|
54
|
-
// Path=/ so /api/*, /replay/*, /dashboard/* all see it.
|
|
55
|
-
// SameSite=Lax keeps the cookie on cross-tab nav.
|
|
56
|
-
// No HttpOnly: the SPA's WS code may want to surface the token in
|
|
57
|
-
// a Bearer header for tools that don't share cookies (rare —
|
|
58
|
-
// browsers attach cookies to WS, so this is just defense in depth).
|
|
59
|
-
// 30-day Max-Age matches the /__auth login flow.
|
|
60
|
-
`Path=/; SameSite=Lax; Max-Age=2592000`;
|
|
61
|
-
res.statusCode = 302;
|
|
62
|
-
res.setHeader('set-cookie', cookie);
|
|
63
|
-
res.setHeader('location', clean);
|
|
64
|
-
res.setHeader('cache-control', 'no-store');
|
|
65
|
-
res.end();
|
|
66
|
-
return true;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function resolveDashboardDist(): string | undefined {
|
|
70
|
-
try {
|
|
71
|
-
const pkgPath = require.resolve('@harness-fe/dashboard-ui/package.json');
|
|
72
|
-
const dist = join(dirname(pkgPath), 'dist');
|
|
73
|
-
return existsSync(join(dist, 'index.html')) ? dist : undefined;
|
|
74
|
-
} catch {
|
|
75
|
-
return undefined;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const CONTENT_TYPES: Record<string, string> = {
|
|
80
|
-
'.html': 'text/html; charset=utf-8',
|
|
81
|
-
'.js': 'application/javascript; charset=utf-8',
|
|
82
|
-
'.mjs': 'application/javascript; charset=utf-8',
|
|
83
|
-
'.css': 'text/css; charset=utf-8',
|
|
84
|
-
'.json': 'application/json; charset=utf-8',
|
|
85
|
-
'.svg': 'image/svg+xml',
|
|
86
|
-
'.png': 'image/png',
|
|
87
|
-
'.jpg': 'image/jpeg',
|
|
88
|
-
'.jpeg': 'image/jpeg',
|
|
89
|
-
'.gif': 'image/gif',
|
|
90
|
-
'.webp': 'image/webp',
|
|
91
|
-
'.ico': 'image/x-icon',
|
|
92
|
-
'.woff': 'font/woff',
|
|
93
|
-
'.woff2': 'font/woff2',
|
|
94
|
-
'.txt': 'text/plain; charset=utf-8',
|
|
95
|
-
'.map': 'application/json; charset=utf-8',
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
function contentTypeFor(filePath: string): string {
|
|
99
|
-
return CONTENT_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export function createDashboardSpaHandler(): (
|
|
103
|
-
req: IncomingMessage,
|
|
104
|
-
res: ServerResponse,
|
|
105
|
-
) => boolean {
|
|
106
|
-
const dist = resolveDashboardDist();
|
|
107
|
-
return (req, res) => {
|
|
108
|
-
if (!req.url) return false;
|
|
109
|
-
const url = new URL(req.url, 'http://localhost');
|
|
110
|
-
const path = url.pathname;
|
|
111
|
-
const method = req.method ?? 'GET';
|
|
112
|
-
if (method !== 'GET' && method !== 'HEAD') return false;
|
|
113
|
-
|
|
114
|
-
// Legacy paths from the old server-rendered dashboard — redirect into
|
|
115
|
-
// the SPA preserving the token query so the user stays authenticated.
|
|
116
|
-
if (path === '/' || path === '/index.html') {
|
|
117
|
-
res.statusCode = 302;
|
|
118
|
-
res.setHeader('location', `/dashboard/${url.search ?? ''}`);
|
|
119
|
-
res.end();
|
|
120
|
-
return true;
|
|
121
|
-
}
|
|
122
|
-
const legacySession = path.match(/^\/sessions\/([^/]+)$/);
|
|
123
|
-
if (legacySession) {
|
|
124
|
-
const sid = legacySession[1];
|
|
125
|
-
res.statusCode = 302;
|
|
126
|
-
res.setHeader('location', `/dashboard/sessions/${sid}${url.search ?? ''}`);
|
|
127
|
-
res.end();
|
|
128
|
-
return true;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (!path.startsWith('/dashboard')) return false;
|
|
132
|
-
|
|
133
|
-
// First-load token handoff: ?token=… → cookie + redirect.
|
|
134
|
-
// Skip for static asset paths; setting the cookie there would still
|
|
135
|
-
// work but the redirect would break the script tag's request chain.
|
|
136
|
-
if (!path.startsWith('/dashboard/assets/') && !path.startsWith('/dashboard/static/')) {
|
|
137
|
-
if (maybeHandleTokenHandoff(req, res, url)) return true;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// If dashboard-ui isn't installed (someone using an older deploy
|
|
141
|
-
// or running tests in isolation), fall through with a friendly
|
|
142
|
-
// 503 rather than 404 — clearer signal than silent miss.
|
|
143
|
-
if (!dist) {
|
|
144
|
-
res.statusCode = 503;
|
|
145
|
-
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
146
|
-
res.end('Dashboard UI is not bundled with this build.');
|
|
147
|
-
return true;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// /dashboard with no trailing slash → redirect, preserving token.
|
|
151
|
-
if (path === '/dashboard') {
|
|
152
|
-
const search = url.search ?? '';
|
|
153
|
-
res.statusCode = 302;
|
|
154
|
-
res.setHeader('location', `/dashboard/${search}`);
|
|
155
|
-
res.end();
|
|
156
|
-
return true;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Strip the `/dashboard/` prefix to get the relative path inside dist.
|
|
160
|
-
const rel = path.slice('/dashboard/'.length) || 'index.html';
|
|
161
|
-
|
|
162
|
-
// Path traversal defense: resolve against dist and require the
|
|
163
|
-
// result is still inside dist. Without this, a crafted URL like
|
|
164
|
-
// `/dashboard/../../etc/passwd` would escape.
|
|
165
|
-
const candidate = resolvePath(dist, rel);
|
|
166
|
-
if (!candidate.startsWith(dist + (dist.endsWith('/') ? '' : '/')) && candidate !== dist) {
|
|
167
|
-
res.statusCode = 403;
|
|
168
|
-
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
169
|
-
res.end('Forbidden');
|
|
170
|
-
return true;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Serve the file if it exists; otherwise fall back to index.html
|
|
174
|
-
// so the SPA's client-side router can take over (this is how /sessions/:id
|
|
175
|
-
// works without server config).
|
|
176
|
-
const target = existsSync(candidate) ? candidate : join(dist, 'index.html');
|
|
177
|
-
try {
|
|
178
|
-
const body = readFileSync(target);
|
|
179
|
-
res.statusCode = 200;
|
|
180
|
-
res.setHeader('content-type', contentTypeFor(target));
|
|
181
|
-
// Hashed assets are immutable; HTML shells should never cache.
|
|
182
|
-
const isHashed = /\/assets\/.+-[A-Za-z0-9_-]{6,}\.[a-z0-9]+$/.test(target);
|
|
183
|
-
res.setHeader(
|
|
184
|
-
'cache-control',
|
|
185
|
-
isHashed ? 'public, max-age=31536000, immutable' : 'no-store',
|
|
186
|
-
);
|
|
187
|
-
res.end(body);
|
|
188
|
-
} catch (err) {
|
|
189
|
-
res.statusCode = 500;
|
|
190
|
-
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
191
|
-
res.end(`dashboard read error: ${err instanceof Error ? err.message : String(err)}`);
|
|
192
|
-
}
|
|
193
|
-
return true;
|
|
194
|
-
};
|
|
195
|
-
}
|
package/src/dashboardUrl.test.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { buildDashboardUrl } from './dashboardUrl.js';
|
|
3
|
-
import type { IBridge } from './bridge.js';
|
|
4
|
-
|
|
5
|
-
function makeBridge(opts: { base?: string; token?: string }): IBridge {
|
|
6
|
-
return {
|
|
7
|
-
getViewerBaseUrl: () => opts.base,
|
|
8
|
-
getAuthToken: () => opts.token,
|
|
9
|
-
} as unknown as IBridge;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
describe('buildDashboardUrl', () => {
|
|
13
|
-
it('returns the project-list URL with token when both are set', () => {
|
|
14
|
-
const bridge = makeBridge({ base: 'http://127.0.0.1:47729', token: 'abc' });
|
|
15
|
-
expect(buildDashboardUrl(bridge)).toBe('http://127.0.0.1:47729/dashboard/?token=abc');
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it('deep-links into a session detail when sessionId is provided', () => {
|
|
19
|
-
const bridge = makeBridge({ base: 'http://127.0.0.1:47729', token: 'abc' });
|
|
20
|
-
expect(buildDashboardUrl(bridge, { sessionId: 'sess-1' })).toBe(
|
|
21
|
-
'http://127.0.0.1:47729/dashboard/sessions/sess-1?token=abc',
|
|
22
|
-
);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it('URL-encodes the session id', () => {
|
|
26
|
-
const bridge = makeBridge({ base: 'http://127.0.0.1:47729', token: 'tok' });
|
|
27
|
-
expect(buildDashboardUrl(bridge, { sessionId: 'a/b c' })).toBe(
|
|
28
|
-
'http://127.0.0.1:47729/dashboard/sessions/a%2Fb%20c?token=tok',
|
|
29
|
-
);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('omits the token query when no token is configured', () => {
|
|
33
|
-
const bridge = makeBridge({ base: 'http://127.0.0.1:47729' });
|
|
34
|
-
expect(buildDashboardUrl(bridge)).toBe('http://127.0.0.1:47729/dashboard/');
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('returns undefined when the bridge has no base URL (no bound port yet)', () => {
|
|
38
|
-
const bridge = makeBridge({});
|
|
39
|
-
expect(buildDashboardUrl(bridge)).toBeUndefined();
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('URL-encodes the token (defensive against weird characters in HARNESS_FE_TOKEN)', () => {
|
|
43
|
-
const bridge = makeBridge({ base: 'http://127.0.0.1:47729', token: 'a b&c' });
|
|
44
|
-
expect(buildDashboardUrl(bridge)).toBe('http://127.0.0.1:47729/dashboard/?token=a%20b%26c');
|
|
45
|
-
});
|
|
46
|
-
});
|