@harness-fe/mcp-server 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/dist/auth.d.ts +53 -0
  4. package/dist/auth.js +212 -0
  5. package/dist/bridge.d.ts +302 -0
  6. package/dist/bridge.js +1580 -0
  7. package/dist/cli.d.ts +18 -0
  8. package/dist/cli.js +277 -0
  9. package/dist/daemon.d.ts +98 -0
  10. package/dist/daemon.js +80 -0
  11. package/dist/dashboardApi.d.ts +40 -0
  12. package/dist/dashboardApi.js +142 -0
  13. package/dist/dashboardSpa.d.ts +18 -0
  14. package/dist/dashboardSpa.js +180 -0
  15. package/dist/dashboardUrl.d.ts +13 -0
  16. package/dist/dashboardUrl.js +18 -0
  17. package/dist/eventsHandler.d.ts +24 -0
  18. package/dist/eventsHandler.js +114 -0
  19. package/dist/index.d.ts +7 -0
  20. package/dist/index.js +6 -0
  21. package/dist/mcp.d.ts +15 -0
  22. package/dist/mcp.js +923 -0
  23. package/dist/mcpHttp.d.ts +39 -0
  24. package/dist/mcpHttp.js +49 -0
  25. package/dist/openBrowser.d.ts +33 -0
  26. package/dist/openBrowser.js +63 -0
  27. package/dist/remoteBridge.d.ts +61 -0
  28. package/dist/remoteBridge.js +307 -0
  29. package/dist/replayCreate.d.ts +36 -0
  30. package/dist/replayCreate.js +156 -0
  31. package/dist/replayViewer.d.ts +20 -0
  32. package/dist/replayViewer.js +168 -0
  33. package/dist/sessionRouter.d.ts +42 -0
  34. package/dist/sessionRouter.js +88 -0
  35. package/dist/store/JsonMemoryStore.d.ts +52 -0
  36. package/dist/store/JsonMemoryStore.js +119 -0
  37. package/dist/store/JsonTaskStore.d.ts +21 -0
  38. package/dist/store/JsonTaskStore.js +53 -0
  39. package/dist/store/JsonlStore.d.ts +128 -0
  40. package/dist/store/JsonlStore.js +1168 -0
  41. package/dist/store/MemoryEventStore.d.ts +47 -0
  42. package/dist/store/MemoryEventStore.js +111 -0
  43. package/dist/store/WriteQueue.d.ts +51 -0
  44. package/dist/store/WriteQueue.js +142 -0
  45. package/dist/store/index.d.ts +6 -0
  46. package/dist/store/index.js +5 -0
  47. package/dist/store/types.d.ts +416 -0
  48. package/dist/store/types.js +19 -0
  49. package/package.json +63 -0
  50. package/src/auth.test.ts +90 -0
  51. package/src/auth.ts +248 -0
  52. package/src/bridge-auth.test.ts +196 -0
  53. package/src/bridge.test.ts +1708 -0
  54. package/src/bridge.ts +1804 -0
  55. package/src/cli.ts +315 -0
  56. package/src/daemon.test.ts +123 -0
  57. package/src/daemon.ts +161 -0
  58. package/src/dashboardApi.test.ts +235 -0
  59. package/src/dashboardApi.ts +184 -0
  60. package/src/dashboardSpa.test.ts +239 -0
  61. package/src/dashboardSpa.ts +195 -0
  62. package/src/dashboardUrl.test.ts +46 -0
  63. package/src/dashboardUrl.ts +28 -0
  64. package/src/eventsHandler.test.ts +247 -0
  65. package/src/eventsHandler.ts +136 -0
  66. package/src/index.ts +26 -0
  67. package/src/mcp.ts +1407 -0
  68. package/src/mcpHttp.test.ts +101 -0
  69. package/src/mcpHttp.ts +88 -0
  70. package/src/openBrowser.test.ts +103 -0
  71. package/src/openBrowser.ts +81 -0
  72. package/src/remoteBridge.test.ts +119 -0
  73. package/src/remoteBridge.ts +404 -0
  74. package/src/replay.test.ts +271 -0
  75. package/src/replayCreate.ts +194 -0
  76. package/src/replayViewer.ts +173 -0
  77. package/src/sessionRouter.ts +116 -0
  78. package/src/store/JsonMemoryStore.test.ts +175 -0
  79. package/src/store/JsonMemoryStore.ts +128 -0
  80. package/src/store/JsonTaskStore.test.ts +212 -0
  81. package/src/store/JsonTaskStore.ts +59 -0
  82. package/src/store/JsonlStore.test.ts +1538 -0
  83. package/src/store/JsonlStore.ts +1321 -0
  84. package/src/store/MemoryEventStore.test.ts +119 -0
  85. package/src/store/MemoryEventStore.ts +151 -0
  86. package/src/store/WriteQueue.ts +165 -0
  87. package/src/store/index.ts +29 -0
  88. package/src/store/types.ts +517 -0
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Tests for the JSON API surface consumed by @harness-fe/dashboard-ui.
3
+ *
4
+ * Mirrors the seed setup used by `dashboard.test.ts` so we have parity:
5
+ * anything the HTML dashboard could show should also be reachable via JSON
6
+ * once we ship the SPA in PR C.
7
+ */
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-dashboard-api-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
+ function seed(store: JsonlStore, projectId: string): string {
41
+ const { randomUUID } = require('node:crypto') as typeof import('node:crypto');
42
+ const sessionId = randomUUID();
43
+ store.upsertProject(projectId, { displayName: projectId });
44
+ store.upsertTab('tab-1', { connectedAt: Date.now(), userAgent: 'test-agent' });
45
+ store.upsertSession(sessionId, {
46
+ tabId: 'tab-1',
47
+ startedAt: Date.now(),
48
+ url: 'http://localhost:5173/',
49
+ title: 'Demo',
50
+ participants: [{ projectId, joinedAt: Date.now() }],
51
+ });
52
+ store.appendEvent(sessionId, { ts: 1000, t: 'log', d: { args: ['hello'] } });
53
+ store.appendEvent(sessionId, { ts: 1100, t: 'err', d: { message: 'boom' } });
54
+ store.appendRecording(sessionId, {
55
+ chunkId: 'rrc_a', startTs: 1000, endTs: 2000, eventCount: 3,
56
+ events: [
57
+ { type: 4, data: {}, timestamp: 1000 },
58
+ { type: 2, data: {}, timestamp: 1100 },
59
+ { type: 3, data: {}, timestamp: 2000 },
60
+ ],
61
+ });
62
+ return sessionId;
63
+ }
64
+
65
+ describe('Dashboard JSON API', () => {
66
+ it('GET /api/projects returns project list with recent sessions inline', async () => {
67
+ const { bridge, store, port } = await bootBridge();
68
+ try {
69
+ const sessionId = seed(store, 'my-app');
70
+ await store.flush();
71
+ const resp = await fetch(`http://127.0.0.1:${port}/api/projects`);
72
+ expect(resp.status).toBe(200);
73
+ expect(resp.headers.get('content-type')).toMatch(/application\/json/);
74
+ const body = await resp.json() as { projects: Array<{ project: { id: string }; recentSessions: Array<{ id: string }> }> };
75
+ expect(body.projects).toHaveLength(1);
76
+ expect(body.projects[0].project.id).toBe('my-app');
77
+ expect(body.projects[0].recentSessions.map((s) => s.id)).toContain(sessionId);
78
+ } finally {
79
+ await bridge.stop();
80
+ }
81
+ });
82
+
83
+ it('GET /api/projects on empty store returns an empty list (not 500)', async () => {
84
+ const { bridge, port } = await bootBridge();
85
+ try {
86
+ const resp = await fetch(`http://127.0.0.1:${port}/api/projects`);
87
+ expect(resp.status).toBe(200);
88
+ const body = await resp.json() as { projects: unknown[] };
89
+ expect(body.projects).toEqual([]);
90
+ } finally {
91
+ await bridge.stop();
92
+ }
93
+ });
94
+
95
+ it('GET /api/sessions filters by projectId', async () => {
96
+ const { bridge, store, port } = await bootBridge();
97
+ try {
98
+ seed(store, 'project-a');
99
+ seed(store, 'project-b');
100
+ await store.flush();
101
+ const resp = await fetch(`http://127.0.0.1:${port}/api/sessions?projectId=project-a`);
102
+ expect(resp.status).toBe(200);
103
+ const body = await resp.json() as { sessions: Array<{ participants: Array<{ projectId: string }> }> };
104
+ expect(body.sessions.length).toBeGreaterThan(0);
105
+ for (const s of body.sessions) {
106
+ expect(s.participants[0].projectId).toBe('project-a');
107
+ }
108
+ } finally {
109
+ await bridge.stop();
110
+ }
111
+ });
112
+
113
+ it('GET /api/sessions/:id returns session + summary + chunks + timeline + exports', async () => {
114
+ const { bridge, store, port } = await bootBridge();
115
+ try {
116
+ const sessionId = seed(store, 'my-app');
117
+ await store.flush();
118
+ const resp = await fetch(`http://127.0.0.1:${port}/api/sessions/${sessionId}`);
119
+ expect(resp.status).toBe(200);
120
+ const body = await resp.json() as {
121
+ session: { id: string };
122
+ summary: { tabs: string[] };
123
+ chunks: Array<{ chunkId: string; tabId: string; eventCount: number }>;
124
+ timeline: Array<{ t: string }>;
125
+ exports: unknown[];
126
+ };
127
+ expect(body.session.id).toBe(sessionId);
128
+ expect(body.summary.tabs).toContain('tab-1');
129
+ expect(body.chunks).toHaveLength(1);
130
+ expect(body.chunks[0].chunkId).toBe('rrc_a');
131
+ expect(body.chunks[0].eventCount).toBe(3);
132
+ expect(body.timeline.map((e) => e.t)).toContain('log');
133
+ expect(body.timeline.map((e) => e.t)).toContain('err');
134
+ expect(body.exports).toEqual([]);
135
+ } finally {
136
+ await bridge.stop();
137
+ }
138
+ });
139
+
140
+ it('GET /api/sessions/:id returns 404 for unknown id', async () => {
141
+ const { bridge, port } = await bootBridge();
142
+ try {
143
+ const resp = await fetch(`http://127.0.0.1:${port}/api/sessions/no-such-session`);
144
+ expect(resp.status).toBe(404);
145
+ const body = await resp.json() as { error: string; sessionId: string };
146
+ expect(body.error).toMatch(/not found/i);
147
+ expect(body.sessionId).toBe('no-such-session');
148
+ } finally {
149
+ await bridge.stop();
150
+ }
151
+ });
152
+
153
+ it('POST /api/sessions/:id/replay returns exportId + viewerUrl on success', async () => {
154
+ const { bridge, store, port } = await bootBridge();
155
+ try {
156
+ const sessionId = seed(store, 'my-app');
157
+ await store.flush();
158
+ const resp = await fetch(`http://127.0.0.1:${port}/api/sessions/${sessionId}/replay`, {
159
+ method: 'POST',
160
+ headers: { 'content-type': 'application/json' },
161
+ body: JSON.stringify({ since: 1000, until: 3000, tabId: 'tab-1' }),
162
+ });
163
+ expect(resp.status).toBe(200);
164
+ const body = await resp.json() as { exportId?: string; viewerUrl?: string; error?: string };
165
+ expect(body.error).toBeUndefined();
166
+ expect(body.exportId).toBeTruthy();
167
+ // viewerUrl is best-effort (depends on bridge.getViewerBaseUrl()); just assert shape.
168
+ expect(typeof body.exportId === 'string').toBe(true);
169
+ } finally {
170
+ await bridge.stop();
171
+ }
172
+ });
173
+
174
+ it('POST /api/sessions/:id/replay returns 400 with error message for empty window', async () => {
175
+ const { bridge, store, port } = await bootBridge();
176
+ try {
177
+ const sessionId = seed(store, 'my-app');
178
+ await store.flush();
179
+ const resp = await fetch(`http://127.0.0.1:${port}/api/sessions/${sessionId}/replay`, {
180
+ method: 'POST',
181
+ headers: { 'content-type': 'application/json' },
182
+ body: JSON.stringify({ since: 999_999_000, until: 999_999_100 }),
183
+ });
184
+ expect(resp.status).toBe(400);
185
+ const body = await resp.json() as { error: string };
186
+ expect(body.error).toMatch(/no rrweb chunks|empty|window/i);
187
+ } finally {
188
+ await bridge.stop();
189
+ }
190
+ });
191
+
192
+ it('POST /api/sessions/:id/replay rejects malformed JSON with 400', async () => {
193
+ const { bridge, store, port } = await bootBridge();
194
+ try {
195
+ const sessionId = seed(store, 'my-app');
196
+ await store.flush();
197
+ const resp = await fetch(`http://127.0.0.1:${port}/api/sessions/${sessionId}/replay`, {
198
+ method: 'POST',
199
+ headers: { 'content-type': 'application/json' },
200
+ body: 'not json {{{',
201
+ });
202
+ expect(resp.status).toBe(400);
203
+ const body = await resp.json() as { error: string };
204
+ expect(body.error).toMatch(/invalid JSON/i);
205
+ } finally {
206
+ await bridge.stop();
207
+ }
208
+ });
209
+
210
+ it('non-API root path redirects into the SPA (legacy / no longer serves HTML)', async () => {
211
+ const { bridge, store, port } = await bootBridge();
212
+ try {
213
+ seed(store, 'my-app');
214
+ await store.flush();
215
+ const resp = await fetch(`http://127.0.0.1:${port}/?token=abc`, { redirect: 'manual' });
216
+ expect(resp.status).toBe(302);
217
+ expect(resp.headers.get('location')).toBe('/dashboard/?token=abc');
218
+ } finally {
219
+ await bridge.stop();
220
+ }
221
+ });
222
+
223
+ it('unknown /api/* paths return 404 JSON (not the HTML 404 page)', async () => {
224
+ const { bridge, port } = await bootBridge();
225
+ try {
226
+ const resp = await fetch(`http://127.0.0.1:${port}/api/bogus/path`);
227
+ expect(resp.status).toBe(404);
228
+ expect(resp.headers.get('content-type')).toMatch(/application\/json/);
229
+ const body = await resp.json() as { error: string };
230
+ expect(body.error).toBe('not found');
231
+ } finally {
232
+ await bridge.stop();
233
+ }
234
+ });
235
+ });
@@ -0,0 +1,184 @@
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
+ }
@@ -0,0 +1,239 @@
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
+ });