@luanpdd/kit-mcp 1.34.0 → 1.36.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.
Files changed (118) hide show
  1. package/README.md +1 -1
  2. package/bin/cli.js +2 -2
  3. package/bin/mcp.js +6 -6
  4. package/bin/ui.js +74 -74
  5. package/gates/ai-prompt-stability.md +120 -120
  6. package/gates/budget-description.md +68 -68
  7. package/gates/confidence.md +29 -29
  8. package/gates/dependency-check.md +33 -33
  9. package/gates/dept-cycle-prevention.md +179 -179
  10. package/gates/golden-signals-coverage.md +133 -133
  11. package/gates/legacy-refactor-safety.md +178 -178
  12. package/gates/multi-tenant-rls-coverage.md +102 -102
  13. package/gates/no-personal-uuid.md +72 -72
  14. package/gates/obs-agents-mcp-supabase.md +86 -86
  15. package/gates/obs-skills-frontmatter.md +76 -76
  16. package/gates/observability-coverage.md +151 -151
  17. package/gates/omm-no-regression.md +83 -83
  18. package/gates/postmortem-template-required.md +127 -127
  19. package/gates/prr-checklist-coverage.md +128 -128
  20. package/gates/regression.md +32 -32
  21. package/gates/release-pipeline-policy.md +132 -132
  22. package/gates/secrets-scan.md +33 -33
  23. package/gates/service-role-not-in-user-facing.md +113 -113
  24. package/gates/skill-must-include.md +71 -71
  25. package/gates/sync-idempotent.md +62 -62
  26. package/gates/verify-phase-goal.md +34 -34
  27. package/kit/agents/designer-ui.md +216 -216
  28. package/kit/agents/workflow-generator.md +537 -0
  29. package/kit/commands/adicionar-backlog.md +1 -1
  30. package/kit/commands/adicionar-fase.md +1 -1
  31. package/kit/commands/adicionar-tarefa.md +1 -1
  32. package/kit/commands/auditar-observabilidade.md +103 -103
  33. package/kit/commands/auditar-toil.md +129 -129
  34. package/kit/commands/caracterizar-prompt.md +195 -195
  35. package/kit/commands/criar-workflow.md +158 -0
  36. package/kit/commands/definir-perfil.md +1 -1
  37. package/kit/commands/definir-slo.md +108 -108
  38. package/kit/commands/fio.md +1 -1
  39. package/kit/commands/golden-signals.md +142 -142
  40. package/kit/commands/instrumentar-fase.md +200 -200
  41. package/kit/commands/investigar-producao.md +162 -162
  42. package/kit/commands/observabilidade.md +118 -118
  43. package/kit/commands/postmortem.md +179 -179
  44. package/kit/commands/prr.md +205 -205
  45. package/kit/commands/publicar-rapido.md +207 -207
  46. package/kit/commands/risk-budget.md +220 -220
  47. package/kit/commands/sre.md +230 -230
  48. package/kit/file-manifest.json +5 -2
  49. package/kit/framework/references/output-style.md +22 -22
  50. package/kit/hooks/post-apply-migration.js +199 -199
  51. package/kit/hooks/sidecar-tool-publisher.js +210 -210
  52. package/kit/skills/_shared-dados-distribuidos/glossary.md +224 -224
  53. package/kit/skills/_shared-legacy/glossary.md +389 -389
  54. package/kit/skills/_shared-multi-tenant/glossary.md +186 -186
  55. package/kit/skills/_shared-observability/glossary.md +396 -396
  56. package/kit/skills/_shared-sre/glossary.md +712 -712
  57. package/kit/skills/_shared-supabase/glossary.md +234 -234
  58. package/kit/skills/blameless-postmortems/SKILL.md +340 -340
  59. package/kit/skills/burn-rate-alerting/SKILL.md +258 -258
  60. package/kit/skills/cascading-failures/SKILL.md +311 -311
  61. package/kit/skills/core-analysis-loop/SKILL.md +352 -352
  62. package/kit/skills/distributed-tracing/SKILL.md +362 -362
  63. package/kit/skills/dynamic-workflow-authoring/SKILL.md +327 -0
  64. package/kit/skills/eliminating-toil/SKILL.md +243 -243
  65. package/kit/skills/event-based-slos/SKILL.md +296 -296
  66. package/kit/skills/four-golden-signals/SKILL.md +314 -314
  67. package/kit/skills/hermetic-builds/SKILL.md +323 -323
  68. package/kit/skills/legacy-monster-methods/SKILL.md +444 -444
  69. package/kit/skills/llm-as-dependency/SKILL.md +436 -436
  70. package/kit/skills/load-shedding-graceful-degradation/SKILL.md +396 -396
  71. package/kit/skills/observability-driven-development/SKILL.md +315 -315
  72. package/kit/skills/observability-maturity-model/SKILL.md +222 -222
  73. package/kit/skills/opentelemetry-standard/SKILL.md +351 -351
  74. package/kit/skills/production-readiness-review/SKILL.md +305 -305
  75. package/kit/skills/release-engineering/SKILL.md +367 -367
  76. package/kit/skills/retry-strategies/SKILL.md +372 -372
  77. package/kit/skills/sre-risk-management/SKILL.md +221 -221
  78. package/kit/skills/structured-events/SKILL.md +265 -265
  79. package/kit/skills/supabase-cron-queues/SKILL.md +275 -275
  80. package/kit/skills/supabase-database-functions/SKILL.md +332 -332
  81. package/kit/skills/supabase-declarative-schema/SKILL.md +183 -183
  82. package/kit/skills/supabase-pgvector-rag/SKILL.md +253 -253
  83. package/kit/skills/supabase-postgres-style/SKILL.md +138 -138
  84. package/kit/skills/supabase-storage/SKILL.md +234 -234
  85. package/kit/skills/telemetry-pipelines/SKILL.md +259 -259
  86. package/kit/skills/telemetry-sampling/SKILL.md +256 -256
  87. package/kit/skills/ui-anti-padroes-ia/SKILL.md +261 -261
  88. package/kit/skills/ui-contexto-produto/SKILL.md +248 -248
  89. package/kit/skills/ui-cor-estrategia/SKILL.md +213 -213
  90. package/kit/skills/ui-critica-auditoria/SKILL.md +260 -260
  91. package/kit/skills/ui-motion-funcional/SKILL.md +264 -264
  92. package/kit/skills/ui-ritmo-espacial/SKILL.md +259 -259
  93. package/kit/skills/ui-tipografia/SKILL.md +211 -211
  94. package/package.json +1 -1
  95. package/src/cli/index.js +1114 -1114
  96. package/src/cli/render.js +194 -194
  97. package/src/cli/upgrade-check.js +135 -135
  98. package/src/core/error-redaction.js +76 -76
  99. package/src/core/failures.js +153 -153
  100. package/src/core/gate-runner.js +205 -205
  101. package/src/core/gates.js +82 -82
  102. package/src/core/logger.js +170 -170
  103. package/src/core/manifest-verify.js +174 -174
  104. package/src/core/metrics.js +268 -268
  105. package/src/core/notify.js +60 -60
  106. package/src/core/path-safety.js +141 -141
  107. package/src/core/replays.js +120 -120
  108. package/src/core/ui.js +185 -185
  109. package/src/mcp-server/install.js +149 -149
  110. package/src/mcp-server/roots.js +124 -124
  111. package/src/ui/auto-spawn.js +113 -113
  112. package/src/ui/browser.js +78 -78
  113. package/src/ui/client.js +130 -130
  114. package/src/ui/events.js +65 -65
  115. package/src/ui/lockfile.js +191 -191
  116. package/src/ui/port.js +67 -67
  117. package/src/ui/server.js +547 -547
  118. package/src/ui/wrapper.js +129 -129
package/src/ui/server.js CHANGED
@@ -1,547 +1,547 @@
1
- // src/ui/server.js
2
- // Sidecar HTTP + Server-Sent Events server.
3
- //
4
- // Responsibilities:
5
- // - bind on 127.0.0.1 only (REQ SEC ADR-06)
6
- // - 5 routes: GET /, GET /events (SSE), GET /healthz, GET /state, POST /publish, POST /shutdown
7
- // - in-process EventEmitter bus relays POST /publish payloads to SSE subscribers
8
- // - ring buffer (200 events) for /state hydrate-on-load
9
- // - cap of 32 simultaneous SSE subscribers
10
- // - heartbeat every 15s on each open SSE connection
11
- // - idle shutdown after 30min default (REQ SRV-10)
12
- // - graceful SIGINT/SIGTERM (REQ SRV-11): emit shutdown event, drain, release lock
13
- // - Host header validation on every request (REQ SEC-01)
14
- // - Origin validation on non-GET (REQ SEC-02)
15
- //
16
- // Logging discipline: all log output goes to stderr or to a file. Never stdout.
17
- // (REQ SEC-04, enforced by CI gate in .github/workflows/ci.yml)
18
-
19
- import http from 'node:http';
20
- import { EventEmitter } from 'node:events';
21
- import { readFileSync } from 'node:fs';
22
- import path from 'node:path';
23
- import { fileURLToPath } from 'node:url';
24
- import process from 'node:process';
25
- import { createHash } from 'node:crypto';
26
-
27
- import { findFreePortOrThrow } from './port.js';
28
- import { acquireLockOrReclaim, releaseLock } from './lockfile.js';
29
- import { validateEvent, makeEvent, EVENT_TYPES } from './events.js';
30
-
31
- const HERE = path.dirname(fileURLToPath(import.meta.url));
32
- const STATIC_DIR = path.join(HERE, 'static');
33
- const HOST = '127.0.0.1';
34
- const HEARTBEAT_INTERVAL_MS = 15_000;
35
- const RING_BUFFER_SIZE = 200;
36
- const MAX_SSE_SUBSCRIBERS = 32;
37
- const DEFAULT_IDLE_MS = 0; // never auto-shutdown by default — pass --idle-ms 1800000 to opt back in
38
-
39
- const SSE_HEADERS = {
40
- 'Content-Type': 'text/event-stream; charset=utf-8',
41
- 'Cache-Control': 'no-cache, no-transform',
42
- 'Connection': 'keep-alive',
43
- 'X-Accel-Buffering': 'no',
44
- };
45
-
46
- // SEC-14-01: CSP without 'unsafe-inline' in script-src. The single inline
47
- // <script> block in index.html is allowed via SHA-256 hash injected at boot.
48
- // 'unsafe-inline' kept ONLY for style-src (the entire <style> block is intentional;
49
- // CSS injection has no script execution vector with connect-src 'self').
50
- function buildCsp(scriptHash) {
51
- const scriptSrc = scriptHash ? `'self' ${scriptHash}` : "'self'";
52
- return (
53
- "default-src 'self'; " +
54
- "connect-src 'self'; " +
55
- `script-src ${scriptSrc}; ` +
56
- "style-src 'self' 'unsafe-inline'; " +
57
- "img-src 'self' data:; " +
58
- "frame-ancestors 'none'"
59
- );
60
- }
61
-
62
- // Computes the SHA-256 hash of the inline <script> block in the static HTML.
63
- // Returns the CSP-formatted source expression: "'sha256-<base64>='".
64
- // Returns empty string if no <script> block found (graceful — caller falls back to "'self'" alone).
65
- function computeScriptHashFromHtml(html) {
66
- if (typeof html !== 'string') return '';
67
- const m = html.match(/<script>([\s\S]*?)<\/script>/);
68
- if (!m) return '';
69
- const hash = createHash('sha256').update(m[1], 'utf8').digest('base64');
70
- return `'sha256-${hash}'`;
71
- }
72
-
73
- function logErr(...args) {
74
- // Strict stderr discipline — never stdout (collides with MCP JSON-RPC if running in same process).
75
- process.stderr.write(args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n');
76
- }
77
-
78
- // SEC-14-02: per-process auth token. Set during start() from acquireLock result.
79
- // Cleared on shutdown(). Never logged in full.
80
- let authToken = null;
81
-
82
- // requireAuth: returns true if request has a valid token via either:
83
- // - Authorization: Bearer <token> (preferred for fetch from same-origin browser)
84
- // - ?t=<token> query param (required for EventSource — browser API can't set headers)
85
- // Caller is responsible for sending 401 when this returns false.
86
- function requireAuth(req, url) {
87
- if (!authToken) return false; // server didn't init token — fail closed
88
- const auth = req.headers.authorization;
89
- if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
90
- const provided = auth.slice('Bearer '.length).trim();
91
- if (timingSafeEqual(provided, authToken)) return true;
92
- }
93
- const qp = url?.searchParams?.get('t');
94
- if (typeof qp === 'string' && timingSafeEqual(qp, authToken)) return true;
95
- return false;
96
- }
97
-
98
- // Constant-time string comparison to prevent timing-leak side channel.
99
- // Walks the longer of the two strings even when lengths differ to keep timing flat.
100
- function timingSafeEqual(a, b) {
101
- if (typeof a !== 'string' || typeof b !== 'string') return false;
102
- const max = Math.max(a.length, b.length);
103
- let diff = a.length ^ b.length;
104
- for (let i = 0; i < max; i++) {
105
- diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
106
- }
107
- return diff === 0;
108
- }
109
-
110
- // Validate Host header against allowed hostnames (REQ SEC-01).
111
- // Allow 127.0.0.1 and localhost on whatever port we're on.
112
- function isHostAllowed(req, port) {
113
- const host = req.headers.host;
114
- if (!host) return false;
115
- const expected = [`127.0.0.1:${port}`, `localhost:${port}`];
116
- return expected.includes(host);
117
- }
118
-
119
- // Validate Origin header for non-GET requests (REQ SEC-02).
120
- // Same-origin (no Origin header on same-page fetch) or matching scheme+host+port.
121
- function isOriginAllowed(req, port) {
122
- const origin = req.headers.origin;
123
- if (!origin) return true; // same-origin fetch may omit Origin
124
- const expected = [`http://127.0.0.1:${port}`, `http://localhost:${port}`];
125
- return expected.includes(origin);
126
- }
127
-
128
- function send(res, status, headers, body) {
129
- res.writeHead(status, { ...headers });
130
- if (body !== undefined) res.end(body);
131
- else res.end();
132
- }
133
-
134
- function sendJson(res, status, obj) {
135
- send(res, status, { 'Content-Type': 'application/json; charset=utf-8' }, JSON.stringify(obj));
136
- }
137
-
138
- // Serialize an event into a single SSE message (with id for Last-Event-ID hint).
139
- function formatSseMessage(event, seq) {
140
- const payload = JSON.stringify(event);
141
- // SSE spec: \r\n is fine but \n is canonical; payload can contain \n which we must split.
142
- const dataLines = payload.split('\n').map((line) => `data: ${line}`).join('\n');
143
- return `id: ${seq}\nevent: ${event.type}\n${dataLines}\n\n`;
144
- }
145
-
146
- // Read a request body up to maxBytes. Resolves with Buffer; rejects on overflow.
147
- function readBody(req, maxBytes = 64 * 1024) {
148
- return new Promise((resolve, reject) => {
149
- const chunks = [];
150
- let size = 0;
151
- let aborted = false;
152
- req.on('data', (chunk) => {
153
- if (aborted) return;
154
- size += chunk.length;
155
- if (size > maxBytes) {
156
- aborted = true;
157
- // Don't destroy the request — let the caller send a 413 response first.
158
- // We just stop accumulating; further chunks (and 'end') are ignored.
159
- const err = new Error(`Request body exceeds ${maxBytes} bytes`);
160
- err.code = 'EBODYTOOBIG';
161
- reject(err);
162
- return;
163
- }
164
- chunks.push(chunk);
165
- });
166
- req.once('end', () => {
167
- if (aborted) return;
168
- resolve(Buffer.concat(chunks));
169
- });
170
- req.once('error', (err) => {
171
- if (aborted) return;
172
- reject(err);
173
- });
174
- });
175
- }
176
-
177
- let _cachedIndex = null; // { html, scriptHash }
178
- function loadStaticIndex() {
179
- // src/ui/static/index.html — written in Phase 14. We tolerate it missing in
180
- // unit tests by serving a placeholder so the server module is testable in isolation.
181
- if (_cachedIndex) return _cachedIndex;
182
- let html;
183
- try {
184
- html = readFileSync(path.join(STATIC_DIR, 'index.html'), 'utf8');
185
- } catch {
186
- html = `<!doctype html><meta charset="utf-8"><title>kit-mcp sidecar</title>
187
- <body><pre>UI not yet packaged. Run \`kit ui\` after Phase 14 is shipped.</pre></body>`;
188
- }
189
- // SEC-14-01: hash inline <script> for CSP whitelist. Cache per-process.
190
- const scriptHash = computeScriptHashFromHtml(html);
191
- _cachedIndex = { html, scriptHash };
192
- return _cachedIndex;
193
- }
194
-
195
- export function createServer({
196
- projectRoot,
197
- version = null,
198
- idleMs = DEFAULT_IDLE_MS,
199
- maxSubscribers = MAX_SSE_SUBSCRIBERS,
200
- ringSize = RING_BUFFER_SIZE,
201
- staticHtml,
202
- } = {}) {
203
- if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
204
- throw new TypeError('createServer requires projectRoot: string');
205
- }
206
-
207
- const bus = new EventEmitter();
208
- bus.setMaxListeners(maxSubscribers + 4);
209
-
210
- // Ring buffer for hydrate-on-load
211
- const ring = [];
212
- let nextSeq = 1;
213
- function pushEvent(evt) {
214
- evt._seq = nextSeq++;
215
- ring.push(evt);
216
- if (ring.length > ringSize) ring.shift();
217
- bus.emit('event', evt);
218
- }
219
-
220
- const subscribers = new Set();
221
- const activeSockets = new Set();
222
- let server = null;
223
- let listeningPort = 0;
224
- let lockMeta = null;
225
- let idleTimer = null;
226
- let lastEventTs = Date.now();
227
- let shuttingDown = false;
228
- let signalHandlers = null;
229
- const startedAt = Date.now();
230
-
231
- function resetIdleTimer() {
232
- if (idleMs <= 0) return;
233
- if (idleTimer) clearTimeout(idleTimer);
234
- idleTimer = setTimeout(() => {
235
- // Only auto-shutdown if no subscribers AND no recent events
236
- if (subscribers.size === 0) {
237
- logErr('[kit-mcp ui] idle shutdown after', Math.round(idleMs / 1000), 's');
238
- // eslint-disable-next-line no-use-before-define
239
- shutdown('idle').catch((err) => logErr('idle shutdown error:', err.message));
240
- } else {
241
- // Subscribers connected — push idle timer forward
242
- resetIdleTimer();
243
- }
244
- }, idleMs);
245
- // Don't keep event loop alive just for the idle timer
246
- if (typeof idleTimer.unref === 'function') idleTimer.unref();
247
- }
248
-
249
- async function shutdown(reason = 'sigterm') {
250
- if (shuttingDown) return;
251
- shuttingDown = true;
252
- if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
253
-
254
- // Notify subscribers
255
- const final = makeEvent({ type: 'shutdown', payload: { reason } });
256
- pushEvent(final);
257
-
258
- // Drain SSE
259
- for (const sub of subscribers) {
260
- try { sub.res.end(); } catch { /* noop */ }
261
- }
262
- subscribers.clear();
263
-
264
- // Stop accepting new connections AND destroy lingering sockets so close() resolves quickly.
265
- if (server) {
266
- for (const sock of activeSockets) {
267
- try { sock.destroy(); } catch { /* noop */ }
268
- }
269
- activeSockets.clear();
270
- await new Promise((resolve) => server.close(() => resolve()));
271
- server = null;
272
- }
273
-
274
- // Detach signal handlers so test harnesses don't accumulate listeners.
275
- if (signalHandlers) {
276
- try { process.removeListener('SIGINT', signalHandlers.sigint); } catch { /* noop */ }
277
- try { process.removeListener('SIGTERM', signalHandlers.sigterm); } catch { /* noop */ }
278
- signalHandlers = null;
279
- }
280
-
281
- // Release lockfile
282
- if (lockMeta) {
283
- try { releaseLock(projectRoot); } catch { /* noop */ }
284
- lockMeta = null;
285
- }
286
- authToken = null; // SEC-14-02: clear so a re-start gets a fresh one
287
- }
288
-
289
- function handleEvents(req, res, url) {
290
- if (!requireAuth(req, url)) {
291
- sendJson(res, 401, { error: 'auth_required' });
292
- return;
293
- }
294
- if (subscribers.size >= maxSubscribers) {
295
- sendJson(res, 503, { error: 'too_many_subscribers', max: maxSubscribers });
296
- return;
297
- }
298
-
299
- res.writeHead(200, SSE_HEADERS);
300
- if (typeof res.flushHeaders === 'function') res.flushHeaders();
301
-
302
- // Optional retry hint for the browser EventSource (3s)
303
- res.write('retry: 3000\n\n');
304
-
305
- const sub = { req, res };
306
- subscribers.add(sub);
307
-
308
- const onEvent = (evt) => {
309
- try {
310
- res.write(formatSseMessage(evt, evt._seq ?? 0));
311
- } catch {
312
- cleanup();
313
- }
314
- };
315
- bus.on('event', onEvent);
316
-
317
- const heartbeat = setInterval(() => {
318
- try { res.write(`: ping ${Date.now()}\n\n`); } catch { cleanup(); }
319
- }, HEARTBEAT_INTERVAL_MS);
320
- if (typeof heartbeat.unref === 'function') heartbeat.unref();
321
-
322
- function cleanup() {
323
- if (!subscribers.has(sub)) return;
324
- subscribers.delete(sub);
325
- clearInterval(heartbeat);
326
- bus.off('event', onEvent);
327
- try { res.end(); } catch { /* noop */ }
328
- }
329
-
330
- req.on('close', cleanup);
331
- req.on('error', cleanup);
332
- res.on('close', cleanup);
333
- res.on('error', cleanup);
334
- }
335
-
336
- async function handlePublish(req, res, url) {
337
- if (!requireAuth(req, url)) {
338
- sendJson(res, 401, { error: 'auth_required' });
339
- return;
340
- }
341
- if (!isOriginAllowed(req, listeningPort)) {
342
- sendJson(res, 403, { error: 'origin_not_allowed' });
343
- return;
344
- }
345
- let body;
346
- try {
347
- body = await readBody(req, 64 * 1024);
348
- } catch (err) {
349
- const status = err.code === 'EBODYTOOBIG' ? 413 : 400;
350
- sendJson(res, status, { error: err.message });
351
- return;
352
- }
353
- let parsed;
354
- try {
355
- parsed = JSON.parse(body.toString('utf8'));
356
- } catch (err) {
357
- sendJson(res, 400, { error: `invalid_json: ${err.message}` });
358
- return;
359
- }
360
- const validationErr = validateEvent(parsed);
361
- if (validationErr) {
362
- sendJson(res, 400, { error: validationErr.message });
363
- return;
364
- }
365
- pushEvent(parsed);
366
- lastEventTs = Date.now();
367
- resetIdleTimer();
368
- sendJson(res, 202, { ok: true, seq: parsed._seq });
369
- }
370
-
371
- function handleHealthz(res) {
372
- sendJson(res, 200, {
373
- ok: true,
374
- version,
375
- uptime: Date.now() - startedAt,
376
- port: listeningPort,
377
- subscribers: subscribers.size,
378
- eventsTotal: nextSeq - 1,
379
- });
380
- }
381
-
382
- // PERF-05: optional pagination via ?offset=N&limit=M. No query → ring inteiro
383
- // (back-compat preservada). Out-of-range values clamp to bounds rather than 4xx.
384
- function handleState(req, res, url) {
385
- if (!requireAuth(req, url)) {
386
- sendJson(res, 401, { error: 'auth_required' });
387
- return;
388
- }
389
- let events = ring;
390
- const offsetRaw = url?.searchParams?.get('offset');
391
- const limitRaw = url?.searchParams?.get('limit');
392
- if (offsetRaw !== null && offsetRaw !== undefined) {
393
- const offset = Math.max(0, Number.parseInt(offsetRaw, 10) || 0);
394
- const limit = limitRaw !== null && limitRaw !== undefined
395
- ? Math.max(0, Number.parseInt(limitRaw, 10) || 0)
396
- : ring.length - offset;
397
- events = ring.slice(offset, offset + limit);
398
- } else if (limitRaw !== null && limitRaw !== undefined) {
399
- const limit = Math.max(0, Number.parseInt(limitRaw, 10) || 0);
400
- events = ring.slice(0, limit);
401
- } else {
402
- events = ring.slice();
403
- }
404
- sendJson(res, 200, {
405
- version,
406
- port: listeningPort,
407
- eventsTotal: nextSeq - 1,
408
- ringSize: ring.length,
409
- events,
410
- });
411
- }
412
-
413
- async function handleShutdownRequest(req, res, url) {
414
- if (!requireAuth(req, url)) {
415
- sendJson(res, 401, { error: 'auth_required' });
416
- return;
417
- }
418
- if (!isOriginAllowed(req, listeningPort)) {
419
- sendJson(res, 403, { error: 'origin_not_allowed' });
420
- return;
421
- }
422
- sendJson(res, 200, { ok: true, draining: true });
423
- setImmediate(() => {
424
- shutdown('explicit').catch((err) => logErr('shutdown error:', err.message));
425
- });
426
- }
427
-
428
- function handleIndex(res) {
429
- let html, scriptHash;
430
- if (typeof staticHtml === 'string') {
431
- html = staticHtml;
432
- scriptHash = computeScriptHashFromHtml(staticHtml);
433
- } else {
434
- ({ html, scriptHash } = loadStaticIndex());
435
- }
436
- res.writeHead(200, {
437
- 'Content-Type': 'text/html; charset=utf-8',
438
- 'Content-Security-Policy': buildCsp(scriptHash),
439
- 'X-Content-Type-Options': 'nosniff',
440
- 'Referrer-Policy': 'no-referrer',
441
- });
442
- res.end(html);
443
- }
444
-
445
- async function handleRequest(req, res) {
446
- if (!isHostAllowed(req, listeningPort)) {
447
- sendJson(res, 403, { error: 'host_not_allowed', expected: ['127.0.0.1', 'localhost'] });
448
- return;
449
- }
450
- const url = new URL(req.url, `http://${HOST}:${listeningPort}`);
451
- const route = `${req.method} ${url.pathname}`;
452
-
453
- try {
454
- switch (route) {
455
- case 'GET /':
456
- case 'GET /index.html':
457
- return handleIndex(res);
458
- case 'GET /events':
459
- return handleEvents(req, res, url);
460
- case 'GET /healthz':
461
- return handleHealthz(res);
462
- case 'GET /state':
463
- return handleState(req, res, url);
464
- case 'POST /publish':
465
- return handlePublish(req, res, url);
466
- case 'POST /shutdown':
467
- return handleShutdownRequest(req, res, url);
468
- default:
469
- return sendJson(res, 404, { error: 'not_found', route });
470
- }
471
- } catch (err) {
472
- logErr('handler error:', err.message);
473
- try { sendJson(res, 500, { error: 'internal_error' }); } catch { /* noop */ }
474
- }
475
- }
476
-
477
- async function start({ port } = {}) {
478
- listeningPort = port ?? (await findFreePortOrThrow());
479
- lockMeta = await acquireLockOrReclaim({
480
- projectRoot,
481
- port: listeningPort,
482
- version,
483
- startedAt,
484
- });
485
- // SEC-14-02: copy per-process token from lockfile into closure for requireAuth.
486
- authToken = lockMeta.token;
487
- if (typeof authToken !== 'string' || authToken.length !== 64) {
488
- throw new Error('SEC-14-02: lockMeta.token missing or malformed; refusing to start');
489
- }
490
- server = http.createServer(handleRequest);
491
- server.on('connection', (sock) => {
492
- activeSockets.add(sock);
493
- sock.on('close', () => activeSockets.delete(sock));
494
- });
495
- await new Promise((resolve, reject) => {
496
- server.once('error', reject);
497
- server.listen(listeningPort, HOST, () => {
498
- server.removeListener('error', reject);
499
- resolve();
500
- });
501
- });
502
- resetIdleTimer();
503
-
504
- // Graceful shutdown handlers (REQ SRV-11). Stored so we can detach in shutdown().
505
- const sigint = () => {
506
- logErr('[kit-mcp ui] received SIGINT, shutting down');
507
- shutdown('SIGINT').catch((err) => logErr('shutdown error:', err.message));
508
- };
509
- const sigterm = () => {
510
- logErr('[kit-mcp ui] received SIGTERM, shutting down');
511
- shutdown('SIGTERM').catch((err) => logErr('shutdown error:', err.message));
512
- };
513
- signalHandlers = { sigint, sigterm };
514
- process.on('SIGINT', sigint);
515
- process.on('SIGTERM', sigterm);
516
-
517
- // run.start event
518
- pushEvent(makeEvent({ type: 'run.start', payload: { server: 'sidecar', version, port: listeningPort } }));
519
-
520
- return { port: listeningPort, lockMeta };
521
- }
522
-
523
- return {
524
- start,
525
- shutdown,
526
- pushEvent, // for tests
527
- get url() { return `http://${HOST}:${listeningPort}/`; },
528
- get port() { return listeningPort; },
529
- get subscriberCount() { return subscribers.size; },
530
- get eventsTotal() { return nextSeq - 1; },
531
- };
532
- }
533
-
534
- export const __test = {
535
- RING_BUFFER_SIZE,
536
- MAX_SSE_SUBSCRIBERS,
537
- DEFAULT_IDLE_MS,
538
- HEARTBEAT_INTERVAL_MS,
539
- // SEC-14-01: CSP is now built dynamically with sha256 hash of inline <script>.
540
- // The constant CSP no longer exists; tests should use buildCsp(scriptHash).
541
- buildCsp,
542
- computeScriptHashFromHtml,
543
- EVENT_TYPES,
544
- // SEC-14-02: timingSafeEqual exposed for unit tests; requireAuth depends on
545
- // closure state (authToken) so end-to-end HTTP tests verify behavior.
546
- timingSafeEqual,
547
- };
1
+ // src/ui/server.js
2
+ // Sidecar HTTP + Server-Sent Events server.
3
+ //
4
+ // Responsibilities:
5
+ // - bind on 127.0.0.1 only (REQ SEC ADR-06)
6
+ // - 5 routes: GET /, GET /events (SSE), GET /healthz, GET /state, POST /publish, POST /shutdown
7
+ // - in-process EventEmitter bus relays POST /publish payloads to SSE subscribers
8
+ // - ring buffer (200 events) for /state hydrate-on-load
9
+ // - cap of 32 simultaneous SSE subscribers
10
+ // - heartbeat every 15s on each open SSE connection
11
+ // - idle shutdown after 30min default (REQ SRV-10)
12
+ // - graceful SIGINT/SIGTERM (REQ SRV-11): emit shutdown event, drain, release lock
13
+ // - Host header validation on every request (REQ SEC-01)
14
+ // - Origin validation on non-GET (REQ SEC-02)
15
+ //
16
+ // Logging discipline: all log output goes to stderr or to a file. Never stdout.
17
+ // (REQ SEC-04, enforced by CI gate in .github/workflows/ci.yml)
18
+
19
+ import http from 'node:http';
20
+ import { EventEmitter } from 'node:events';
21
+ import { readFileSync } from 'node:fs';
22
+ import path from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import process from 'node:process';
25
+ import { createHash } from 'node:crypto';
26
+
27
+ import { findFreePortOrThrow } from './port.js';
28
+ import { acquireLockOrReclaim, releaseLock } from './lockfile.js';
29
+ import { validateEvent, makeEvent, EVENT_TYPES } from './events.js';
30
+
31
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
32
+ const STATIC_DIR = path.join(HERE, 'static');
33
+ const HOST = '127.0.0.1';
34
+ const HEARTBEAT_INTERVAL_MS = 15_000;
35
+ const RING_BUFFER_SIZE = 200;
36
+ const MAX_SSE_SUBSCRIBERS = 32;
37
+ const DEFAULT_IDLE_MS = 0; // never auto-shutdown by default — pass --idle-ms 1800000 to opt back in
38
+
39
+ const SSE_HEADERS = {
40
+ 'Content-Type': 'text/event-stream; charset=utf-8',
41
+ 'Cache-Control': 'no-cache, no-transform',
42
+ 'Connection': 'keep-alive',
43
+ 'X-Accel-Buffering': 'no',
44
+ };
45
+
46
+ // SEC-14-01: CSP without 'unsafe-inline' in script-src. The single inline
47
+ // <script> block in index.html is allowed via SHA-256 hash injected at boot.
48
+ // 'unsafe-inline' kept ONLY for style-src (the entire <style> block is intentional;
49
+ // CSS injection has no script execution vector with connect-src 'self').
50
+ function buildCsp(scriptHash) {
51
+ const scriptSrc = scriptHash ? `'self' ${scriptHash}` : "'self'";
52
+ return (
53
+ "default-src 'self'; " +
54
+ "connect-src 'self'; " +
55
+ `script-src ${scriptSrc}; ` +
56
+ "style-src 'self' 'unsafe-inline'; " +
57
+ "img-src 'self' data:; " +
58
+ "frame-ancestors 'none'"
59
+ );
60
+ }
61
+
62
+ // Computes the SHA-256 hash of the inline <script> block in the static HTML.
63
+ // Returns the CSP-formatted source expression: "'sha256-<base64>='".
64
+ // Returns empty string if no <script> block found (graceful — caller falls back to "'self'" alone).
65
+ function computeScriptHashFromHtml(html) {
66
+ if (typeof html !== 'string') return '';
67
+ const m = html.match(/<script>([\s\S]*?)<\/script>/);
68
+ if (!m) return '';
69
+ const hash = createHash('sha256').update(m[1], 'utf8').digest('base64');
70
+ return `'sha256-${hash}'`;
71
+ }
72
+
73
+ function logErr(...args) {
74
+ // Strict stderr discipline — never stdout (collides with MCP JSON-RPC if running in same process).
75
+ process.stderr.write(args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n');
76
+ }
77
+
78
+ // SEC-14-02: per-process auth token. Set during start() from acquireLock result.
79
+ // Cleared on shutdown(). Never logged in full.
80
+ let authToken = null;
81
+
82
+ // requireAuth: returns true if request has a valid token via either:
83
+ // - Authorization: Bearer <token> (preferred for fetch from same-origin browser)
84
+ // - ?t=<token> query param (required for EventSource — browser API can't set headers)
85
+ // Caller is responsible for sending 401 when this returns false.
86
+ function requireAuth(req, url) {
87
+ if (!authToken) return false; // server didn't init token — fail closed
88
+ const auth = req.headers.authorization;
89
+ if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
90
+ const provided = auth.slice('Bearer '.length).trim();
91
+ if (timingSafeEqual(provided, authToken)) return true;
92
+ }
93
+ const qp = url?.searchParams?.get('t');
94
+ if (typeof qp === 'string' && timingSafeEqual(qp, authToken)) return true;
95
+ return false;
96
+ }
97
+
98
+ // Constant-time string comparison to prevent timing-leak side channel.
99
+ // Walks the longer of the two strings even when lengths differ to keep timing flat.
100
+ function timingSafeEqual(a, b) {
101
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
102
+ const max = Math.max(a.length, b.length);
103
+ let diff = a.length ^ b.length;
104
+ for (let i = 0; i < max; i++) {
105
+ diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
106
+ }
107
+ return diff === 0;
108
+ }
109
+
110
+ // Validate Host header against allowed hostnames (REQ SEC-01).
111
+ // Allow 127.0.0.1 and localhost on whatever port we're on.
112
+ function isHostAllowed(req, port) {
113
+ const host = req.headers.host;
114
+ if (!host) return false;
115
+ const expected = [`127.0.0.1:${port}`, `localhost:${port}`];
116
+ return expected.includes(host);
117
+ }
118
+
119
+ // Validate Origin header for non-GET requests (REQ SEC-02).
120
+ // Same-origin (no Origin header on same-page fetch) or matching scheme+host+port.
121
+ function isOriginAllowed(req, port) {
122
+ const origin = req.headers.origin;
123
+ if (!origin) return true; // same-origin fetch may omit Origin
124
+ const expected = [`http://127.0.0.1:${port}`, `http://localhost:${port}`];
125
+ return expected.includes(origin);
126
+ }
127
+
128
+ function send(res, status, headers, body) {
129
+ res.writeHead(status, { ...headers });
130
+ if (body !== undefined) res.end(body);
131
+ else res.end();
132
+ }
133
+
134
+ function sendJson(res, status, obj) {
135
+ send(res, status, { 'Content-Type': 'application/json; charset=utf-8' }, JSON.stringify(obj));
136
+ }
137
+
138
+ // Serialize an event into a single SSE message (with id for Last-Event-ID hint).
139
+ function formatSseMessage(event, seq) {
140
+ const payload = JSON.stringify(event);
141
+ // SSE spec: \r\n is fine but \n is canonical; payload can contain \n which we must split.
142
+ const dataLines = payload.split('\n').map((line) => `data: ${line}`).join('\n');
143
+ return `id: ${seq}\nevent: ${event.type}\n${dataLines}\n\n`;
144
+ }
145
+
146
+ // Read a request body up to maxBytes. Resolves with Buffer; rejects on overflow.
147
+ function readBody(req, maxBytes = 64 * 1024) {
148
+ return new Promise((resolve, reject) => {
149
+ const chunks = [];
150
+ let size = 0;
151
+ let aborted = false;
152
+ req.on('data', (chunk) => {
153
+ if (aborted) return;
154
+ size += chunk.length;
155
+ if (size > maxBytes) {
156
+ aborted = true;
157
+ // Don't destroy the request — let the caller send a 413 response first.
158
+ // We just stop accumulating; further chunks (and 'end') are ignored.
159
+ const err = new Error(`Request body exceeds ${maxBytes} bytes`);
160
+ err.code = 'EBODYTOOBIG';
161
+ reject(err);
162
+ return;
163
+ }
164
+ chunks.push(chunk);
165
+ });
166
+ req.once('end', () => {
167
+ if (aborted) return;
168
+ resolve(Buffer.concat(chunks));
169
+ });
170
+ req.once('error', (err) => {
171
+ if (aborted) return;
172
+ reject(err);
173
+ });
174
+ });
175
+ }
176
+
177
+ let _cachedIndex = null; // { html, scriptHash }
178
+ function loadStaticIndex() {
179
+ // src/ui/static/index.html — written in Phase 14. We tolerate it missing in
180
+ // unit tests by serving a placeholder so the server module is testable in isolation.
181
+ if (_cachedIndex) return _cachedIndex;
182
+ let html;
183
+ try {
184
+ html = readFileSync(path.join(STATIC_DIR, 'index.html'), 'utf8');
185
+ } catch {
186
+ html = `<!doctype html><meta charset="utf-8"><title>kit-mcp sidecar</title>
187
+ <body><pre>UI not yet packaged. Run \`kit ui\` after Phase 14 is shipped.</pre></body>`;
188
+ }
189
+ // SEC-14-01: hash inline <script> for CSP whitelist. Cache per-process.
190
+ const scriptHash = computeScriptHashFromHtml(html);
191
+ _cachedIndex = { html, scriptHash };
192
+ return _cachedIndex;
193
+ }
194
+
195
+ export function createServer({
196
+ projectRoot,
197
+ version = null,
198
+ idleMs = DEFAULT_IDLE_MS,
199
+ maxSubscribers = MAX_SSE_SUBSCRIBERS,
200
+ ringSize = RING_BUFFER_SIZE,
201
+ staticHtml,
202
+ } = {}) {
203
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
204
+ throw new TypeError('createServer requires projectRoot: string');
205
+ }
206
+
207
+ const bus = new EventEmitter();
208
+ bus.setMaxListeners(maxSubscribers + 4);
209
+
210
+ // Ring buffer for hydrate-on-load
211
+ const ring = [];
212
+ let nextSeq = 1;
213
+ function pushEvent(evt) {
214
+ evt._seq = nextSeq++;
215
+ ring.push(evt);
216
+ if (ring.length > ringSize) ring.shift();
217
+ bus.emit('event', evt);
218
+ }
219
+
220
+ const subscribers = new Set();
221
+ const activeSockets = new Set();
222
+ let server = null;
223
+ let listeningPort = 0;
224
+ let lockMeta = null;
225
+ let idleTimer = null;
226
+ let lastEventTs = Date.now();
227
+ let shuttingDown = false;
228
+ let signalHandlers = null;
229
+ const startedAt = Date.now();
230
+
231
+ function resetIdleTimer() {
232
+ if (idleMs <= 0) return;
233
+ if (idleTimer) clearTimeout(idleTimer);
234
+ idleTimer = setTimeout(() => {
235
+ // Only auto-shutdown if no subscribers AND no recent events
236
+ if (subscribers.size === 0) {
237
+ logErr('[kit-mcp ui] idle shutdown after', Math.round(idleMs / 1000), 's');
238
+ // eslint-disable-next-line no-use-before-define
239
+ shutdown('idle').catch((err) => logErr('idle shutdown error:', err.message));
240
+ } else {
241
+ // Subscribers connected — push idle timer forward
242
+ resetIdleTimer();
243
+ }
244
+ }, idleMs);
245
+ // Don't keep event loop alive just for the idle timer
246
+ if (typeof idleTimer.unref === 'function') idleTimer.unref();
247
+ }
248
+
249
+ async function shutdown(reason = 'sigterm') {
250
+ if (shuttingDown) return;
251
+ shuttingDown = true;
252
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
253
+
254
+ // Notify subscribers
255
+ const final = makeEvent({ type: 'shutdown', payload: { reason } });
256
+ pushEvent(final);
257
+
258
+ // Drain SSE
259
+ for (const sub of subscribers) {
260
+ try { sub.res.end(); } catch { /* noop */ }
261
+ }
262
+ subscribers.clear();
263
+
264
+ // Stop accepting new connections AND destroy lingering sockets so close() resolves quickly.
265
+ if (server) {
266
+ for (const sock of activeSockets) {
267
+ try { sock.destroy(); } catch { /* noop */ }
268
+ }
269
+ activeSockets.clear();
270
+ await new Promise((resolve) => server.close(() => resolve()));
271
+ server = null;
272
+ }
273
+
274
+ // Detach signal handlers so test harnesses don't accumulate listeners.
275
+ if (signalHandlers) {
276
+ try { process.removeListener('SIGINT', signalHandlers.sigint); } catch { /* noop */ }
277
+ try { process.removeListener('SIGTERM', signalHandlers.sigterm); } catch { /* noop */ }
278
+ signalHandlers = null;
279
+ }
280
+
281
+ // Release lockfile
282
+ if (lockMeta) {
283
+ try { releaseLock(projectRoot); } catch { /* noop */ }
284
+ lockMeta = null;
285
+ }
286
+ authToken = null; // SEC-14-02: clear so a re-start gets a fresh one
287
+ }
288
+
289
+ function handleEvents(req, res, url) {
290
+ if (!requireAuth(req, url)) {
291
+ sendJson(res, 401, { error: 'auth_required' });
292
+ return;
293
+ }
294
+ if (subscribers.size >= maxSubscribers) {
295
+ sendJson(res, 503, { error: 'too_many_subscribers', max: maxSubscribers });
296
+ return;
297
+ }
298
+
299
+ res.writeHead(200, SSE_HEADERS);
300
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
301
+
302
+ // Optional retry hint for the browser EventSource (3s)
303
+ res.write('retry: 3000\n\n');
304
+
305
+ const sub = { req, res };
306
+ subscribers.add(sub);
307
+
308
+ const onEvent = (evt) => {
309
+ try {
310
+ res.write(formatSseMessage(evt, evt._seq ?? 0));
311
+ } catch {
312
+ cleanup();
313
+ }
314
+ };
315
+ bus.on('event', onEvent);
316
+
317
+ const heartbeat = setInterval(() => {
318
+ try { res.write(`: ping ${Date.now()}\n\n`); } catch { cleanup(); }
319
+ }, HEARTBEAT_INTERVAL_MS);
320
+ if (typeof heartbeat.unref === 'function') heartbeat.unref();
321
+
322
+ function cleanup() {
323
+ if (!subscribers.has(sub)) return;
324
+ subscribers.delete(sub);
325
+ clearInterval(heartbeat);
326
+ bus.off('event', onEvent);
327
+ try { res.end(); } catch { /* noop */ }
328
+ }
329
+
330
+ req.on('close', cleanup);
331
+ req.on('error', cleanup);
332
+ res.on('close', cleanup);
333
+ res.on('error', cleanup);
334
+ }
335
+
336
+ async function handlePublish(req, res, url) {
337
+ if (!requireAuth(req, url)) {
338
+ sendJson(res, 401, { error: 'auth_required' });
339
+ return;
340
+ }
341
+ if (!isOriginAllowed(req, listeningPort)) {
342
+ sendJson(res, 403, { error: 'origin_not_allowed' });
343
+ return;
344
+ }
345
+ let body;
346
+ try {
347
+ body = await readBody(req, 64 * 1024);
348
+ } catch (err) {
349
+ const status = err.code === 'EBODYTOOBIG' ? 413 : 400;
350
+ sendJson(res, status, { error: err.message });
351
+ return;
352
+ }
353
+ let parsed;
354
+ try {
355
+ parsed = JSON.parse(body.toString('utf8'));
356
+ } catch (err) {
357
+ sendJson(res, 400, { error: `invalid_json: ${err.message}` });
358
+ return;
359
+ }
360
+ const validationErr = validateEvent(parsed);
361
+ if (validationErr) {
362
+ sendJson(res, 400, { error: validationErr.message });
363
+ return;
364
+ }
365
+ pushEvent(parsed);
366
+ lastEventTs = Date.now();
367
+ resetIdleTimer();
368
+ sendJson(res, 202, { ok: true, seq: parsed._seq });
369
+ }
370
+
371
+ function handleHealthz(res) {
372
+ sendJson(res, 200, {
373
+ ok: true,
374
+ version,
375
+ uptime: Date.now() - startedAt,
376
+ port: listeningPort,
377
+ subscribers: subscribers.size,
378
+ eventsTotal: nextSeq - 1,
379
+ });
380
+ }
381
+
382
+ // PERF-05: optional pagination via ?offset=N&limit=M. No query → ring inteiro
383
+ // (back-compat preservada). Out-of-range values clamp to bounds rather than 4xx.
384
+ function handleState(req, res, url) {
385
+ if (!requireAuth(req, url)) {
386
+ sendJson(res, 401, { error: 'auth_required' });
387
+ return;
388
+ }
389
+ let events = ring;
390
+ const offsetRaw = url?.searchParams?.get('offset');
391
+ const limitRaw = url?.searchParams?.get('limit');
392
+ if (offsetRaw !== null && offsetRaw !== undefined) {
393
+ const offset = Math.max(0, Number.parseInt(offsetRaw, 10) || 0);
394
+ const limit = limitRaw !== null && limitRaw !== undefined
395
+ ? Math.max(0, Number.parseInt(limitRaw, 10) || 0)
396
+ : ring.length - offset;
397
+ events = ring.slice(offset, offset + limit);
398
+ } else if (limitRaw !== null && limitRaw !== undefined) {
399
+ const limit = Math.max(0, Number.parseInt(limitRaw, 10) || 0);
400
+ events = ring.slice(0, limit);
401
+ } else {
402
+ events = ring.slice();
403
+ }
404
+ sendJson(res, 200, {
405
+ version,
406
+ port: listeningPort,
407
+ eventsTotal: nextSeq - 1,
408
+ ringSize: ring.length,
409
+ events,
410
+ });
411
+ }
412
+
413
+ async function handleShutdownRequest(req, res, url) {
414
+ if (!requireAuth(req, url)) {
415
+ sendJson(res, 401, { error: 'auth_required' });
416
+ return;
417
+ }
418
+ if (!isOriginAllowed(req, listeningPort)) {
419
+ sendJson(res, 403, { error: 'origin_not_allowed' });
420
+ return;
421
+ }
422
+ sendJson(res, 200, { ok: true, draining: true });
423
+ setImmediate(() => {
424
+ shutdown('explicit').catch((err) => logErr('shutdown error:', err.message));
425
+ });
426
+ }
427
+
428
+ function handleIndex(res) {
429
+ let html, scriptHash;
430
+ if (typeof staticHtml === 'string') {
431
+ html = staticHtml;
432
+ scriptHash = computeScriptHashFromHtml(staticHtml);
433
+ } else {
434
+ ({ html, scriptHash } = loadStaticIndex());
435
+ }
436
+ res.writeHead(200, {
437
+ 'Content-Type': 'text/html; charset=utf-8',
438
+ 'Content-Security-Policy': buildCsp(scriptHash),
439
+ 'X-Content-Type-Options': 'nosniff',
440
+ 'Referrer-Policy': 'no-referrer',
441
+ });
442
+ res.end(html);
443
+ }
444
+
445
+ async function handleRequest(req, res) {
446
+ if (!isHostAllowed(req, listeningPort)) {
447
+ sendJson(res, 403, { error: 'host_not_allowed', expected: ['127.0.0.1', 'localhost'] });
448
+ return;
449
+ }
450
+ const url = new URL(req.url, `http://${HOST}:${listeningPort}`);
451
+ const route = `${req.method} ${url.pathname}`;
452
+
453
+ try {
454
+ switch (route) {
455
+ case 'GET /':
456
+ case 'GET /index.html':
457
+ return handleIndex(res);
458
+ case 'GET /events':
459
+ return handleEvents(req, res, url);
460
+ case 'GET /healthz':
461
+ return handleHealthz(res);
462
+ case 'GET /state':
463
+ return handleState(req, res, url);
464
+ case 'POST /publish':
465
+ return handlePublish(req, res, url);
466
+ case 'POST /shutdown':
467
+ return handleShutdownRequest(req, res, url);
468
+ default:
469
+ return sendJson(res, 404, { error: 'not_found', route });
470
+ }
471
+ } catch (err) {
472
+ logErr('handler error:', err.message);
473
+ try { sendJson(res, 500, { error: 'internal_error' }); } catch { /* noop */ }
474
+ }
475
+ }
476
+
477
+ async function start({ port } = {}) {
478
+ listeningPort = port ?? (await findFreePortOrThrow());
479
+ lockMeta = await acquireLockOrReclaim({
480
+ projectRoot,
481
+ port: listeningPort,
482
+ version,
483
+ startedAt,
484
+ });
485
+ // SEC-14-02: copy per-process token from lockfile into closure for requireAuth.
486
+ authToken = lockMeta.token;
487
+ if (typeof authToken !== 'string' || authToken.length !== 64) {
488
+ throw new Error('SEC-14-02: lockMeta.token missing or malformed; refusing to start');
489
+ }
490
+ server = http.createServer(handleRequest);
491
+ server.on('connection', (sock) => {
492
+ activeSockets.add(sock);
493
+ sock.on('close', () => activeSockets.delete(sock));
494
+ });
495
+ await new Promise((resolve, reject) => {
496
+ server.once('error', reject);
497
+ server.listen(listeningPort, HOST, () => {
498
+ server.removeListener('error', reject);
499
+ resolve();
500
+ });
501
+ });
502
+ resetIdleTimer();
503
+
504
+ // Graceful shutdown handlers (REQ SRV-11). Stored so we can detach in shutdown().
505
+ const sigint = () => {
506
+ logErr('[kit-mcp ui] received SIGINT, shutting down');
507
+ shutdown('SIGINT').catch((err) => logErr('shutdown error:', err.message));
508
+ };
509
+ const sigterm = () => {
510
+ logErr('[kit-mcp ui] received SIGTERM, shutting down');
511
+ shutdown('SIGTERM').catch((err) => logErr('shutdown error:', err.message));
512
+ };
513
+ signalHandlers = { sigint, sigterm };
514
+ process.on('SIGINT', sigint);
515
+ process.on('SIGTERM', sigterm);
516
+
517
+ // run.start event
518
+ pushEvent(makeEvent({ type: 'run.start', payload: { server: 'sidecar', version, port: listeningPort } }));
519
+
520
+ return { port: listeningPort, lockMeta };
521
+ }
522
+
523
+ return {
524
+ start,
525
+ shutdown,
526
+ pushEvent, // for tests
527
+ get url() { return `http://${HOST}:${listeningPort}/`; },
528
+ get port() { return listeningPort; },
529
+ get subscriberCount() { return subscribers.size; },
530
+ get eventsTotal() { return nextSeq - 1; },
531
+ };
532
+ }
533
+
534
+ export const __test = {
535
+ RING_BUFFER_SIZE,
536
+ MAX_SSE_SUBSCRIBERS,
537
+ DEFAULT_IDLE_MS,
538
+ HEARTBEAT_INTERVAL_MS,
539
+ // SEC-14-01: CSP is now built dynamically with sha256 hash of inline <script>.
540
+ // The constant CSP no longer exists; tests should use buildCsp(scriptHash).
541
+ buildCsp,
542
+ computeScriptHashFromHtml,
543
+ EVENT_TYPES,
544
+ // SEC-14-02: timingSafeEqual exposed for unit tests; requireAuth depends on
545
+ // closure state (authToken) so end-to-end HTTP tests verify behavior.
546
+ timingSafeEqual,
547
+ };