@luanpdd/kit-mcp 1.35.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 (117) hide show
  1. package/bin/cli.js +2 -2
  2. package/bin/mcp.js +6 -6
  3. package/bin/ui.js +74 -74
  4. package/gates/ai-prompt-stability.md +120 -120
  5. package/gates/budget-description.md +68 -68
  6. package/gates/confidence.md +29 -29
  7. package/gates/dependency-check.md +33 -33
  8. package/gates/dept-cycle-prevention.md +179 -179
  9. package/gates/golden-signals-coverage.md +133 -133
  10. package/gates/legacy-refactor-safety.md +178 -178
  11. package/gates/multi-tenant-rls-coverage.md +102 -102
  12. package/gates/no-personal-uuid.md +72 -72
  13. package/gates/obs-agents-mcp-supabase.md +86 -86
  14. package/gates/obs-skills-frontmatter.md +76 -76
  15. package/gates/observability-coverage.md +151 -151
  16. package/gates/omm-no-regression.md +83 -83
  17. package/gates/postmortem-template-required.md +127 -127
  18. package/gates/prr-checklist-coverage.md +128 -128
  19. package/gates/regression.md +32 -32
  20. package/gates/release-pipeline-policy.md +132 -132
  21. package/gates/secrets-scan.md +33 -33
  22. package/gates/service-role-not-in-user-facing.md +113 -113
  23. package/gates/skill-must-include.md +71 -71
  24. package/gates/sync-idempotent.md +62 -62
  25. package/gates/verify-phase-goal.md +34 -34
  26. package/kit/agents/designer-ui.md +216 -216
  27. package/kit/agents/workflow-generator.md +537 -167
  28. package/kit/commands/adicionar-backlog.md +1 -1
  29. package/kit/commands/adicionar-fase.md +1 -1
  30. package/kit/commands/adicionar-tarefa.md +1 -1
  31. package/kit/commands/auditar-observabilidade.md +103 -103
  32. package/kit/commands/auditar-toil.md +129 -129
  33. package/kit/commands/caracterizar-prompt.md +195 -195
  34. package/kit/commands/criar-workflow.md +158 -158
  35. package/kit/commands/definir-perfil.md +1 -1
  36. package/kit/commands/definir-slo.md +108 -108
  37. package/kit/commands/fio.md +1 -1
  38. package/kit/commands/golden-signals.md +142 -142
  39. package/kit/commands/instrumentar-fase.md +200 -200
  40. package/kit/commands/investigar-producao.md +162 -162
  41. package/kit/commands/observabilidade.md +118 -118
  42. package/kit/commands/postmortem.md +179 -179
  43. package/kit/commands/prr.md +205 -205
  44. package/kit/commands/publicar-rapido.md +207 -207
  45. package/kit/commands/risk-budget.md +220 -220
  46. package/kit/commands/sre.md +230 -230
  47. package/kit/file-manifest.json +424 -424
  48. package/kit/framework/references/output-style.md +22 -22
  49. package/kit/hooks/post-apply-migration.js +199 -199
  50. package/kit/hooks/sidecar-tool-publisher.js +210 -210
  51. package/kit/skills/_shared-dados-distribuidos/glossary.md +224 -224
  52. package/kit/skills/_shared-legacy/glossary.md +389 -389
  53. package/kit/skills/_shared-multi-tenant/glossary.md +186 -186
  54. package/kit/skills/_shared-observability/glossary.md +396 -396
  55. package/kit/skills/_shared-sre/glossary.md +712 -712
  56. package/kit/skills/_shared-supabase/glossary.md +234 -234
  57. package/kit/skills/blameless-postmortems/SKILL.md +340 -340
  58. package/kit/skills/burn-rate-alerting/SKILL.md +258 -258
  59. package/kit/skills/cascading-failures/SKILL.md +311 -311
  60. package/kit/skills/core-analysis-loop/SKILL.md +352 -352
  61. package/kit/skills/distributed-tracing/SKILL.md +362 -362
  62. package/kit/skills/dynamic-workflow-authoring/SKILL.md +327 -223
  63. package/kit/skills/eliminating-toil/SKILL.md +243 -243
  64. package/kit/skills/event-based-slos/SKILL.md +296 -296
  65. package/kit/skills/four-golden-signals/SKILL.md +314 -314
  66. package/kit/skills/hermetic-builds/SKILL.md +323 -323
  67. package/kit/skills/legacy-monster-methods/SKILL.md +444 -444
  68. package/kit/skills/llm-as-dependency/SKILL.md +436 -436
  69. package/kit/skills/load-shedding-graceful-degradation/SKILL.md +396 -396
  70. package/kit/skills/observability-driven-development/SKILL.md +315 -315
  71. package/kit/skills/observability-maturity-model/SKILL.md +222 -222
  72. package/kit/skills/opentelemetry-standard/SKILL.md +351 -351
  73. package/kit/skills/production-readiness-review/SKILL.md +305 -305
  74. package/kit/skills/release-engineering/SKILL.md +367 -367
  75. package/kit/skills/retry-strategies/SKILL.md +372 -372
  76. package/kit/skills/sre-risk-management/SKILL.md +221 -221
  77. package/kit/skills/structured-events/SKILL.md +265 -265
  78. package/kit/skills/supabase-cron-queues/SKILL.md +275 -275
  79. package/kit/skills/supabase-database-functions/SKILL.md +332 -332
  80. package/kit/skills/supabase-declarative-schema/SKILL.md +183 -183
  81. package/kit/skills/supabase-pgvector-rag/SKILL.md +253 -253
  82. package/kit/skills/supabase-postgres-style/SKILL.md +138 -138
  83. package/kit/skills/supabase-storage/SKILL.md +234 -234
  84. package/kit/skills/telemetry-pipelines/SKILL.md +259 -259
  85. package/kit/skills/telemetry-sampling/SKILL.md +256 -256
  86. package/kit/skills/ui-anti-padroes-ia/SKILL.md +261 -261
  87. package/kit/skills/ui-contexto-produto/SKILL.md +248 -248
  88. package/kit/skills/ui-cor-estrategia/SKILL.md +213 -213
  89. package/kit/skills/ui-critica-auditoria/SKILL.md +260 -260
  90. package/kit/skills/ui-motion-funcional/SKILL.md +264 -264
  91. package/kit/skills/ui-ritmo-espacial/SKILL.md +259 -259
  92. package/kit/skills/ui-tipografia/SKILL.md +211 -211
  93. package/package.json +1 -1
  94. package/src/cli/index.js +1114 -1114
  95. package/src/cli/render.js +194 -194
  96. package/src/cli/upgrade-check.js +135 -135
  97. package/src/core/error-redaction.js +76 -76
  98. package/src/core/failures.js +153 -153
  99. package/src/core/gate-runner.js +205 -205
  100. package/src/core/gates.js +82 -82
  101. package/src/core/logger.js +170 -170
  102. package/src/core/manifest-verify.js +174 -174
  103. package/src/core/metrics.js +268 -268
  104. package/src/core/notify.js +60 -60
  105. package/src/core/path-safety.js +141 -141
  106. package/src/core/replays.js +120 -120
  107. package/src/core/ui.js +185 -185
  108. package/src/mcp-server/install.js +149 -149
  109. package/src/mcp-server/roots.js +124 -124
  110. package/src/ui/auto-spawn.js +113 -113
  111. package/src/ui/browser.js +78 -78
  112. package/src/ui/client.js +130 -130
  113. package/src/ui/events.js +65 -65
  114. package/src/ui/lockfile.js +191 -191
  115. package/src/ui/port.js +67 -67
  116. package/src/ui/server.js +547 -547
  117. 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
+ };