requ-mcp 0.2.0 → 0.5.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.
@@ -0,0 +1,789 @@
1
+ /**
2
+ * Web dashboard REST API + static file serving layer.
3
+ *
4
+ * Export: handleWebRequest — drop-in handler for the HTTP server in index.ts.
5
+ * Returns true when the request was handled; false to fall through to MCP routing.
6
+ */
7
+ import { fileURLToPath } from "node:url";
8
+ import path from "node:path";
9
+ import { promises as fsp, readFileSync } from "node:fs";
10
+ import { indexConductor, scenariosByStory } from "./conductor.js";
11
+ import { buildReport, buildTrend, findGaps, resolveStatuses } from "./coverage.js";
12
+ import { ExportPayload } from "./schema.js";
13
+ import { buildExport, applyImport } from "./export-import.js";
14
+ // ---------------------------------------------------------------------------
15
+ // Constants
16
+ // ---------------------------------------------------------------------------
17
+ const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "public");
18
+ const SERVER_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
19
+ const MIME = {
20
+ ".html": "text/html; charset=utf-8",
21
+ ".js": "application/javascript; charset=utf-8",
22
+ ".css": "text/css; charset=utf-8",
23
+ ".json": "application/json",
24
+ ".ico": "image/x-icon",
25
+ ".svg": "image/svg+xml",
26
+ };
27
+ const CORS_HEADERS = {
28
+ "Access-Control-Allow-Origin": "*",
29
+ "Access-Control-Allow-Methods": "GET, POST, PATCH, OPTIONS",
30
+ "Access-Control-Allow-Headers": "Content-Type",
31
+ };
32
+ // ---------------------------------------------------------------------------
33
+ // Helpers
34
+ // ---------------------------------------------------------------------------
35
+ function setHeaders(res, headers) {
36
+ for (const [k, v] of Object.entries(headers))
37
+ res.setHeader(k, v);
38
+ }
39
+ function jsonOk(res, data) {
40
+ const body = JSON.stringify(data);
41
+ res.writeHead(200, {
42
+ ...CORS_HEADERS,
43
+ "Content-Type": "application/json",
44
+ "Content-Length": Buffer.byteLength(body),
45
+ });
46
+ res.end(body);
47
+ }
48
+ function jsonError(res, status, message, code) {
49
+ const body = JSON.stringify({ error: message, ...(code ? { code } : {}) });
50
+ res.writeHead(status, {
51
+ ...CORS_HEADERS,
52
+ "Content-Type": "application/json",
53
+ "Content-Length": Buffer.byteLength(body),
54
+ });
55
+ res.end(body);
56
+ }
57
+ async function collectBody(req, maxBytes = 10 * 1024 * 1024) {
58
+ return new Promise((resolve, reject) => {
59
+ const chunks = [];
60
+ let size = 0;
61
+ req.on("data", (chunk) => {
62
+ size += chunk.length;
63
+ if (size > maxBytes) {
64
+ req.resume();
65
+ reject(new Error("Payload too large"));
66
+ return;
67
+ }
68
+ chunks.push(chunk);
69
+ });
70
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
71
+ req.on("error", reject);
72
+ });
73
+ }
74
+ /**
75
+ * Minimal route matcher. Supports `:param` segments.
76
+ * Returns a params object on match, null otherwise.
77
+ */
78
+ function matchRoute(url, method, pattern, expectedMethod) {
79
+ if (method.toUpperCase() !== expectedMethod.toUpperCase())
80
+ return null;
81
+ // Strip query string from URL.
82
+ const pathname = url.split("?")[0];
83
+ const patParts = pattern.split("/");
84
+ const urlParts = pathname.split("/");
85
+ if (patParts.length !== urlParts.length)
86
+ return null;
87
+ const params = {};
88
+ for (let i = 0; i < patParts.length; i++) {
89
+ const pp = patParts[i];
90
+ const up = urlParts[i];
91
+ if (pp.startsWith(":")) {
92
+ params[pp.slice(1)] = decodeURIComponent(up);
93
+ }
94
+ else if (pp !== up) {
95
+ return null;
96
+ }
97
+ }
98
+ return params;
99
+ }
100
+ // ---------------------------------------------------------------------------
101
+ // Static file serving
102
+ // ---------------------------------------------------------------------------
103
+ async function serveStatic(res, filePath) {
104
+ // Path traversal guard.
105
+ const resolved = path.resolve(filePath);
106
+ if (!resolved.startsWith(PUBLIC_DIR + path.sep) && resolved !== PUBLIC_DIR) {
107
+ jsonError(res, 403, "Forbidden");
108
+ return;
109
+ }
110
+ try {
111
+ const content = await fsp.readFile(resolved);
112
+ const ext = path.extname(resolved).toLowerCase();
113
+ const mime = MIME[ext] ?? "application/octet-stream";
114
+ const isHtml = ext === ".html";
115
+ res.writeHead(200, {
116
+ ...CORS_HEADERS,
117
+ "Content-Type": mime,
118
+ "Cache-Control": "no-cache",
119
+ "Content-Length": content.length,
120
+ });
121
+ res.end(content);
122
+ }
123
+ catch (err) {
124
+ const code = err.code;
125
+ if (code === "ENOENT") {
126
+ jsonError(res, 404, "Not found");
127
+ }
128
+ else {
129
+ console.error("[requ-mcp] serveStatic error:", err);
130
+ jsonError(res, 500, "Internal server error");
131
+ }
132
+ }
133
+ }
134
+ async function serveIndexHtml(res) {
135
+ // Inject the version as a cache-busting query string on static asset references,
136
+ // so browsers always load the correct JS/CSS when the server version changes.
137
+ const filePath = path.join(PUBLIC_DIR, "index.html");
138
+ try {
139
+ let html = await fsp.readFile(filePath, "utf-8");
140
+ html = html.replace(/(\/public\/(?:app|style)\.[a-z]+)"/g, `$1?v=${SERVER_VERSION}"`);
141
+ const buf = Buffer.from(html, "utf-8");
142
+ res.writeHead(200, {
143
+ ...CORS_HEADERS,
144
+ "Content-Type": "text/html; charset=utf-8",
145
+ "Cache-Control": "no-cache",
146
+ "Content-Length": buf.length,
147
+ });
148
+ res.end(buf);
149
+ }
150
+ catch {
151
+ jsonError(res, 500, "Internal server error");
152
+ }
153
+ }
154
+ // ---------------------------------------------------------------------------
155
+ // Summary helper (shared by GET /api/summary and SSE)
156
+ // ---------------------------------------------------------------------------
157
+ async function computeSummary(store) {
158
+ const [requirements, stories, components, phases] = await Promise.all([
159
+ store.listRequirements(),
160
+ store.listStories(),
161
+ store.listComponents(),
162
+ store.listPhases(),
163
+ ]);
164
+ const activePhase = await store.resolvePhaseId();
165
+ const executionsByPhase = await store.readAllExecutions();
166
+ let storyMap = new Map();
167
+ try {
168
+ const conductorRoot = await store.conductorRoot();
169
+ const index = await indexConductor(conductorRoot);
170
+ storyMap = scenariosByStory(index);
171
+ }
172
+ catch {
173
+ // Conductor not available yet — use empty map.
174
+ }
175
+ const vcsRefs = await store.listVcsRefs();
176
+ const status = resolveStatuses(executionsByPhase, phases, activePhase, "cumulative");
177
+ const report = buildReport(requirements, stories, storyMap, status, activePhase, "cumulative", vcsRefs, phases);
178
+ const { summary } = report;
179
+ return {
180
+ requirements: requirements.length,
181
+ stories: stories.length,
182
+ components: components.length,
183
+ phases: phases.length,
184
+ vcsRefs: vcsRefs.length,
185
+ scenariosPassing: summary.scenariosPassing,
186
+ scenariosLinked: summary.scenariosLinked,
187
+ verifiedPct: summary.verifiedPct,
188
+ storyCoveragePct: summary.storyCoveragePct,
189
+ activePhase,
190
+ };
191
+ }
192
+ // ---------------------------------------------------------------------------
193
+ // Coverage helper (builds index + story map, catches ENOENT)
194
+ // ---------------------------------------------------------------------------
195
+ async function buildCoverageData(store) {
196
+ const [requirements, stories, phases, executionsByPhase, vcsRefs] = await Promise.all([
197
+ store.listRequirements(),
198
+ store.listStories(),
199
+ store.listPhases(),
200
+ store.readAllExecutions(),
201
+ store.listVcsRefs(),
202
+ ]);
203
+ let storyMap = new Map();
204
+ try {
205
+ const conductorRoot = await store.conductorRoot();
206
+ const index = await indexConductor(conductorRoot);
207
+ storyMap = scenariosByStory(index);
208
+ }
209
+ catch {
210
+ // Conductor not available — use empty map.
211
+ }
212
+ return { requirements, stories, phases, executionsByPhase, storyMap, vcsRefs };
213
+ }
214
+ // ---------------------------------------------------------------------------
215
+ // NOT_INITIALIZED guard
216
+ // ---------------------------------------------------------------------------
217
+ function notInitialized(res) {
218
+ jsonError(res, 503, "Project not initialized. Run init_project first.", "NOT_INITIALIZED");
219
+ return true;
220
+ }
221
+ // ---------------------------------------------------------------------------
222
+ // Coverage mode validation
223
+ // ---------------------------------------------------------------------------
224
+ const VALID_MODES = new Set(["cumulative", "strict"]);
225
+ function parseCoverageMode(searchParams, res) {
226
+ const raw = searchParams.get("mode");
227
+ if (raw !== null && !VALID_MODES.has(raw)) {
228
+ jsonError(res, 400, `Invalid mode "${raw}". Accepted values: "cumulative", "strict".`);
229
+ return null;
230
+ }
231
+ return (raw ?? "cumulative");
232
+ }
233
+ function resolveStore(stores, searchParams) {
234
+ if (stores.size === 0)
235
+ return { status: "not_initialized" };
236
+ if (stores.size === 1)
237
+ return { status: "ok", store: [...stores.values()][0] };
238
+ const slug = searchParams.get("project");
239
+ if (!slug)
240
+ return { status: "ambiguous", available: [...stores.keys()] };
241
+ const store = stores.get(slug);
242
+ if (!store)
243
+ return { status: "unknown_project", slug };
244
+ return { status: "ok", store };
245
+ }
246
+ function handleStoreResult(res, result) {
247
+ if (result.status === "ok")
248
+ return true;
249
+ if (result.status === "not_initialized") {
250
+ notInitialized(res);
251
+ }
252
+ else if (result.status === "ambiguous") {
253
+ const body = JSON.stringify({
254
+ error: "Multiple projects loaded; specify ?project=<slug>",
255
+ available: result.available,
256
+ });
257
+ res.writeHead(400, { ...CORS_HEADERS, "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) });
258
+ res.end(body);
259
+ }
260
+ else {
261
+ jsonError(res, 404, `Unknown project: ${result.slug}`);
262
+ }
263
+ return false;
264
+ }
265
+ // ---------------------------------------------------------------------------
266
+ // Main export
267
+ // ---------------------------------------------------------------------------
268
+ export async function handleWebRequest(req, res, stores) {
269
+ const rawUrl = req.url ?? "/";
270
+ const method = req.method ?? "GET";
271
+ // Handle CORS preflight.
272
+ if (method === "OPTIONS") {
273
+ setHeaders(res, CORS_HEADERS);
274
+ res.writeHead(204).end();
275
+ return true;
276
+ }
277
+ // -------------------------------------------------------------------------
278
+ // SSE — GET /events
279
+ // -------------------------------------------------------------------------
280
+ if (rawUrl === "/events" || rawUrl.startsWith("/events?")) {
281
+ const sseResult = resolveStore(stores, new URL(rawUrl, "http://localhost").searchParams);
282
+ if (!handleStoreResult(res, sseResult))
283
+ return true;
284
+ const store = sseResult.store;
285
+ res.writeHead(200, {
286
+ ...CORS_HEADERS,
287
+ "Content-Type": "text/event-stream",
288
+ "Cache-Control": "no-cache",
289
+ "Connection": "keep-alive",
290
+ "X-Accel-Buffering": "no",
291
+ });
292
+ // Helper to send one SSE data event.
293
+ let closed = false;
294
+ const sendSummary = async () => {
295
+ if (closed)
296
+ return;
297
+ try {
298
+ const summary = await computeSummary(store);
299
+ if (!closed)
300
+ res.write(`data: ${JSON.stringify(summary)}\n\n`);
301
+ }
302
+ catch {
303
+ // Don't crash the SSE loop on transient errors.
304
+ }
305
+ };
306
+ // Send current snapshot immediately.
307
+ await sendSummary();
308
+ const dataTimer = setInterval(() => { void sendSummary(); }, 5_000);
309
+ const keepAliveTimer = setInterval(() => { if (!closed)
310
+ res.write(": keepalive\n\n"); }, 25_000);
311
+ req.on("close", () => {
312
+ closed = true;
313
+ clearInterval(dataTimer);
314
+ clearInterval(keepAliveTimer);
315
+ });
316
+ return true;
317
+ }
318
+ // -------------------------------------------------------------------------
319
+ // REST API — /api/*
320
+ // -------------------------------------------------------------------------
321
+ // Catch `/api` (no trailing slash) before the prefix check so it doesn't
322
+ // fall through to the SPA fallback and return 200 + HTML.
323
+ if (rawUrl === "/api" || rawUrl.startsWith("/api?")) {
324
+ jsonError(res, 404, "Unknown API route");
325
+ return true;
326
+ }
327
+ if (rawUrl.startsWith("/api/")) {
328
+ // Extract pathname once; all matchRoute calls below receive a clean path.
329
+ const pathname = rawUrl.split("?")[0];
330
+ const searchParams = new URL(rawUrl, "http://localhost").searchParams;
331
+ // --- GET /api/version --- (no store needed)
332
+ if (matchRoute(pathname, method, "/api/version", "GET") !== null) {
333
+ jsonOk(res, { version: SERVER_VERSION });
334
+ return true;
335
+ }
336
+ // --- GET /api/projects --- (no store needed)
337
+ if (matchRoute(pathname, method, "/api/projects", "GET") !== null) {
338
+ const list = [...stores.entries()].map(([slug, s]) => ({ slug, root: s.root }));
339
+ jsonOk(res, list);
340
+ return true;
341
+ }
342
+ // --- POST /api/init --- (must work before project is initialized)
343
+ if (matchRoute(pathname, method, "/api/init", "POST") !== null) {
344
+ try {
345
+ // Store resolution without handleStoreResult — project may not be initialized yet.
346
+ let store;
347
+ if (stores.size === 0) {
348
+ jsonError(res, 503, "No project root configured on this server");
349
+ return true;
350
+ }
351
+ else if (stores.size > 1) {
352
+ const slug = searchParams.get("project");
353
+ if (!slug) {
354
+ jsonError(res, 400, "Multiple projects loaded — pass ?project=<slug>");
355
+ return true;
356
+ }
357
+ const found = stores.get(slug);
358
+ if (!found) {
359
+ jsonError(res, 404, `Unknown project: ${slug}`);
360
+ return true;
361
+ }
362
+ store = found;
363
+ }
364
+ else {
365
+ store = [...stores.values()][0];
366
+ }
367
+ let body;
368
+ try {
369
+ body = JSON.parse(await collectBody(req));
370
+ }
371
+ catch {
372
+ jsonError(res, 400, "Request body is not valid JSON");
373
+ return true;
374
+ }
375
+ // Derive / validate key.
376
+ let key;
377
+ if (typeof body.key === "string" && body.key.trim()) {
378
+ if (!/^[A-Z0-9][A-Z0-9_-]{0,19}$/.test(body.key)) {
379
+ jsonError(res, 400, "key must be 1–20 uppercase alphanumeric/hyphen/underscore characters starting with a letter or digit");
380
+ return true;
381
+ }
382
+ key = body.key;
383
+ }
384
+ else {
385
+ key = ((typeof body.name === "string" ? body.name : path.basename(store.root))
386
+ .replace(/[^A-Z0-9]/gi, "")
387
+ .toUpperCase()
388
+ .slice(0, 10)) || "PROJECT";
389
+ }
390
+ // Uniqueness check across all stores.
391
+ for (const s of stores.values()) {
392
+ try {
393
+ const cfg = await s.readConfig();
394
+ if (cfg.key === key && s !== store) {
395
+ jsonError(res, 409, `Project key '${key}' is already used by another project`);
396
+ return true;
397
+ }
398
+ }
399
+ catch { /* skip uninitialized stores */ }
400
+ }
401
+ const config = {
402
+ name: typeof body.name === "string" && body.name.trim() ? body.name.trim() : path.basename(store.root),
403
+ key,
404
+ brief: typeof body.brief === "string" ? body.brief : undefined,
405
+ conductorPath: ".",
406
+ };
407
+ await store.init(config);
408
+ if (typeof body.initialPhase === "string" && body.initialPhase.trim()) {
409
+ const phase = {
410
+ id: "P1",
411
+ name: body.initialPhase,
412
+ order: 1,
413
+ status: "active",
414
+ description: "",
415
+ createdAt: new Date().toISOString(),
416
+ updatedAt: new Date().toISOString(),
417
+ };
418
+ await store.writePhase(phase);
419
+ await store.writeConfig({ ...config, activePhase: "P1" });
420
+ }
421
+ jsonOk(res, { initialized: true, config: await store.readConfig() });
422
+ }
423
+ catch (err) {
424
+ jsonError(res, 500, err.message);
425
+ }
426
+ return true;
427
+ }
428
+ // --- GET /api/summary ---
429
+ if (matchRoute(pathname, method, "/api/summary", "GET") !== null) {
430
+ const r = resolveStore(stores, searchParams);
431
+ if (!handleStoreResult(res, r))
432
+ return true;
433
+ try {
434
+ if (!await r.store.isInitialized())
435
+ return notInitialized(res);
436
+ const summary = await computeSummary(r.store);
437
+ jsonOk(res, summary);
438
+ }
439
+ catch (err) {
440
+ jsonError(res, 500, String(err));
441
+ }
442
+ return true;
443
+ }
444
+ // --- GET /api/global ---
445
+ if (matchRoute(pathname, method, "/api/global", "GET") !== null) {
446
+ if (stores.size === 0) {
447
+ jsonOk(res, []);
448
+ return true;
449
+ }
450
+ try {
451
+ const results = await Promise.all([...stores.entries()].map(async ([slug, store]) => {
452
+ try {
453
+ // readConfig & computeSummary both throw on uninitialised DBs.
454
+ // Fall back gracefully so partially-initialised projects still appear.
455
+ const [config, summary] = await Promise.all([
456
+ store.readConfig().catch(() => ({ name: slug, activePhase: undefined })),
457
+ computeSummary(store).catch(async () => {
458
+ // Conductor / phase resolution not available — count entities directly.
459
+ const [reqs, stories] = await Promise.all([
460
+ store.listRequirements(),
461
+ store.listStories(),
462
+ ]);
463
+ return { requirements: reqs.length, stories: stories.length,
464
+ verifiedPct: 0, storyCoveragePct: 0, activePhase: undefined };
465
+ }),
466
+ ]);
467
+ return {
468
+ slug,
469
+ name: config.name,
470
+ activePhase: summary.activePhase ?? null,
471
+ requirements: summary.requirements,
472
+ stories: summary.stories,
473
+ verifiedPct: summary.verifiedPct,
474
+ storyCoveragePct: summary.storyCoveragePct,
475
+ };
476
+ }
477
+ catch {
478
+ return null;
479
+ } // truly broken store (no schema) — skip silently
480
+ }));
481
+ jsonOk(res, results.filter(Boolean));
482
+ }
483
+ catch (err) {
484
+ jsonError(res, 500, String(err));
485
+ }
486
+ return true;
487
+ }
488
+ // --- GET /api/requirements ---
489
+ if (matchRoute(pathname, method, "/api/requirements", "GET") !== null) {
490
+ const r = resolveStore(stores, searchParams);
491
+ if (!handleStoreResult(res, r))
492
+ return true;
493
+ try {
494
+ jsonOk(res, await r.store.listRequirements());
495
+ }
496
+ catch (err) {
497
+ jsonError(res, 500, String(err));
498
+ }
499
+ return true;
500
+ }
501
+ // --- GET /api/requirements/:id ---
502
+ {
503
+ const params = matchRoute(pathname, method, "/api/requirements/:id", "GET");
504
+ if (params !== null) {
505
+ const r = resolveStore(stores, searchParams);
506
+ if (!handleStoreResult(res, r))
507
+ return true;
508
+ try {
509
+ const req_ = await r.store.getRequirement(params.id);
510
+ if (!req_) {
511
+ jsonError(res, 404, `Requirement ${params.id} not found`);
512
+ return true;
513
+ }
514
+ // Augment with linked story IDs.
515
+ const stories = await r.store.listStories();
516
+ const linkedStoryIds = stories
517
+ .filter((s) => s.requirements.includes(params.id))
518
+ .map((s) => s.id);
519
+ jsonOk(res, { ...req_, linkedStoryIds });
520
+ }
521
+ catch (err) {
522
+ jsonError(res, 500, String(err));
523
+ }
524
+ return true;
525
+ }
526
+ }
527
+ // --- GET /api/stories ---
528
+ if (matchRoute(pathname, method, "/api/stories", "GET") !== null) {
529
+ const r = resolveStore(stores, searchParams);
530
+ if (!handleStoreResult(res, r))
531
+ return true;
532
+ try {
533
+ jsonOk(res, await r.store.listStories());
534
+ }
535
+ catch (err) {
536
+ jsonError(res, 500, String(err));
537
+ }
538
+ return true;
539
+ }
540
+ // --- GET /api/stories/:id ---
541
+ {
542
+ const params = matchRoute(pathname, method, "/api/stories/:id", "GET");
543
+ if (params !== null) {
544
+ const r = resolveStore(stores, searchParams);
545
+ if (!handleStoreResult(res, r))
546
+ return true;
547
+ try {
548
+ const story = await r.store.getStory(params.id);
549
+ if (!story) {
550
+ jsonError(res, 404, `Story ${params.id} not found`);
551
+ return true;
552
+ }
553
+ jsonOk(res, story);
554
+ }
555
+ catch (err) {
556
+ jsonError(res, 500, String(err));
557
+ }
558
+ return true;
559
+ }
560
+ }
561
+ // --- GET /api/components ---
562
+ if (matchRoute(pathname, method, "/api/components", "GET") !== null) {
563
+ const r = resolveStore(stores, searchParams);
564
+ if (!handleStoreResult(res, r))
565
+ return true;
566
+ try {
567
+ jsonOk(res, await r.store.listComponents());
568
+ }
569
+ catch (err) {
570
+ jsonError(res, 500, String(err));
571
+ }
572
+ return true;
573
+ }
574
+ // --- GET /api/phases ---
575
+ if (matchRoute(pathname, method, "/api/phases", "GET") !== null) {
576
+ const r = resolveStore(stores, searchParams);
577
+ if (!handleStoreResult(res, r))
578
+ return true;
579
+ try {
580
+ jsonOk(res, await r.store.listPhases());
581
+ }
582
+ catch (err) {
583
+ jsonError(res, 500, String(err));
584
+ }
585
+ return true;
586
+ }
587
+ // --- GET /api/config ---
588
+ if (matchRoute(pathname, method, "/api/config", "GET") !== null) {
589
+ const r = resolveStore(stores, searchParams);
590
+ if (!handleStoreResult(res, r))
591
+ return true;
592
+ try {
593
+ if (!await r.store.isInitialized())
594
+ return notInitialized(res);
595
+ jsonOk(res, await r.store.readConfig());
596
+ }
597
+ catch (err) {
598
+ jsonError(res, 500, String(err));
599
+ }
600
+ return true;
601
+ }
602
+ // --- PATCH /api/config ---
603
+ if (matchRoute(pathname, method, "/api/config", "PATCH") !== null) {
604
+ const r = resolveStore(stores, searchParams);
605
+ if (!handleStoreResult(res, r))
606
+ return true;
607
+ try {
608
+ let body;
609
+ try {
610
+ body = JSON.parse(await collectBody(req));
611
+ }
612
+ catch {
613
+ jsonError(res, 400, "Request body is not valid JSON");
614
+ return true;
615
+ }
616
+ const cfg = await r.store.readConfig();
617
+ const updated = { ...cfg };
618
+ if (typeof body.name === "string" && body.name.trim())
619
+ updated.name = body.name.trim();
620
+ if (typeof body.brief === "string")
621
+ updated.brief = body.brief;
622
+ await r.store.writeConfig(updated);
623
+ jsonOk(res, await r.store.readConfig());
624
+ }
625
+ catch (err) {
626
+ jsonError(res, 500, String(err));
627
+ }
628
+ return true;
629
+ }
630
+ // --- GET /api/vcs ---
631
+ if (matchRoute(pathname, method, "/api/vcs", "GET") !== null) {
632
+ const r = resolveStore(stores, searchParams);
633
+ if (!handleStoreResult(res, r))
634
+ return true;
635
+ try {
636
+ jsonOk(res, await r.store.listVcsRefs());
637
+ }
638
+ catch (err) {
639
+ jsonError(res, 500, String(err));
640
+ }
641
+ return true;
642
+ }
643
+ // --- GET /api/coverage/trend ---
644
+ if (matchRoute(pathname, method, "/api/coverage/trend", "GET") !== null) {
645
+ const r = resolveStore(stores, searchParams);
646
+ if (!handleStoreResult(res, r))
647
+ return true;
648
+ try {
649
+ const mode = parseCoverageMode(searchParams, res);
650
+ if (mode === null)
651
+ return true;
652
+ const { requirements, stories, phases, executionsByPhase, storyMap } = await buildCoverageData(r.store);
653
+ const trend = buildTrend(requirements, stories, storyMap, executionsByPhase, phases, mode);
654
+ jsonOk(res, trend);
655
+ }
656
+ catch (err) {
657
+ jsonError(res, 500, String(err));
658
+ }
659
+ return true;
660
+ }
661
+ // --- GET /api/coverage/gaps ---
662
+ if (matchRoute(pathname, method, "/api/coverage/gaps", "GET") !== null) {
663
+ const r = resolveStore(stores, searchParams);
664
+ if (!handleStoreResult(res, r))
665
+ return true;
666
+ try {
667
+ const mode = parseCoverageMode(searchParams, res);
668
+ if (mode === null)
669
+ return true;
670
+ const { requirements, stories, phases, executionsByPhase, storyMap } = await buildCoverageData(r.store);
671
+ const phaseParam = searchParams.get("phase");
672
+ const phaseId = phaseParam ?? (await r.store.resolvePhaseId());
673
+ const status = resolveStatuses(executionsByPhase, phases, phaseId, mode);
674
+ const gaps = findGaps(requirements, stories, storyMap, status, phaseId, mode, phases);
675
+ jsonOk(res, gaps);
676
+ }
677
+ catch (err) {
678
+ jsonError(res, 500, String(err));
679
+ }
680
+ return true;
681
+ }
682
+ // --- GET /api/coverage ---
683
+ if (matchRoute(pathname, method, "/api/coverage", "GET") !== null) {
684
+ const r = resolveStore(stores, searchParams);
685
+ if (!handleStoreResult(res, r))
686
+ return true;
687
+ try {
688
+ const mode = parseCoverageMode(searchParams, res);
689
+ if (mode === null)
690
+ return true;
691
+ const { requirements, stories, phases, executionsByPhase, storyMap, vcsRefs } = await buildCoverageData(r.store);
692
+ const phaseParam = searchParams.get("phase");
693
+ const phaseId = phaseParam ?? (await r.store.resolvePhaseId());
694
+ const status = resolveStatuses(executionsByPhase, phases, phaseId, mode);
695
+ const report = buildReport(requirements, stories, storyMap, status, phaseId, mode, vcsRefs, phases);
696
+ jsonOk(res, report);
697
+ }
698
+ catch (err) {
699
+ jsonError(res, 500, String(err));
700
+ }
701
+ return true;
702
+ }
703
+ // --- GET /api/export ---
704
+ if (matchRoute(pathname, method, "/api/export", "GET") !== null) {
705
+ const r = resolveStore(stores, searchParams);
706
+ if (!handleStoreResult(res, r))
707
+ return true;
708
+ try {
709
+ const payload = await buildExport(r.store);
710
+ const body = JSON.stringify(payload, null, 2);
711
+ // Strip to a safe allowlist before reflecting into a response header.
712
+ const rawSlug = searchParams.get("project") ?? "project";
713
+ const safeSlug = rawSlug.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 64) || "project";
714
+ res.writeHead(200, {
715
+ ...CORS_HEADERS,
716
+ "Content-Type": "application/json",
717
+ "Content-Disposition": `attachment; filename="requ-export-${safeSlug}.json"`,
718
+ "Content-Length": Buffer.byteLength(body),
719
+ });
720
+ res.end(body);
721
+ }
722
+ catch (err) {
723
+ jsonError(res, 500, String(err));
724
+ }
725
+ return true;
726
+ }
727
+ // --- POST /api/import ---
728
+ if (matchRoute(pathname, method, "/api/import", "POST") !== null) {
729
+ const r = resolveStore(stores, searchParams);
730
+ if (!handleStoreResult(res, r))
731
+ return true;
732
+ try {
733
+ const bodyText = await collectBody(req);
734
+ let parsed;
735
+ try {
736
+ parsed = JSON.parse(bodyText);
737
+ }
738
+ catch {
739
+ jsonError(res, 400, "Request body is not valid JSON");
740
+ return true;
741
+ }
742
+ const result = ExportPayload.safeParse(parsed);
743
+ if (!result.success) {
744
+ jsonError(res, 400, `Invalid export format: ${result.error.message}`);
745
+ return true;
746
+ }
747
+ const report = await applyImport(r.store, result.data);
748
+ jsonOk(res, report);
749
+ }
750
+ catch (err) {
751
+ const msg = err.message;
752
+ if (msg === "Payload too large") {
753
+ jsonError(res, 413, msg);
754
+ }
755
+ else {
756
+ jsonError(res, 500, msg);
757
+ }
758
+ }
759
+ return true;
760
+ }
761
+ // Unknown /api/* route.
762
+ jsonError(res, 404, "Unknown API route");
763
+ return true;
764
+ }
765
+ // -------------------------------------------------------------------------
766
+ // Static files — only for GET requests that are NOT MCP or /events
767
+ // -------------------------------------------------------------------------
768
+ if (method !== "GET")
769
+ return false;
770
+ if (rawUrl.includes("/mcp"))
771
+ return false;
772
+ // GET / → index.html
773
+ const pathname = rawUrl.split("?")[0];
774
+ if (pathname === "/") {
775
+ await serveIndexHtml(res);
776
+ return true;
777
+ }
778
+ // GET /public/* → file from public dir
779
+ if (pathname.startsWith("/public/")) {
780
+ const rel = pathname.slice("/public/".length);
781
+ await serveStatic(res, path.join(PUBLIC_DIR, rel));
782
+ return true;
783
+ }
784
+ // SPA fallback: any other GET that is not /api/* and not /events
785
+ // (those were already handled above or returned false)
786
+ await serveIndexHtml(res);
787
+ return true;
788
+ }
789
+ //# sourceMappingURL=web-api.js.map