nexus-prime 3.2.2 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,6 +20,7 @@ const REQUIRED_CAPABILITIES = {
20
20
  };
21
21
  export class DashboardServer {
22
22
  server;
23
+ cachedDashboardHtml = null;
23
24
  clients = new Set();
24
25
  unsubscribeBus = null;
25
26
  runtimeProvider;
@@ -108,220 +109,312 @@ export class DashboardServer {
108
109
  console.error(`[Dashboard] Topology console listening at ${this.dashboardUrl}`);
109
110
  }
110
111
  async requestHandler(req, res) {
111
- const url = new URL(req.url || '/', this.dashboardUrl ?? this.buildUrl(this.activePort ?? DEFAULT_PORT));
112
- if (req.method === 'OPTIONS') {
113
- this.respondOptions(res);
114
- return;
115
- }
116
- if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
117
- this.serveDashboard(res);
118
- return;
119
- }
120
- if (req.method === 'GET' && url.pathname === '/stream') {
121
- this.serveSSE(req, res);
122
- return;
123
- }
124
- if (req.method === 'GET' && url.pathname === '/api/runs') {
125
- const limit = parseInt(url.searchParams.get('limit') || '20', 10);
126
- this.respondJson(res, this.getRuntime()?.listRuns(limit) ?? []);
127
- return;
128
- }
129
- if (req.method === 'GET' && url.pathname.startsWith('/api/runs/')) {
130
- const runId = decodeURIComponent(url.pathname.replace('/api/runs/', ''));
131
- const run = this.getRuntime()?.getRun(runId);
132
- this.respondJson(res, run ?? { error: 'run-not-found', runId }, run ? 200 : 404);
133
- return;
134
- }
135
- if (req.method === 'GET' && url.pathname === '/api/skills') {
136
- this.respondJson(res, this.getRuntime()?.listSkills() ?? []);
137
- return;
138
- }
139
- if (req.method === 'GET' && url.pathname === '/api/workflows') {
140
- this.respondJson(res, this.getRuntime()?.listWorkflows() ?? []);
141
- return;
142
- }
143
- if (req.method === 'GET' && url.pathname === '/api/backends') {
144
- this.respondJson(res, this.getRuntime()?.getBackendCatalog() ?? {});
145
- return;
146
- }
147
- if (req.method === 'GET' && url.pathname === '/api/health') {
148
- this.respondJson(res, this.collectHealth());
149
- return;
150
- }
151
- if (req.method === 'GET' && url.pathname === '/api/memory') {
152
- const limit = parseInt(url.searchParams.get('limit') || '40', 10);
153
- const tier = url.searchParams.get('tier') ?? undefined;
154
- const tag = url.searchParams.get('tag') ?? undefined;
155
- const linkedType = url.searchParams.get('linkedType') ?? undefined;
156
- const recencyMs = url.searchParams.get('recencyMs');
157
- const memory = this.getMemory();
158
- this.respondJson(res, memory?.listSnapshots(limit, {
159
- tier: tier,
160
- tag,
161
- linkedType: linkedType,
162
- recencyMs: recencyMs ? parseInt(recencyMs, 10) : undefined,
163
- }) ?? []);
164
- return;
165
- }
166
- if (req.method === 'GET' && url.pathname.startsWith('/api/memory/') && url.pathname.endsWith('/network')) {
167
- const id = decodeURIComponent(url.pathname.replace('/api/memory/', '').replace('/network', '').replace(/\/$/, ''));
168
- const depth = parseInt(url.searchParams.get('depth') || '2', 10);
169
- const limit = parseInt(url.searchParams.get('limit') || '18', 10);
170
- const memory = this.getMemory();
171
- this.respondJson(res, memory?.getNetworkSnapshot(id, depth, limit) ?? { focusId: id, nodes: [], links: [] });
172
- return;
173
- }
174
- if (req.method === 'GET' && url.pathname.startsWith('/api/memory/')) {
175
- const id = decodeURIComponent(url.pathname.replace('/api/memory/', ''));
176
- const memory = this.getMemory();
177
- const detail = memory?.getDetail(id);
178
- this.respondJson(res, detail ?? { error: 'memory-not-found', id }, detail ? 200 : 404);
179
- return;
180
- }
181
- if (req.method === 'GET' && url.pathname === '/api/pod') {
182
- const limit = parseInt(url.searchParams.get('limit') || '40', 10);
183
- this.respondJson(res, podNetwork.getDashboardSnapshot(limit));
184
- return;
185
- }
186
- if (req.method === 'GET' && url.pathname.startsWith('/api/pod/')) {
187
- const workerId = decodeURIComponent(url.pathname.replace('/api/pod/', ''));
188
- const limit = parseInt(url.searchParams.get('limit') || '20', 10);
189
- this.respondJson(res, podNetwork.getWorker(workerId, limit));
190
- return;
191
- }
192
- if (req.method === 'GET' && url.pathname === '/api/clients') {
193
- this.respondJson(res, this.getClientRegistry()?.listClients(this.getAdapters()) ?? []);
194
- return;
195
- }
196
- if (req.method === 'GET' && url.pathname === '/api/events') {
197
- const category = url.searchParams.get('category');
198
- const type = url.searchParams.get('type');
199
- const limit = parseInt(url.searchParams.get('limit') || '80', 10);
200
- const events = this.getEventCards()
201
- .filter((event) => !category || event.category === category)
202
- .filter((event) => !type || event.type === type)
203
- .slice(0, Math.max(limit, 1));
204
- this.respondJson(res, events);
205
- return;
206
- }
207
- if (req.method === 'POST' && url.pathname === '/api/skills/deploy') {
208
- const body = await this.readJsonBody(req);
209
- const runtime = this.getRuntime();
210
- const deployed = runtime?.deploySkill(String(body.skillId), body.scope);
211
- if (deployed) {
212
- nexusEventBus.emit('skill.deploy', {
213
- skillId: deployed.skillId,
214
- scope: deployed.scope,
215
- status: deployed.rolloutStatus,
216
- });
112
+ try {
113
+ const url = new URL(req.url || '/', this.dashboardUrl ?? this.buildUrl(this.activePort ?? DEFAULT_PORT));
114
+ if (req.method === 'OPTIONS') {
115
+ this.respondOptions(res);
116
+ return;
217
117
  }
218
- this.respondJson(res, deployed ?? { error: 'skill-not-found' }, deployed ? 200 : 404);
219
- return;
220
- }
221
- if (req.method === 'POST' && url.pathname === '/api/skills/revoke') {
222
- const body = await this.readJsonBody(req);
223
- const runtime = this.getRuntime();
224
- const revoked = runtime?.revokeSkill(String(body.skillId));
225
- if (revoked) {
226
- nexusEventBus.emit('skill.revoke', {
227
- skillId: revoked.skillId,
228
- status: revoked.rolloutStatus,
229
- });
118
+ if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
119
+ this.serveDashboard(res);
120
+ return;
230
121
  }
231
- this.respondJson(res, revoked ?? { error: 'skill-not-found' }, revoked ? 200 : 404);
232
- return;
233
- }
234
- if (req.method === 'POST' && url.pathname === '/api/workflows/deploy') {
235
- const body = await this.readJsonBody(req);
236
- const runtime = this.getRuntime();
237
- const deployed = runtime?.deployWorkflow(String(body.workflowId), body.scope);
238
- if (deployed) {
239
- nexusEventBus.emit('workflow.deploy', {
240
- workflowId: deployed.workflowId,
241
- scope: deployed.scope,
242
- status: deployed.rolloutStatus,
243
- });
122
+ if (req.method === 'GET' && url.pathname === '/stream') {
123
+ this.serveSSE(req, res);
124
+ return;
244
125
  }
245
- this.respondJson(res, deployed ?? { error: 'workflow-not-found' }, deployed ? 200 : 404);
246
- return;
247
- }
248
- if (req.method === 'POST' && url.pathname === '/api/workflows/run') {
249
- const body = await this.readJsonBody(req);
250
- const runtime = this.getRuntime();
251
- if (!runtime) {
252
- this.respondJson(res, { error: 'runtime-unavailable' }, 503);
126
+ if (req.method === 'GET' && url.pathname === '/api/runs') {
127
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
128
+ this.respondJson(res, this.getRuntime()?.listRuns(limit) ?? []);
253
129
  return;
254
130
  }
255
- try {
256
- const run = await runtime.runWorkflow(String(body.workflowId), body.goal ? String(body.goal) : undefined);
257
- nexusEventBus.emit('workflow.run', {
258
- workflowId: String(body.workflowId),
259
- runId: run.runId,
260
- status: run.state,
261
- });
262
- this.respondJson(res, run);
131
+ if (req.method === 'GET' && url.pathname.startsWith('/api/runs/')) {
132
+ const runId = decodeURIComponent(url.pathname.replace('/api/runs/', ''));
133
+ const run = this.getRuntime()?.getRun(runId);
134
+ this.respondJson(res, run ?? { error: 'run-not-found', runId }, run ? 200 : 404);
135
+ return;
263
136
  }
264
- catch (error) {
265
- this.respondJson(res, { error: error instanceof Error ? error.message : 'workflow-run-failed' }, 400);
137
+ if (req.method === 'GET' && url.pathname === '/api/skills') {
138
+ this.respondJson(res, this.getRuntime()?.listSkills() ?? []);
139
+ return;
266
140
  }
267
- return;
268
- }
269
- if (req.method === 'POST' && url.pathname === '/api/runtime/execute') {
270
- const body = await this.readJsonBody(req);
271
- const runtime = this.getRuntime();
272
- if (!runtime) {
273
- this.respondJson(res, { error: 'runtime-unavailable' }, 503);
141
+ if (req.method === 'GET' && url.pathname === '/api/workflows') {
142
+ this.respondJson(res, this.getRuntime()?.listWorkflows() ?? []);
274
143
  return;
275
144
  }
276
- if (!body.goal || typeof body.goal !== 'string') {
277
- this.respondJson(res, { error: 'goal-required' }, 400);
145
+ if (req.method === 'GET' && url.pathname === '/api/backends') {
146
+ this.respondJson(res, this.getRuntime()?.getBackendCatalog() ?? {});
278
147
  return;
279
148
  }
280
- const run = await runtime.run(body);
281
- nexusEventBus.emit('dashboard.action', {
282
- action: 'runtime.execute',
283
- status: run.state,
284
- target: run.runId,
285
- });
286
- this.respondJson(res, run, 201);
287
- return;
288
- }
289
- if (req.method === 'POST' && url.pathname.startsWith('/api/clients/') && url.pathname.endsWith('/reconnect')) {
290
- const clientId = decodeURIComponent(url.pathname.replace('/api/clients/', '').replace('/reconnect', '').replace(/\/$/, ''));
291
- const registry = this.getClientRegistry();
292
- if (!registry) {
293
- this.respondJson(res, { error: 'client-registry-unavailable' }, 503);
149
+ if (req.method === 'GET' && url.pathname === '/api/health') {
150
+ this.respondJson(res, this.collectHealth());
294
151
  return;
295
152
  }
296
- const client = registry.reconnect(clientId);
297
- nexusEventBus.emit('dashboard.action', {
298
- action: 'client.reconnect',
299
- status: 'ok',
300
- target: clientId,
301
- });
302
- this.respondJson(res, client);
303
- return;
304
- }
305
- if (req.method === 'POST' && url.pathname.startsWith('/api/clients/') && url.pathname.endsWith('/clear')) {
306
- const clientId = decodeURIComponent(url.pathname.replace('/api/clients/', '').replace('/clear', '').replace(/\/$/, ''));
307
- const registry = this.getClientRegistry();
308
- if (!registry) {
309
- this.respondJson(res, { error: 'client-registry-unavailable' }, 503);
153
+ if (req.method === 'GET' && url.pathname === '/api/memory') {
154
+ const limit = parseInt(url.searchParams.get('limit') || '40', 10);
155
+ const tier = url.searchParams.get('tier') ?? undefined;
156
+ const tag = url.searchParams.get('tag') ?? undefined;
157
+ const linkedType = url.searchParams.get('linkedType') ?? undefined;
158
+ const recencyMs = url.searchParams.get('recencyMs');
159
+ const memory = this.getMemory();
160
+ this.respondJson(res, memory?.listSnapshots(limit, {
161
+ tier: tier,
162
+ tag,
163
+ linkedType: linkedType,
164
+ recencyMs: recencyMs ? parseInt(recencyMs, 10) : undefined,
165
+ }) ?? []);
310
166
  return;
311
167
  }
312
- registry.clear(clientId);
313
- nexusEventBus.emit('dashboard.action', {
314
- action: 'client.clear',
315
- status: 'ok',
316
- target: clientId,
317
- });
318
- this.respondJson(res, { ok: true, clientId });
319
- return;
168
+ if (req.method === 'GET' && url.pathname.startsWith('/api/memory/') && url.pathname.endsWith('/network')) {
169
+ const id = decodeURIComponent(url.pathname.replace('/api/memory/', '').replace('/network', '').replace(/\/$/, ''));
170
+ const depth = parseInt(url.searchParams.get('depth') || '2', 10);
171
+ const limit = parseInt(url.searchParams.get('limit') || '18', 10);
172
+ const memory = this.getMemory();
173
+ this.respondJson(res, memory?.getNetworkSnapshot(id, depth, limit) ?? { focusId: id, nodes: [], links: [] });
174
+ return;
175
+ }
176
+ if (req.method === 'GET' && url.pathname.startsWith('/api/memory/')) {
177
+ const id = decodeURIComponent(url.pathname.replace('/api/memory/', ''));
178
+ const memory = this.getMemory();
179
+ const detail = memory?.getDetail(id);
180
+ this.respondJson(res, detail ?? { error: 'memory-not-found', id }, detail ? 200 : 404);
181
+ return;
182
+ }
183
+ if (req.method === 'GET' && url.pathname === '/api/pod') {
184
+ const limit = parseInt(url.searchParams.get('limit') || '40', 10);
185
+ this.respondJson(res, podNetwork.getDashboardSnapshot(limit));
186
+ return;
187
+ }
188
+ if (req.method === 'GET' && url.pathname.startsWith('/api/pod/')) {
189
+ const workerId = decodeURIComponent(url.pathname.replace('/api/pod/', ''));
190
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
191
+ this.respondJson(res, podNetwork.getWorker(workerId, limit));
192
+ return;
193
+ }
194
+ if (req.method === 'GET' && url.pathname === '/api/clients') {
195
+ this.respondJson(res, this.getClientRegistry()?.listClients(this.getAdapters()) ?? []);
196
+ return;
197
+ }
198
+ if (req.method === 'GET' && url.pathname === '/api/events') {
199
+ const category = url.searchParams.get('category');
200
+ const type = url.searchParams.get('type');
201
+ const limit = parseInt(url.searchParams.get('limit') || '80', 10);
202
+ const events = this.getEventCards()
203
+ .filter((event) => !category || event.category === category)
204
+ .filter((event) => !type || event.type === type)
205
+ .slice(0, Math.max(limit, 1));
206
+ this.respondJson(res, events);
207
+ return;
208
+ }
209
+ if (req.method === 'POST' && url.pathname === '/api/skills/deploy') {
210
+ const body = await this.readJsonBody(req);
211
+ const runtime = this.getRuntime();
212
+ const deployed = runtime?.deploySkill(String(body.skillId), body.scope);
213
+ if (deployed) {
214
+ nexusEventBus.emit('skill.deploy', {
215
+ skillId: deployed.skillId,
216
+ scope: deployed.scope,
217
+ status: deployed.rolloutStatus,
218
+ });
219
+ }
220
+ this.respondJson(res, deployed ?? { error: 'skill-not-found' }, deployed ? 200 : 404);
221
+ return;
222
+ }
223
+ if (req.method === 'POST' && url.pathname === '/api/skills/revoke') {
224
+ const body = await this.readJsonBody(req);
225
+ const runtime = this.getRuntime();
226
+ const revoked = runtime?.revokeSkill(String(body.skillId));
227
+ if (revoked) {
228
+ nexusEventBus.emit('skill.revoke', {
229
+ skillId: revoked.skillId,
230
+ status: revoked.rolloutStatus,
231
+ });
232
+ }
233
+ this.respondJson(res, revoked ?? { error: 'skill-not-found' }, revoked ? 200 : 404);
234
+ return;
235
+ }
236
+ if (req.method === 'POST' && url.pathname === '/api/skills/register') {
237
+ const body = await this.readJsonBody(req);
238
+ const runtime = this.getRuntime();
239
+ if (!runtime) {
240
+ this.respondJson(res, { error: 'runtime-unavailable' }, 503);
241
+ return;
242
+ }
243
+ try {
244
+ const riskClass = (['read', 'orchestrate', 'mutate'].includes(body.riskClass) ? body.riskClass : 'orchestrate');
245
+ const scope = (['session', 'worker', 'global'].includes(body.scope) ? body.scope : 'session');
246
+ const skill = runtime.generateSkill({
247
+ name: String(body.name || 'unnamed-skill'),
248
+ instructions: String(body.instructions || ''),
249
+ riskClass,
250
+ scope,
251
+ });
252
+ nexusEventBus.emit('skill.register', {
253
+ name: skill.name,
254
+ id: skill.skillId,
255
+ });
256
+ this.respondJson(res, skill);
257
+ }
258
+ catch (err) {
259
+ this.respondJson(res, { error: err.message || 'register-failed' }, 400);
260
+ }
261
+ return;
262
+ }
263
+ if (req.method === 'POST' && url.pathname === '/api/skills/seed') {
264
+ const runtime = this.getRuntime();
265
+ if (!runtime) {
266
+ this.respondJson(res, { error: 'runtime-unavailable' }, 503);
267
+ return;
268
+ }
269
+ const defaultSkills = [
270
+ { name: 'code-review-playbook', instructions: 'Guide structured code review: check for bugs, security issues, performance problems, readability concerns. Produce a checklist with severity ratings.', riskClass: 'read', scope: 'global' },
271
+ { name: 'token-budget-guardian', instructions: 'Monitor token usage during sessions. Warn when context exceeds 70k tokens. Suggest pruning strategies and memory offloading when approaching limits.', riskClass: 'read', scope: 'global' },
272
+ { name: 'session-handover', instructions: 'At session end, generate a comprehensive summary: files modified, decisions made, open questions, recommended next steps. Store as session-summary memory.', riskClass: 'orchestrate', scope: 'global' },
273
+ { name: 'memory-hygiene', instructions: 'Review stored memories for staleness, duplicates, and contradictions. Suggest pruning candidates and consolidation opportunities. Never auto-delete.', riskClass: 'read', scope: 'global' },
274
+ { name: 'test-first-guard', instructions: 'Before implementing features, ensure test files exist or are planned. Prompt for test strategy if missing. Verify tests pass after implementation.', riskClass: 'orchestrate', scope: 'session' },
275
+ { name: 'commit-message-crafter', instructions: 'Generate concise, semantic commit messages following conventional commits format. Analyze staged changes to infer the correct type (feat/fix/chore/refactor).', riskClass: 'read', scope: 'global' },
276
+ ];
277
+ const results = [];
278
+ for (const skill of defaultSkills) {
279
+ try {
280
+ const existing = runtime.listSkills().find((s) => s.name === skill.name);
281
+ if (!existing) {
282
+ const registered = runtime.generateSkill(skill);
283
+ results.push({ name: skill.name, status: 'created', skillId: registered.skillId });
284
+ }
285
+ else {
286
+ results.push({ name: skill.name, status: 'exists', skillId: existing.skillId });
287
+ }
288
+ }
289
+ catch {
290
+ results.push({ name: skill.name, status: 'failed' });
291
+ }
292
+ }
293
+ this.respondJson(res, { seeded: results });
294
+ return;
295
+ }
296
+ if (req.method === 'POST' && url.pathname === '/api/workflows/deploy') {
297
+ const body = await this.readJsonBody(req);
298
+ const runtime = this.getRuntime();
299
+ const deployed = runtime?.deployWorkflow(String(body.workflowId), body.scope);
300
+ if (deployed) {
301
+ nexusEventBus.emit('workflow.deploy', {
302
+ workflowId: deployed.workflowId,
303
+ scope: deployed.scope,
304
+ status: deployed.rolloutStatus,
305
+ });
306
+ }
307
+ this.respondJson(res, deployed ?? { error: 'workflow-not-found' }, deployed ? 200 : 404);
308
+ return;
309
+ }
310
+ if (req.method === 'POST' && url.pathname === '/api/workflows/run') {
311
+ const body = await this.readJsonBody(req);
312
+ const runtime = this.getRuntime();
313
+ if (!runtime) {
314
+ this.respondJson(res, { error: 'runtime-unavailable' }, 503);
315
+ return;
316
+ }
317
+ try {
318
+ const run = await runtime.runWorkflow(String(body.workflowId), body.goal ? String(body.goal) : undefined);
319
+ nexusEventBus.emit('workflow.run', {
320
+ workflowId: String(body.workflowId),
321
+ runId: run.runId,
322
+ status: run.state,
323
+ });
324
+ this.respondJson(res, run);
325
+ }
326
+ catch (error) {
327
+ this.respondJson(res, { error: error instanceof Error ? error.message : 'workflow-run-failed' }, 400);
328
+ }
329
+ return;
330
+ }
331
+ if (req.method === 'POST' && url.pathname === '/api/runtime/execute') {
332
+ const body = await this.readJsonBody(req);
333
+ const runtime = this.getRuntime();
334
+ if (!runtime) {
335
+ this.respondJson(res, { error: 'runtime-unavailable' }, 503);
336
+ return;
337
+ }
338
+ if (!body.goal || typeof body.goal !== 'string') {
339
+ this.respondJson(res, { error: 'goal-required' }, 400);
340
+ return;
341
+ }
342
+ const run = await runtime.run(body);
343
+ nexusEventBus.emit('dashboard.action', {
344
+ action: 'runtime.execute',
345
+ status: run.state,
346
+ target: run.runId,
347
+ });
348
+ this.respondJson(res, run, 201);
349
+ return;
350
+ }
351
+ if (req.method === 'POST' && url.pathname.startsWith('/api/clients/') && url.pathname.endsWith('/reconnect')) {
352
+ const clientId = decodeURIComponent(url.pathname.replace('/api/clients/', '').replace('/reconnect', '').replace(/\/$/, ''));
353
+ const registry = this.getClientRegistry();
354
+ if (!registry) {
355
+ this.respondJson(res, { error: 'client-registry-unavailable' }, 503);
356
+ return;
357
+ }
358
+ const client = registry.reconnect(clientId);
359
+ nexusEventBus.emit('dashboard.action', {
360
+ action: 'client.reconnect',
361
+ status: 'ok',
362
+ target: clientId,
363
+ });
364
+ this.respondJson(res, client);
365
+ return;
366
+ }
367
+ if (req.method === 'POST' && url.pathname.startsWith('/api/clients/') && url.pathname.endsWith('/clear')) {
368
+ const clientId = decodeURIComponent(url.pathname.replace('/api/clients/', '').replace('/clear', '').replace(/\/$/, ''));
369
+ const registry = this.getClientRegistry();
370
+ if (!registry) {
371
+ this.respondJson(res, { error: 'client-registry-unavailable' }, 503);
372
+ return;
373
+ }
374
+ registry.clear(clientId);
375
+ nexusEventBus.emit('dashboard.action', {
376
+ action: 'client.clear',
377
+ status: 'ok',
378
+ target: clientId,
379
+ });
380
+ this.respondJson(res, { ok: true, clientId });
381
+ return;
382
+ }
383
+ res.writeHead(404);
384
+ res.end('Not found');
385
+ }
386
+ catch (error) {
387
+ if (!res.headersSent) {
388
+ if (error instanceof Error && error.message === 'Request body too large') {
389
+ res.writeHead(413, { 'Content-Type': 'text/plain' });
390
+ res.end('Request body too large');
391
+ }
392
+ else {
393
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
394
+ res.end('Internal server error');
395
+ }
396
+ }
320
397
  }
321
- res.writeHead(404);
322
- res.end('Not found');
323
398
  }
324
399
  serveDashboard(res) {
400
+ const securityHeaders = {
401
+ 'Content-Type': 'text/html',
402
+ 'Content-Security-Policy': [
403
+ "default-src 'self'",
404
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
405
+ "font-src 'self' https://fonts.gstatic.com",
406
+ "script-src 'unsafe-inline'",
407
+ "connect-src 'self'",
408
+ "img-src 'self' data:",
409
+ ].join('; '),
410
+ 'X-Content-Type-Options': 'nosniff',
411
+ 'X-Frame-Options': 'DENY',
412
+ };
413
+ if (this.cachedDashboardHtml) {
414
+ res.writeHead(200, securityHeaders);
415
+ res.end(this.cachedDashboardHtml);
416
+ return;
417
+ }
325
418
  const htmlPath = path.join(__dirname, 'index.html');
326
419
  fs.readFile(htmlPath, 'utf8', (err, data) => {
327
420
  if (err) {
@@ -329,7 +422,8 @@ export class DashboardServer {
329
422
  res.end('Error loading dashboard HTML');
330
423
  return;
331
424
  }
332
- res.writeHead(200, { 'Content-Type': 'text/html' });
425
+ this.cachedDashboardHtml = data;
426
+ res.writeHead(200, securityHeaders);
333
427
  res.end(data);
334
428
  });
335
429
  }
@@ -338,7 +432,7 @@ export class DashboardServer {
338
432
  'Content-Type': 'text/event-stream',
339
433
  'Cache-Control': 'no-cache',
340
434
  Connection: 'keep-alive',
341
- 'Access-Control-Allow-Origin': '*',
435
+ 'Access-Control-Allow-Origin': this.getCorsOrigin(),
342
436
  });
343
437
  res.write('retry: 3000\n\n');
344
438
  res.write(`event: bootstrap\ndata: ${JSON.stringify({ connected: true, timestamp: Date.now() })}\n\n`);
@@ -363,9 +457,12 @@ export class DashboardServer {
363
457
  res.write(dataStr);
364
458
  }
365
459
  }
460
+ getCorsOrigin() {
461
+ return this.dashboardUrl || `http://${HOST}:${this.activePort || DEFAULT_PORT}`;
462
+ }
366
463
  respondOptions(res) {
367
464
  res.writeHead(204, {
368
- 'Access-Control-Allow-Origin': '*',
465
+ 'Access-Control-Allow-Origin': this.getCorsOrigin(),
369
466
  'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
370
467
  'Access-Control-Allow-Headers': 'Content-Type',
371
468
  });
@@ -375,14 +472,21 @@ export class DashboardServer {
375
472
  res.writeHead(statusCode, {
376
473
  'Content-Type': 'application/json',
377
474
  'Cache-Control': 'no-cache',
378
- 'Access-Control-Allow-Origin': '*',
475
+ 'Access-Control-Allow-Origin': this.getCorsOrigin(),
379
476
  });
380
477
  res.end(JSON.stringify(data, null, 2));
381
478
  }
382
479
  async readJsonBody(req) {
480
+ const MAX_BODY = 1024 * 1024; // 1MB
383
481
  const chunks = [];
482
+ let totalLength = 0;
384
483
  for await (const chunk of req) {
385
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
484
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
485
+ totalLength += buf.length;
486
+ if (totalLength > MAX_BODY) {
487
+ throw new Error('Request body too large');
488
+ }
489
+ chunks.push(buf);
386
490
  }
387
491
  if (!chunks.length)
388
492
  return {};