@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.
- package/bin/cli.js +2 -2
- package/bin/mcp.js +6 -6
- package/bin/ui.js +74 -74
- package/gates/ai-prompt-stability.md +120 -120
- package/gates/budget-description.md +68 -68
- package/gates/confidence.md +29 -29
- package/gates/dependency-check.md +33 -33
- package/gates/dept-cycle-prevention.md +179 -179
- package/gates/golden-signals-coverage.md +133 -133
- package/gates/legacy-refactor-safety.md +178 -178
- package/gates/multi-tenant-rls-coverage.md +102 -102
- package/gates/no-personal-uuid.md +72 -72
- package/gates/obs-agents-mcp-supabase.md +86 -86
- package/gates/obs-skills-frontmatter.md +76 -76
- package/gates/observability-coverage.md +151 -151
- package/gates/omm-no-regression.md +83 -83
- package/gates/postmortem-template-required.md +127 -127
- package/gates/prr-checklist-coverage.md +128 -128
- package/gates/regression.md +32 -32
- package/gates/release-pipeline-policy.md +132 -132
- package/gates/secrets-scan.md +33 -33
- package/gates/service-role-not-in-user-facing.md +113 -113
- package/gates/skill-must-include.md +71 -71
- package/gates/sync-idempotent.md +62 -62
- package/gates/verify-phase-goal.md +34 -34
- package/kit/agents/designer-ui.md +216 -216
- package/kit/agents/workflow-generator.md +537 -167
- package/kit/commands/adicionar-backlog.md +1 -1
- package/kit/commands/adicionar-fase.md +1 -1
- package/kit/commands/adicionar-tarefa.md +1 -1
- package/kit/commands/auditar-observabilidade.md +103 -103
- package/kit/commands/auditar-toil.md +129 -129
- package/kit/commands/caracterizar-prompt.md +195 -195
- package/kit/commands/criar-workflow.md +158 -158
- package/kit/commands/definir-perfil.md +1 -1
- package/kit/commands/definir-slo.md +108 -108
- package/kit/commands/fio.md +1 -1
- package/kit/commands/golden-signals.md +142 -142
- package/kit/commands/instrumentar-fase.md +200 -200
- package/kit/commands/investigar-producao.md +162 -162
- package/kit/commands/observabilidade.md +118 -118
- package/kit/commands/postmortem.md +179 -179
- package/kit/commands/prr.md +205 -205
- package/kit/commands/publicar-rapido.md +207 -207
- package/kit/commands/risk-budget.md +220 -220
- package/kit/commands/sre.md +230 -230
- package/kit/file-manifest.json +424 -424
- package/kit/framework/references/output-style.md +22 -22
- package/kit/hooks/post-apply-migration.js +199 -199
- package/kit/hooks/sidecar-tool-publisher.js +210 -210
- package/kit/skills/_shared-dados-distribuidos/glossary.md +224 -224
- package/kit/skills/_shared-legacy/glossary.md +389 -389
- package/kit/skills/_shared-multi-tenant/glossary.md +186 -186
- package/kit/skills/_shared-observability/glossary.md +396 -396
- package/kit/skills/_shared-sre/glossary.md +712 -712
- package/kit/skills/_shared-supabase/glossary.md +234 -234
- package/kit/skills/blameless-postmortems/SKILL.md +340 -340
- package/kit/skills/burn-rate-alerting/SKILL.md +258 -258
- package/kit/skills/cascading-failures/SKILL.md +311 -311
- package/kit/skills/core-analysis-loop/SKILL.md +352 -352
- package/kit/skills/distributed-tracing/SKILL.md +362 -362
- package/kit/skills/dynamic-workflow-authoring/SKILL.md +327 -223
- package/kit/skills/eliminating-toil/SKILL.md +243 -243
- package/kit/skills/event-based-slos/SKILL.md +296 -296
- package/kit/skills/four-golden-signals/SKILL.md +314 -314
- package/kit/skills/hermetic-builds/SKILL.md +323 -323
- package/kit/skills/legacy-monster-methods/SKILL.md +444 -444
- package/kit/skills/llm-as-dependency/SKILL.md +436 -436
- package/kit/skills/load-shedding-graceful-degradation/SKILL.md +396 -396
- package/kit/skills/observability-driven-development/SKILL.md +315 -315
- package/kit/skills/observability-maturity-model/SKILL.md +222 -222
- package/kit/skills/opentelemetry-standard/SKILL.md +351 -351
- package/kit/skills/production-readiness-review/SKILL.md +305 -305
- package/kit/skills/release-engineering/SKILL.md +367 -367
- package/kit/skills/retry-strategies/SKILL.md +372 -372
- package/kit/skills/sre-risk-management/SKILL.md +221 -221
- package/kit/skills/structured-events/SKILL.md +265 -265
- package/kit/skills/supabase-cron-queues/SKILL.md +275 -275
- package/kit/skills/supabase-database-functions/SKILL.md +332 -332
- package/kit/skills/supabase-declarative-schema/SKILL.md +183 -183
- package/kit/skills/supabase-pgvector-rag/SKILL.md +253 -253
- package/kit/skills/supabase-postgres-style/SKILL.md +138 -138
- package/kit/skills/supabase-storage/SKILL.md +234 -234
- package/kit/skills/telemetry-pipelines/SKILL.md +259 -259
- package/kit/skills/telemetry-sampling/SKILL.md +256 -256
- package/kit/skills/ui-anti-padroes-ia/SKILL.md +261 -261
- package/kit/skills/ui-contexto-produto/SKILL.md +248 -248
- package/kit/skills/ui-cor-estrategia/SKILL.md +213 -213
- package/kit/skills/ui-critica-auditoria/SKILL.md +260 -260
- package/kit/skills/ui-motion-funcional/SKILL.md +264 -264
- package/kit/skills/ui-ritmo-espacial/SKILL.md +259 -259
- package/kit/skills/ui-tipografia/SKILL.md +211 -211
- package/package.json +1 -1
- package/src/cli/index.js +1114 -1114
- package/src/cli/render.js +194 -194
- package/src/cli/upgrade-check.js +135 -135
- package/src/core/error-redaction.js +76 -76
- package/src/core/failures.js +153 -153
- package/src/core/gate-runner.js +205 -205
- package/src/core/gates.js +82 -82
- package/src/core/logger.js +170 -170
- package/src/core/manifest-verify.js +174 -174
- package/src/core/metrics.js +268 -268
- package/src/core/notify.js +60 -60
- package/src/core/path-safety.js +141 -141
- package/src/core/replays.js +120 -120
- package/src/core/ui.js +185 -185
- package/src/mcp-server/install.js +149 -149
- package/src/mcp-server/roots.js +124 -124
- package/src/ui/auto-spawn.js +113 -113
- package/src/ui/browser.js +78 -78
- package/src/ui/client.js +130 -130
- package/src/ui/events.js +65 -65
- package/src/ui/lockfile.js +191 -191
- package/src/ui/port.js +67 -67
- package/src/ui/server.js +547 -547
- 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
|
+
};
|