storymapper 0.1.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 (61) hide show
  1. package/ARCHITECTURE.md +334 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +6 -0
  4. package/README.md +239 -0
  5. package/frontend/css/storymap.css +4637 -0
  6. package/frontend/js/adapters.js +423 -0
  7. package/frontend/js/card-animate.js +384 -0
  8. package/frontend/js/core/graph.js +825 -0
  9. package/frontend/js/core.js +3908 -0
  10. package/frontend/js/dialog-ticket-import.js +506 -0
  11. package/frontend/js/dnd.js +322 -0
  12. package/frontend/js/filter.js +215 -0
  13. package/frontend/js/main.js +2499 -0
  14. package/frontend/js/project-io.js +109 -0
  15. package/frontend/js/query-autocomplete.js +196 -0
  16. package/frontend/js/query-engine.js +339 -0
  17. package/frontend/js/query.js +280 -0
  18. package/frontend/js/renderer-card.js +639 -0
  19. package/frontend/js/renderer-dependencies.js +974 -0
  20. package/frontend/js/renderer-kanban.js +505 -0
  21. package/frontend/js/renderer-storymap.js +2530 -0
  22. package/frontend/js/renderer-ticket-editor.js +455 -0
  23. package/frontend/js/renderer-ticket-modal.js +758 -0
  24. package/frontend/js/save-pipeline.js +170 -0
  25. package/frontend/js/smartbar-autocomplete.js +162 -0
  26. package/frontend/js/store.js +197 -0
  27. package/frontend/js/ticket-editor-boot.js +24 -0
  28. package/frontend/js/ticket-form.js +2095 -0
  29. package/frontend/js/ticket-import.js +477 -0
  30. package/frontend/js/ui-shell.js +441 -0
  31. package/frontend/js/view-process-steps.js +233 -0
  32. package/frontend/js/view-requirements.js +361 -0
  33. package/frontend/js/view-settings.js +1864 -0
  34. package/frontend/js/view-table.js +659 -0
  35. package/frontend/js/wheel-pan.js +65 -0
  36. package/frontend/storymap.html +87 -0
  37. package/frontend/ticket-editor.html +29 -0
  38. package/package.json +76 -0
  39. package/server/bus.js +16 -0
  40. package/server/core/graph.js +10 -0
  41. package/server/core.js +10 -0
  42. package/server/identity.js +134 -0
  43. package/server/index.js +283 -0
  44. package/server/ingest.js +212 -0
  45. package/server/mcp.js +2510 -0
  46. package/server/server.js +1599 -0
  47. package/server/slice.js +103 -0
  48. package/server/storage.js +571 -0
  49. package/server/validation.js +225 -0
  50. package/shared/core/graph.js +825 -0
  51. package/shared/core.js +3908 -0
  52. package/shared/project-io.js +109 -0
  53. package/shared/query-autocomplete.js +196 -0
  54. package/shared/query-engine.js +339 -0
  55. package/shared/query.js +280 -0
  56. package/shared/ticket-import.js +477 -0
  57. package/skill/SKILL.md +458 -0
  58. package/skill/reference/anatomy.md +196 -0
  59. package/skill/reference/spec-evolution.md +52 -0
  60. package/skill/reference/tools.md +156 -0
  61. package/skill/reference/workflows.md +203 -0
@@ -0,0 +1,1599 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * HTTP server (raw `http`, no framework).
5
+ *
6
+ * Routes are matched by URL.pathname against a handler table. Each
7
+ * handler receives `(req, res, params, body)` and produces a response
8
+ * via `writeJson(res, status, data)` / `writeError(res, status, msg)`.
9
+ *
10
+ * Mutating endpoints accept an optional `X-Origin-Id` header that is
11
+ * forwarded to bus.emit("change", { ..., originId }) so a WS subscriber
12
+ * can ignore echoes of its own writes.
13
+ *
14
+ * `startServer({port, dataDir})` returns
15
+ * { httpServer, storage, shutdown(): Promise<void> }
16
+ *
17
+ * The shutdown handler:
18
+ * 1. stops accepting new connections,
19
+ * 2. destroys tracked sockets (WS to come in E5),
20
+ * 3. closes storage.
21
+ */
22
+
23
+ const http = require("http");
24
+ const fs = require("fs");
25
+ const path = require("path");
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // E20.A: switch-response cache for the standalone-MCP cross-process bridge.
29
+ // Browsers send their accept/cancel verdict over WS; the WS handler caches
30
+ // it here AND fires `bus.emit("switch_response", ...)`. The long-poll route
31
+ // /api/internal/switch-response checks the cache first (race-safe: browser
32
+ // can answer BEFORE the MCP's poll starts) and falls through to a bus
33
+ // listener otherwise. 5-min TTL; entries are deleted on consume.
34
+ // ---------------------------------------------------------------------------
35
+ // SM-213: entries used to be deleted only on consume — never-consumed ones
36
+ // leaked forever in a long-running process. Now every insert sweeps expired
37
+ // entries and the map is hard-bounded (oldest evicted first; Map preserves
38
+ // insertion order).
39
+ const SWITCH_CACHE = {
40
+ TTL_MS: 5 * 60 * 1000,
41
+ MAX_ENTRIES: 200
42
+ };
43
+ const _switchResponseCache = new Map(); // requestId → { accepted, expiresAt }
44
+
45
+ function cacheSwitchResponse(requestId, accepted, ttlMs) {
46
+ const now = Date.now();
47
+ for (const [k, v] of _switchResponseCache) {
48
+ if (v.expiresAt < now) _switchResponseCache.delete(k);
49
+ }
50
+ while (_switchResponseCache.size >= SWITCH_CACHE.MAX_ENTRIES) {
51
+ const oldest = _switchResponseCache.keys().next().value;
52
+ _switchResponseCache.delete(oldest);
53
+ }
54
+ const ttl = Number.isFinite(ttlMs) ? ttlMs : SWITCH_CACHE.TTL_MS;
55
+ _switchResponseCache.set(requestId, { accepted, expiresAt: now + ttl });
56
+ }
57
+ function consumeSwitchResponse(requestId) {
58
+ const entry = _switchResponseCache.get(requestId);
59
+ if (!entry) return null;
60
+ _switchResponseCache.delete(requestId);
61
+ if (entry.expiresAt < Date.now()) return null;
62
+ return { accepted: entry.accepted };
63
+ }
64
+ const { URL } = require("url");
65
+ const { WebSocketServer } = require("ws");
66
+ const core = require("./core.js");
67
+ const validation = require("./validation.js");
68
+ const Storage = require("./storage.js");
69
+ const bus = require("./bus.js");
70
+ const identity = require("./identity.js");
71
+ const ingest = require("./ingest.js"); // SM-203 R-7: attachment → Markdown
72
+ const slice = require("./slice.js"); // SM-203 R-7: Markdown → sections
73
+
74
+ const FRONTEND_DIR = path.resolve(__dirname, "..", "frontend");
75
+ // SM-213: realpath roots for the static-file jail. shared/ is whitelisted
76
+ // because frontend/js/core.js + core/graph.js are legit symlinks into it.
77
+ const REAL_FRONTEND_DIR = (() => { try { return fs.realpathSync(FRONTEND_DIR); } catch (_) { return FRONTEND_DIR; } })();
78
+ const REAL_SHARED_DIR = (() => {
79
+ try { return fs.realpathSync(path.resolve(__dirname, "..", "shared")); } catch (_) { return null; }
80
+ })();
81
+ const MIME = {
82
+ ".html": "text/html; charset=utf-8",
83
+ ".js": "application/javascript; charset=utf-8",
84
+ ".css": "text/css; charset=utf-8",
85
+ ".svg": "image/svg+xml",
86
+ ".png": "image/png",
87
+ ".json": "application/json"
88
+ };
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // HTTP helpers
92
+ // ---------------------------------------------------------------------------
93
+
94
+ // SM-211: security-header policy. The server is a local single-user tool
95
+ // WITHOUT auth — CORS must therefore never be `*` (any website the user visits
96
+ // could read+write all projects via fetch). Contract:
97
+ // - no Origin header (MCP / curl / tests) → request passes, no ACAO emitted
98
+ // - loopback Origin → reflected verbatim + Vary: Origin
99
+ // - foreign Origin (incl. "null" — file:// is deliberately unsupported)
100
+ // → no ACAO; mutating methods → 403
101
+ // All values live here (Konstanten-Disziplin), applied once per request in
102
+ // applyRequestSecurity(); writeJson/writeStatus no longer touch CORS.
103
+ const SECURITY_HEADERS = {
104
+ ALLOW_METHODS: "GET, POST, PUT, DELETE, OPTIONS",
105
+ // Actor attribution (SM-129), origin echo-filter, Authorization reserved for
106
+ // the deferred multi-user token gate, X-Base-Revision reserved for deferred
107
+ // optimistic locking — listing them now keeps future preflights working.
108
+ ALLOW_HEADERS: "Content-Type, X-Origin-Id, X-Actor-Type, X-Actor-Id, X-Actor-Name, X-Actor-Session, Authorization, X-Base-Revision",
109
+ MAX_AGE: "86400",
110
+ NOSNIFF: "nosniff",
111
+ // CSP for served HTML. style-src needs 'unsafe-inline' (renderer-generated
112
+ // style attributes) + the Google-Fonts stylesheet; connect-src allows the
113
+ // ?api= cross-port mode against any loopback port (http + ws, incl. [::1]
114
+ // — must stay symmetric with LOOPBACK_HOSTNAMES); frame-ancestors 'self'
115
+ // stops foreign sites from iframing the auth-less UI (clickjacking).
116
+ CSP: "default-src 'self'; script-src 'self'; " +
117
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
118
+ "font-src https://fonts.gstatic.com; " +
119
+ "connect-src 'self' http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:* http://[::1]:* ws://[::1]:*; " +
120
+ "img-src 'self' data:; " +
121
+ "frame-ancestors 'self'",
122
+ FRAME_OPTIONS: "SAMEORIGIN"
123
+ };
124
+
125
+ /**
126
+ * Apply per-request security headers (SM-211). Returns `{ foreign }` —
127
+ * `foreign: true` means a browser context whose Origin is neither absent nor
128
+ * loopback; the dispatcher rejects mutating methods for those.
129
+ */
130
+ function applyRequestSecurity(req, res) {
131
+ res.setHeader("X-Content-Type-Options", SECURITY_HEADERS.NOSNIFF);
132
+ const origin = req.headers.origin;
133
+ if (!origin) return { foreign: false };
134
+ if (!originIsLocal(origin)) return { foreign: true };
135
+ res.setHeader("Access-Control-Allow-Origin", origin);
136
+ res.setHeader("Vary", "Origin");
137
+ res.setHeader("Access-Control-Allow-Methods", SECURITY_HEADERS.ALLOW_METHODS);
138
+ res.setHeader("Access-Control-Allow-Headers", SECURITY_HEADERS.ALLOW_HEADERS);
139
+ res.setHeader("Access-Control-Max-Age", SECURITY_HEADERS.MAX_AGE);
140
+ return { foreign: false };
141
+ }
142
+
143
+ function writeJson(res, status, data) {
144
+ res.setHeader("Content-Type", "application/json");
145
+ // API responses must never be cached — a reloadStore() after a mutation
146
+ // needs to see the freshly persisted state, not a 304 from the browser.
147
+ res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
148
+ res.statusCode = status;
149
+ res.end(JSON.stringify(data));
150
+ }
151
+
152
+ function writeStatus(res, status) {
153
+ res.statusCode = status;
154
+ res.end();
155
+ }
156
+
157
+ function writeError(res, status, message, extras) {
158
+ const body = Object.assign({ error: message }, extras || {});
159
+ writeJson(res, status, body);
160
+ }
161
+
162
+ // --- security helpers (SM-147) -------------------------------------------
163
+ // The server is a local single-user tool. Two abuse vectors the review found:
164
+ // (1) Cross-Site WebSocket Hijacking — any page the user visits can open
165
+ // ws://localhost:<port>/ws (browsers don't apply same-origin to WS).
166
+ // (2) Browser-origin forgery of the internal MCP bridge endpoints.
167
+ // Both are stopped by: legit non-browser callers (MCP, ws-lib, tests) send NO
168
+ // Origin header; the app's own page sends a loopback Origin; a malicious site
169
+ // sends its own cross-origin Origin. So "no Origin OR loopback Origin" is the
170
+ // allow rule, backed by a loopback remote-address check for /api/internal/*.
171
+
172
+ const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
173
+
174
+ function originIsLocal(origin) {
175
+ if (!origin) return true; // non-browser client (MCP / ws-lib / tests)
176
+ let u;
177
+ try { u = new URL(origin); } catch (_) { return false; }
178
+ return LOOPBACK_HOSTNAMES.has(u.hostname);
179
+ }
180
+
181
+ function isLoopbackRemote(req) {
182
+ const a = req.socket && req.socket.remoteAddress;
183
+ return a === "127.0.0.1" || a === "::1" || a === "::ffff:127.0.0.1";
184
+ }
185
+
186
+ function readJsonBody(req, maxBytes) {
187
+ const limit = maxBytes || 5 * 1024 * 1024;
188
+ return new Promise((resolve, reject) => {
189
+ let total = 0;
190
+ const chunks = [];
191
+ req.on("data", (c) => {
192
+ total += c.length;
193
+ if (total > limit) {
194
+ req.destroy();
195
+ reject(Object.assign(new Error("body too large"), { statusCode: 413 }));
196
+ return;
197
+ }
198
+ chunks.push(c);
199
+ });
200
+ req.on("end", () => {
201
+ const raw = Buffer.concat(chunks).toString("utf8");
202
+ if (raw.length === 0) return resolve({});
203
+ // SM-213: central prototype-pollution guard — keys that target the
204
+ // prototype chain are dropped for every route in one place.
205
+ // CAVEAT: user-defined ids used as object keys (ticket types, statuses
206
+ // in definitions.byType / workflow.byType / entityTypeConfig) named
207
+ // literally "constructor"/"prototype" would be silently stripped —
208
+ // accepted: those names are pathological and the guard must stay dumb.
209
+ const reviver = (key, value) =>
210
+ (key === "__proto__" || key === "constructor" || key === "prototype") ? undefined : value;
211
+ try { resolve(JSON.parse(raw, reviver)); }
212
+ catch (e) { reject(Object.assign(new Error("invalid JSON: " + e.message), { statusCode: 400 })); }
213
+ });
214
+ req.on("error", reject);
215
+ });
216
+ }
217
+
218
+ // SM-183: collect a raw binary request body (attachments upload). Unlike
219
+ // readJsonBody this keeps the bytes as a Buffer and does not parse.
220
+ function readRawBody(req, maxBytes) {
221
+ const limit = maxBytes || (25 * 1024 * 1024);
222
+ return new Promise((resolve, reject) => {
223
+ let total = 0;
224
+ const chunks = [];
225
+ req.on("data", (c) => {
226
+ total += c.length;
227
+ if (total > limit) {
228
+ req.destroy();
229
+ reject(Object.assign(new Error("body too large"), { statusCode: 413 }));
230
+ return;
231
+ }
232
+ chunks.push(c);
233
+ });
234
+ req.on("end", () => resolve(Buffer.concat(chunks)));
235
+ req.on("error", reject);
236
+ });
237
+ }
238
+
239
+ function actorFromReq(req) {
240
+ // SM-129: identity now lives in the shared seam. resolveActor reads the
241
+ // X-Actor-* header convention (id/type/name/session) and falls back to the
242
+ // generic HTTP actor for anonymous callers — never to "unknown". Token
243
+ // verification plugs in here later without touching the routes.
244
+ return identity.resolveActor(req);
245
+ }
246
+
247
+ function originIdFromReq(req) {
248
+ const v = req.headers["x-origin-id"];
249
+ return typeof v === "string" && v.length > 0 ? v : null;
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Persist-and-emit helper: every write goes load → mutate → save → respond.
254
+ // ---------------------------------------------------------------------------
255
+
256
+ async function persistChange(storage, projectId, op, mutate, opts) {
257
+ // SM-154: run load → mutate → save atomically inside the storage mutex so
258
+ // two concurrent REST writes to the same project can't lose each other's
259
+ // change (the old loadProject()+saveProject() pair had a lost-update window).
260
+ // storage.mutate throws { statusCode: 404 } when the project is absent —
261
+ // same shape the explicit guard used to throw.
262
+ return await storage.mutate(projectId, {
263
+ // SM-129: never mint an ad-hoc "unknown" actor on a write path — fall
264
+ // back to the generic HTTP actor from the identity seam.
265
+ actor: (opts && opts.actor) || identity.DEFAULT_HTTP_ACTOR,
266
+ op,
267
+ originId: (opts && opts.originId) || null
268
+ }, (current) => {
269
+ const next = mutate(current);
270
+ validation.snapshotLimits(next);
271
+ return next;
272
+ });
273
+ }
274
+
275
+ function findEntity(snap, kind, id) {
276
+ const list = snap[kind] || [];
277
+ return list.find(x => x.id === id);
278
+ }
279
+
280
+ // SM-110: list links involving a ticket (forward = owned by this ticket,
281
+ // backward = links from other tickets pointing here), resolving display
282
+ // labels via project.linkTypes. Mirrors the read logic of the MCP
283
+ // list_links_for_ticket tool (server/mcp.js) — kept in lockstep.
284
+ function listLinksForTicket(snap, ticketId, direction) {
285
+ const dir = direction || "both";
286
+ const linkTypes = (snap.project && snap.project.linkTypes) || [];
287
+ const ltById = new Map(linkTypes.map(lt => [lt.id, lt]));
288
+ const summary = (t) => t ? { id: t.id, ticketKey: t.ticketKey, title: t.title, type: t.type } : null;
289
+ const resolveLabel = (linkTypeId, fallback, inverse) => {
290
+ const lt = ltById.get(linkTypeId);
291
+ if (lt) return inverse ? lt.inverseLabel : lt.label;
292
+ return fallback || linkTypeId;
293
+ };
294
+ const tickets = snap.tickets || [];
295
+ const ticket = tickets.find(t => t.id === ticketId);
296
+ const out = [];
297
+ if (dir === "forward" || dir === "both") {
298
+ for (const l of (ticket && ticket.links) || []) {
299
+ const target = tickets.find(t => t.id === l.targetTicketId);
300
+ out.push({
301
+ linkId: l.id, linkTypeId: l.linkTypeId,
302
+ label: l.label || resolveLabel(l.linkTypeId, null, false),
303
+ direction: "forward", target: summary(target)
304
+ });
305
+ }
306
+ }
307
+ if (dir === "backward" || dir === "both") {
308
+ for (const other of tickets) {
309
+ if (other.id === ticketId) continue;
310
+ for (const l of other.links || []) {
311
+ if (l.targetTicketId !== ticketId) continue;
312
+ out.push({
313
+ linkId: l.id, sourceTicketId: other.id, linkTypeId: l.linkTypeId,
314
+ label: resolveLabel(l.linkTypeId, l.label, true),
315
+ direction: "backward", source: summary(other)
316
+ });
317
+ }
318
+ }
319
+ }
320
+ return out;
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // Route table
325
+ // ---------------------------------------------------------------------------
326
+
327
+ /**
328
+ * Tiny path-pattern matcher: turn ":pid" segments into capture groups.
329
+ * Returns { match: true, params } or { match: false }.
330
+ */
331
+ function matchPath(pattern, pathname) {
332
+ const ps = pattern.split("/");
333
+ const xs = pathname.split("/");
334
+ if (ps.length !== xs.length) return { match: false };
335
+ const params = {};
336
+ for (let i = 0; i < ps.length; i++) {
337
+ if (ps[i].startsWith(":")) params[ps[i].slice(1)] = decodeURIComponent(xs[i]);
338
+ else if (ps[i] !== xs[i]) return { match: false };
339
+ }
340
+ return { match: true, params };
341
+ }
342
+
343
+ function buildRouter(storage) {
344
+
345
+ const routes = [];
346
+ function route(method, pattern, handler) {
347
+ routes.push({ method, pattern, handler });
348
+ }
349
+
350
+ // -------------------- health ---------------------------------------------
351
+ // SM-99: the frontend probes this endpoint to decide whether to use the
352
+ // HttpAdapter without an explicit `?api=` query param. `name:"storymap"`
353
+ // is the identity check — guards against another service that happens to
354
+ // sit on the same origin/port. Kept cheap: no storage hit.
355
+ route("GET", "/api/health", async (req, res) => {
356
+ writeJson(res, 200, { ok: true, name: "storymap", limits: core.LIMITS });
357
+ });
358
+
359
+ // -------------------- build-info -----------------------------------------
360
+ // Liest beim ersten Aufruf den Git-HEAD aus dem Repo-Root und cached.
361
+ // Schadlos bei deployments ohne .git — fällt auf "unknown" zurück.
362
+ let _buildInfo = null;
363
+ function readBuildInfo() {
364
+ if (_buildInfo) return _buildInfo;
365
+ const { execSync } = require("child_process");
366
+ const repoRoot = path.resolve(__dirname, "..");
367
+ let commit = "unknown", subject = "", iso = "";
368
+ try {
369
+ commit = execSync("git rev-parse --short HEAD", { cwd: repoRoot, stdio: ["ignore","pipe","ignore"] }).toString().trim();
370
+ subject = execSync("git log -1 --format=%s", { cwd: repoRoot, stdio: ["ignore","pipe","ignore"] }).toString().trim();
371
+ iso = execSync("git log -1 --format=%cI", { cwd: repoRoot, stdio: ["ignore","pipe","ignore"] }).toString().trim();
372
+ } catch (_) { /* not a git checkout — leave defaults */ }
373
+ _buildInfo = { commit, subject, committedAt: iso, startedAt: new Date().toISOString() };
374
+ return _buildInfo;
375
+ }
376
+ route("GET", "/api/build-info", async (req, res) => {
377
+ writeJson(res, 200, readBuildInfo());
378
+ });
379
+
380
+ // -------------------- internal: switch-project bridge (E20.A) -----------
381
+ // Standalone MCP (separate Node process) POSTs here to broadcast a
382
+ // switch_request via the in-process bus, which the WS layer relays to
383
+ // every connected browser. The browser shows a confirm modal and POSTs
384
+ // its verdict back over WS as { type:"switch_response", requestId,
385
+ // accepted }, which the WS handler then routes back to the MCP via the
386
+ // long-poll GET below.
387
+ route("POST", "/api/internal/switch-request", async (req, res) => {
388
+ const body = await readJsonBody(req);
389
+ if (!body || typeof body !== "object" || typeof body.workspace !== "string") {
390
+ return writeError(res, 400, "body must be { workspace: string, reason?: string, requestId?: string }");
391
+ }
392
+ bus.emit("switch_request", {
393
+ workspace: body.workspace,
394
+ reason: typeof body.reason === "string" ? body.reason : null,
395
+ requestId: typeof body.requestId === "string" ? body.requestId : null
396
+ });
397
+ writeJson(res, 200, { ok: true });
398
+ });
399
+
400
+ // POST /api/internal/notify-change — cross-process change forwarding so
401
+ // standalone MCP can push live updates to connected browsers (E20.E). MCP
402
+ // forwards its OWN local bus.change events through this endpoint; we
403
+ // re-emit on the in-process bus, which the WS layer then broadcasts to
404
+ // matching subscribers (with the usual origin-id echo filter applied).
405
+ route("POST", "/api/internal/notify-change", async (req, res) => {
406
+ const body = await readJsonBody(req);
407
+ if (!body || typeof body !== "object" || typeof body.projectId !== "string") {
408
+ return writeError(res, 400, "body must include projectId");
409
+ }
410
+ bus.emit("change", {
411
+ projectId: body.projectId,
412
+ revision: body.revision || null,
413
+ savedAt: body.savedAt || null,
414
+ op: body.op || null,
415
+ actor: body.actor || null,
416
+ originId: typeof body.originId === "string" ? body.originId : null
417
+ });
418
+ writeJson(res, 200, { ok: true });
419
+ });
420
+
421
+ route("GET", "/api/internal/switch-response", async (req, res) => {
422
+ const url = new URL(req.url, "http://localhost");
423
+ const requestId = url.searchParams.get("requestId");
424
+ if (!requestId) return writeError(res, 400, "requestId query param required");
425
+ const waitRaw = parseInt(url.searchParams.get("wait"), 10);
426
+ const waitSec = Number.isFinite(waitRaw) ? Math.max(0, Math.min(120, waitRaw)) : 30;
427
+ // Fast path: browser beat the MCP poll.
428
+ const cached = consumeSwitchResponse(requestId);
429
+ if (cached) return writeJson(res, 200, { accepted: cached.accepted, timedOut: false });
430
+ if (waitSec === 0) return writeJson(res, 200, { accepted: null, timedOut: true });
431
+ // Slow path: race a bus listener with timeout + client disconnect.
432
+ const result = await new Promise((resolve) => {
433
+ let settled = false;
434
+ const settle = (val) => {
435
+ if (settled) return;
436
+ settled = true;
437
+ clearTimeout(timer);
438
+ bus.off("switch_response", onResp);
439
+ req.removeListener("close", onClose);
440
+ resolve(val);
441
+ };
442
+ const onResp = (ev) => {
443
+ if (!ev || ev.requestId !== requestId) return;
444
+ consumeSwitchResponse(requestId); // drain the cache the WS handler also wrote
445
+ settle({ accepted: !!ev.accepted });
446
+ };
447
+ const onClose = () => settle(null);
448
+ const timer = setTimeout(() => settle(null), waitSec * 1000);
449
+ bus.on("switch_response", onResp);
450
+ req.once("close", onClose);
451
+ });
452
+ if (result) return writeJson(res, 200, { accepted: result.accepted, timedOut: false });
453
+ writeJson(res, 200, { accepted: null, timedOut: true });
454
+ });
455
+
456
+ // -------------------- projects -------------------------------------------
457
+ route("GET", "/api/projects", async (req, res) => {
458
+ const list = await storage.listProjects();
459
+ writeJson(res, 200, list);
460
+ });
461
+
462
+ route("POST", "/api/projects", async (req, res) => {
463
+ const body = await readJsonBody(req);
464
+ validation.projectInput(body);
465
+ const actor = actorFromReq(req);
466
+ const originId = originIdFromReq(req);
467
+ const project = core.normalizeProject(Object.assign({}, body, {
468
+ createdBy: actor, updatedBy: actor
469
+ }));
470
+ // SM-254: seed a default release + process step so the project is usable.
471
+ const snap = core.seedDefaultScaffold(core.normalizeSnapshot({
472
+ project,
473
+ tickets: [], releases: [], processSteps: []
474
+ }), actor);
475
+ const saved = await storage.saveProject(project.id, snap, {
476
+ actor, op: "project_create", originId
477
+ });
478
+ writeJson(res, 201, { revision: saved.revision, savedAt: saved.savedAt, snapshot: saved.snapshot });
479
+ });
480
+
481
+ route("GET", "/api/projects/:pid", async (req, res, params) => {
482
+ const snap = await storage.loadProject(params.pid);
483
+ if (!snap) return writeError(res, 404, "project not found");
484
+ writeJson(res, 200, snap);
485
+ });
486
+
487
+ // PUT /api/projects/:pid accepts two body shapes:
488
+ //
489
+ // (1) Full-snapshot replace (E18: cmapper-Pattern): body has `tickets` and
490
+ // `releases` arrays. The whole project state is replaced atomically and
491
+ // a single revision with op "project_put" is written. This is the
492
+ // persistence path used by the store-zentrierte Frontend; undo/redo
493
+ // commits all flow through here.
494
+ //
495
+ // (2) Backward-compat header merge: body has no `tickets`/`releases` keys
496
+ // (older callers passed just the project header to rename etc.). Then
497
+ // only project.* fields are merged; entity arrays are left untouched.
498
+ //
499
+ // Sanity-check for (1): project.id in the body must match :pid. Otherwise
500
+ // a typo could silently re-route writes into the wrong project (cmapper
501
+ // hit this and now pins the workspace explicitly).
502
+ route("PUT", "/api/projects/:pid", async (req, res, params) => {
503
+ const body = await readJsonBody(req);
504
+ const actor = actorFromReq(req);
505
+ const originId = originIdFromReq(req);
506
+ const isSnapshot = body && Array.isArray(body.tickets) && Array.isArray(body.releases);
507
+ if (isSnapshot) {
508
+ if (body.project && body.project.id && body.project.id !== params.pid) {
509
+ return writeError(res, 400, "body.project.id does not match URL :pid");
510
+ }
511
+ const result = await persistChange(storage, params.pid, "project_put",
512
+ (cur) => {
513
+ // Pin the project id to the URL so a snapshot exported from another
514
+ // project can't overwrite this one (mirrors cmapper.applySnapshot).
515
+ const pinnedProject = Object.assign({}, body.project || cur.project, {
516
+ id: params.pid,
517
+ updatedBy: actor
518
+ });
519
+ validation.projectInput(pinnedProject);
520
+ return core.normalizeSnapshot(Object.assign({}, body, { project: pinnedProject }));
521
+ },
522
+ { actor, originId });
523
+ return writeJson(res, 200, {
524
+ revision: result.revision,
525
+ savedAt: result.savedAt,
526
+ snapshot: result.snapshot
527
+ });
528
+ }
529
+ // Backward-compat: header-only merge. Route through core.ops.updateProject
530
+ // so id / ticketPrefix / ticketCounter stay locked (SM-148).
531
+ const result = await persistChange(storage, params.pid, "project_update",
532
+ (cur) => {
533
+ const next = core.ops.updateProject(cur, body, actor);
534
+ validation.projectInput(next.project); // SM-148: keep header validation
535
+ return next;
536
+ },
537
+ { actor, originId });
538
+ writeJson(res, 200, { revision: result.revision, savedAt: result.savedAt, project: result.snapshot.project });
539
+ });
540
+
541
+ route("DELETE", "/api/projects/:pid", async (req, res, params) => {
542
+ const actor = actorFromReq(req);
543
+ await storage.deleteProject(params.pid, { actor });
544
+ writeStatus(res, 204);
545
+ });
546
+
547
+ // -------------------- tickets --------------------------------------------
548
+ route("GET", "/api/projects/:pid/tickets", async (req, res, params) => {
549
+ const snap = await storage.loadProject(params.pid);
550
+ if (!snap) return writeError(res, 404, "project not found");
551
+ writeJson(res, 200, snap.tickets.filter(t => !t.isDeleted));
552
+ });
553
+
554
+ route("POST", "/api/projects/:pid/tickets", async (req, res, params) => {
555
+ const body = await readJsonBody(req);
556
+ const actor = actorFromReq(req);
557
+ const originId = originIdFromReq(req);
558
+ const result = await persistChange(storage, params.pid, "ticket_create",
559
+ (cur) => {
560
+ validation.ticketInput(body, cur.project);
561
+ return core.ops.createTicket(cur, body, actor);
562
+ },
563
+ { actor, originId });
564
+ const ticket = result.snapshot.tickets[result.snapshot.tickets.length - 1];
565
+ writeJson(res, 201, { revision: result.revision, savedAt: result.savedAt, ticket });
566
+ });
567
+
568
+ route("GET", "/api/projects/:pid/tickets/:tid", async (req, res, params) => {
569
+ const snap = await storage.loadProject(params.pid);
570
+ if (!snap) return writeError(res, 404, "project not found");
571
+ const t = findEntity(snap, "tickets", params.tid);
572
+ if (!t || t.isDeleted) return writeError(res, 404, "ticket not found");
573
+ writeJson(res, 200, t);
574
+ });
575
+
576
+ route("PUT", "/api/projects/:pid/tickets/:tid", async (req, res, params) => {
577
+ const body = await readJsonBody(req);
578
+ const actor = actorFromReq(req);
579
+ const originId = originIdFromReq(req);
580
+ const result = await persistChange(storage, params.pid, "ticket_update",
581
+ (cur) => {
582
+ const existing = findEntity(cur, "tickets", params.tid);
583
+ if (!existing) throw Object.assign(new Error("ticket not found"), { statusCode: 404 });
584
+ validation.ticketInput(Object.assign({}, existing, body), cur.project);
585
+ return core.ops.updateTicket(cur, params.tid, body, actor);
586
+ },
587
+ { actor, originId });
588
+ writeJson(res, 200, {
589
+ revision: result.revision, savedAt: result.savedAt,
590
+ ticket: findEntity(result.snapshot, "tickets", params.tid)
591
+ });
592
+ });
593
+
594
+ route("DELETE", "/api/projects/:pid/tickets/:tid", async (req, res, params) => {
595
+ const actor = actorFromReq(req);
596
+ const originId = originIdFromReq(req);
597
+ await persistChange(storage, params.pid, "ticket_delete",
598
+ (cur) => core.ops.softDeleteTicket(cur, params.tid, actor),
599
+ { actor, originId });
600
+ writeStatus(res, 204);
601
+ });
602
+
603
+ /**
604
+ * Ticket-Reorder — mirrors the shape of POST /process-steps/reorder.
605
+ * Body: { orderedIds: string[], scope?: { releaseId, processStepId, epicId } }.
606
+ * Sets sortOrder = idx for every ticket in orderedIds. If `scope` is set,
607
+ * releaseId/processStepId/epicId are ALSO applied to every ticket in the
608
+ * list (handles cross-container moves atomically).
609
+ */
610
+ route("POST", "/api/projects/:pid/tickets/reorder", async (req, res, params) => {
611
+ const body = await readJsonBody(req);
612
+ if (!body || !Array.isArray(body.orderedIds)) {
613
+ return writeError(res, 400, "body.orderedIds must be an array");
614
+ }
615
+ const actor = actorFromReq(req);
616
+ const originId = originIdFromReq(req);
617
+ const result = await persistChange(storage, params.pid, "tickets_reorder",
618
+ (cur) => core.ops.reorderTickets(cur, body.orderedIds, body.scope || null, actor),
619
+ { actor, originId });
620
+ writeJson(res, 200, { revision: result.revision, savedAt: result.savedAt, snapshot: result.snapshot });
621
+ });
622
+
623
+ // -------------------- ticket links (SM-110 / A1) -------------------------
624
+ // REST parity with the MCP link_create / link_delete / list_links_for_ticket
625
+ // tools: the write paths go through the SAME core.ops.addLink / removeLink,
626
+ // so the validation error kinds (LINK_TARGET_REQUIRED, LINK_SELF,
627
+ // LINK_TARGET_MISSING, LINK_DUPLICATE, LINK_CYCLE) surface as HTTP 4xx with
628
+ // `kind` via the central router catch.
629
+
630
+ route("GET", "/api/projects/:pid/tickets/:tid/links", async (req, res, params) => {
631
+ const snap = await storage.loadProject(params.pid);
632
+ if (!snap) return writeError(res, 404, "project not found");
633
+ const t = findEntity(snap, "tickets", params.tid);
634
+ if (!t || t.isDeleted) return writeError(res, 404, "ticket not found");
635
+ let url; try { url = new URL(req.url, "http://localhost"); } catch { url = null; }
636
+ const direction = (url && url.searchParams.get("direction")) || "both";
637
+ if (["forward", "backward", "both"].indexOf(direction) === -1) {
638
+ return writeError(res, 400, "direction must be forward|backward|both");
639
+ }
640
+ writeJson(res, 200, {
641
+ ticketId: params.tid, direction,
642
+ links: listLinksForTicket(snap, params.tid, direction)
643
+ });
644
+ });
645
+
646
+ route("POST", "/api/projects/:pid/tickets/:tid/links", async (req, res, params) => {
647
+ const body = await readJsonBody(req);
648
+ const actor = actorFromReq(req);
649
+ const originId = originIdFromReq(req);
650
+ const linkPatch = {
651
+ linkTypeId: body && body.linkTypeId,
652
+ targetTicketId: body && body.targetTicketId
653
+ };
654
+ if (body && typeof body.label === "string" && body.label.length > 0) linkPatch.label = body.label;
655
+ const result = await persistChange(storage, params.pid, "link_create",
656
+ (cur) => core.ops.addLink(cur, params.tid, linkPatch, actor),
657
+ { actor, originId });
658
+ const ticket = findEntity(result.snapshot, "tickets", params.tid);
659
+ const newLink = ticket && ticket.links[ticket.links.length - 1];
660
+ writeJson(res, 201, { revision: result.revision, savedAt: result.savedAt, link: newLink });
661
+ });
662
+
663
+ route("DELETE", "/api/projects/:pid/tickets/:tid/links/:linkId", async (req, res, params) => {
664
+ const actor = actorFromReq(req);
665
+ const originId = originIdFromReq(req);
666
+ const result = await persistChange(storage, params.pid, "link_delete",
667
+ (cur) => core.ops.removeLink(cur, params.tid, params.linkId, actor),
668
+ { actor, originId });
669
+ writeJson(res, 200, { revision: result.revision, savedAt: result.savedAt });
670
+ });
671
+
672
+ // get_link_types parity — the project's link-type catalogue.
673
+ route("GET", "/api/projects/:pid/link-types", async (req, res, params) => {
674
+ const snap = await storage.loadProject(params.pid);
675
+ if (!snap) return writeError(res, 404, "project not found");
676
+ writeJson(res, 200, { linkTypes: (snap.project && snap.project.linkTypes) || [] });
677
+ });
678
+
679
+ // -------------------- test-definition spec (SM-111 / A2) -----------------
680
+ // REST parity with the MCP test_def_step_* / test_def_prereq_* tools: same
681
+ // core.ops, so the DEFINITION_FROZEN gate (published test-def spec is locked)
682
+ // fires identically for REST and MCP — surfaced as HTTP 409 with kind via
683
+ // the central router catch. check/uncheck are runtime toggles, not frozen.
684
+
685
+ route("POST", "/api/projects/:pid/tickets/:tid/test-steps", async (req, res, params) => {
686
+ const body = await readJsonBody(req);
687
+ const actor = actorFromReq(req);
688
+ const originId = originIdFromReq(req);
689
+ const result = await persistChange(storage, params.pid, "test_def_step_add",
690
+ (cur) => core.ops.addTestStep(cur, params.tid, body || {}, actor),
691
+ { actor, originId });
692
+ const t = findEntity(result.snapshot, "tickets", params.tid);
693
+ const step = t && t.steps[t.steps.length - 1];
694
+ writeJson(res, 201, { revision: result.revision, savedAt: result.savedAt, step });
695
+ });
696
+
697
+ route("PUT", "/api/projects/:pid/tickets/:tid/test-steps/:stepId", async (req, res, params) => {
698
+ const body = await readJsonBody(req);
699
+ const actor = actorFromReq(req);
700
+ const originId = originIdFromReq(req);
701
+ const result = await persistChange(storage, params.pid, "test_def_step_update",
702
+ (cur) => core.ops.updateTestStep(cur, params.tid, params.stepId, body || {}, actor),
703
+ { actor, originId });
704
+ const t = findEntity(result.snapshot, "tickets", params.tid);
705
+ const step = t && (t.steps || []).find(x => x.id === params.stepId);
706
+ writeJson(res, 200, { revision: result.revision, savedAt: result.savedAt, step });
707
+ });
708
+
709
+ route("DELETE", "/api/projects/:pid/tickets/:tid/test-steps/:stepId", async (req, res, params) => {
710
+ const actor = actorFromReq(req);
711
+ const originId = originIdFromReq(req);
712
+ const result = await persistChange(storage, params.pid, "test_def_step_remove",
713
+ (cur) => core.ops.removeTestStep(cur, params.tid, params.stepId, actor),
714
+ { actor, originId });
715
+ writeJson(res, 200, { revision: result.revision, savedAt: result.savedAt });
716
+ });
717
+
718
+ route("POST", "/api/projects/:pid/tickets/:tid/test-steps/reorder", async (req, res, params) => {
719
+ const body = await readJsonBody(req);
720
+ if (!body || !Array.isArray(body.orderedStepIds)) {
721
+ return writeError(res, 400, "body.orderedStepIds must be an array");
722
+ }
723
+ const actor = actorFromReq(req);
724
+ const originId = originIdFromReq(req);
725
+ const result = await persistChange(storage, params.pid, "test_def_step_reorder",
726
+ (cur) => core.ops.reorderTestSteps(cur, params.tid, body.orderedStepIds, actor),
727
+ { actor, originId });
728
+ const t = findEntity(result.snapshot, "tickets", params.tid);
729
+ writeJson(res, 200, { revision: result.revision, savedAt: result.savedAt, steps: t && t.steps });
730
+ });
731
+
732
+ route("POST", "/api/projects/:pid/tickets/:tid/test-prereqs", async (req, res, params) => {
733
+ const body = await readJsonBody(req);
734
+ const actor = actorFromReq(req);
735
+ const originId = originIdFromReq(req);
736
+ const result = await persistChange(storage, params.pid, "test_def_prereq_add",
737
+ (cur) => core.ops.addTestPrereq(cur, params.tid, body || {}, actor),
738
+ { actor, originId });
739
+ const t = findEntity(result.snapshot, "tickets", params.tid);
740
+ const prerequisite = t && t.prerequisites[t.prerequisites.length - 1];
741
+ writeJson(res, 201, { revision: result.revision, savedAt: result.savedAt, prerequisite });
742
+ });
743
+
744
+ route("PUT", "/api/projects/:pid/tickets/:tid/test-prereqs/:prereqId", async (req, res, params) => {
745
+ const body = await readJsonBody(req);
746
+ const actor = actorFromReq(req);
747
+ const originId = originIdFromReq(req);
748
+ const result = await persistChange(storage, params.pid, "test_def_prereq_update",
749
+ (cur) => core.ops.updateTestPrereq(cur, params.tid, params.prereqId, body || {}, actor),
750
+ { actor, originId });
751
+ const t = findEntity(result.snapshot, "tickets", params.tid);
752
+ const prerequisite = t && (t.prerequisites || []).find(x => x.id === params.prereqId);
753
+ writeJson(res, 200, { revision: result.revision, savedAt: result.savedAt, prerequisite });
754
+ });
755
+
756
+ route("DELETE", "/api/projects/:pid/tickets/:tid/test-prereqs/:prereqId", async (req, res, params) => {
757
+ const actor = actorFromReq(req);
758
+ const originId = originIdFromReq(req);
759
+ const result = await persistChange(storage, params.pid, "test_def_prereq_remove",
760
+ (cur) => core.ops.removeTestPrereq(cur, params.tid, params.prereqId, actor),
761
+ { actor, originId });
762
+ writeJson(res, 200, { revision: result.revision, savedAt: result.savedAt });
763
+ });
764
+
765
+ route("POST", "/api/projects/:pid/tickets/:tid/test-prereqs/:prereqId/check", async (req, res, params) => {
766
+ const actor = actorFromReq(req);
767
+ const originId = originIdFromReq(req);
768
+ const result = await persistChange(storage, params.pid, "test_def_prereq_check",
769
+ (cur) => core.ops.checkTestPrereq(cur, params.tid, params.prereqId, actor),
770
+ { actor, originId });
771
+ writeJson(res, 200, { revision: result.revision, savedAt: result.savedAt });
772
+ });
773
+
774
+ route("POST", "/api/projects/:pid/tickets/:tid/test-prereqs/:prereqId/uncheck", async (req, res, params) => {
775
+ const actor = actorFromReq(req);
776
+ const originId = originIdFromReq(req);
777
+ const result = await persistChange(storage, params.pid, "test_def_prereq_uncheck",
778
+ (cur) => core.ops.uncheckTestPrereq(cur, params.tid, params.prereqId, actor),
779
+ { actor, originId });
780
+ writeJson(res, 200, { revision: result.revision, savedAt: result.savedAt });
781
+ });
782
+
783
+ // -------------------- test-execution (SM-112 / A3) -----------------------
784
+ // REST parity with the MCP test_exec_* tools, same core.ops. start replicates
785
+ // the published-definition gate the MCP wrapper enforces (DEFINITION_DRAFT)
786
+ // so a draft can't be executed from either surface. record / set_outcome
787
+ // keep the outcome→status auto-coupling intact (it lives in the core ops).
788
+
789
+ route("POST", "/api/projects/:pid/test-executions", async (req, res, params) => {
790
+ const body = await readJsonBody(req);
791
+ const actor = actorFromReq(req);
792
+ const originId = originIdFromReq(req);
793
+ const definitionId = body && body.definitionId;
794
+ const opts = { env: body && body.env, runBy: body && body.runBy };
795
+ const result = await persistChange(storage, params.pid, "test_exec_start",
796
+ (cur) => {
797
+ const def = findEntity(cur, "tickets", definitionId);
798
+ if (!def) throw Object.assign(new Error("test-definition not found: " + definitionId), { statusCode: 404 });
799
+ if (def.type !== "test-definition") {
800
+ throw Object.assign(new Error("not a test-definition: " + definitionId), { statusCode: 422, kind: "WRONG_TYPE" });
801
+ }
802
+ if (def.lifecycle !== "published") {
803
+ throw Object.assign(new Error("test-definition is in draft, not runnable"), { statusCode: 422, kind: "DEFINITION_DRAFT" });
804
+ }
805
+ return core.ops.startTestExecution(cur, definitionId, opts, actor);
806
+ },
807
+ { actor, originId });
808
+ const exec = result.snapshot.tickets
809
+ .filter(t => t.type === "test-execution" && t.referencedTestDefinitionId === definitionId)
810
+ .sort((a, b) => (b.runAt || 0) - (a.runAt || 0))[0];
811
+ writeJson(res, 201, { revision: result.revision, savedAt: result.savedAt, execution: exec });
812
+ });
813
+
814
+ route("POST", "/api/projects/:pid/tickets/:tid/execution-steps/:stepId", async (req, res, params) => {
815
+ const body = await readJsonBody(req);
816
+ const actor = actorFromReq(req);
817
+ const originId = originIdFromReq(req);
818
+ const result = await persistChange(storage, params.pid, "test_exec_record",
819
+ (cur) => core.ops.recordTestExecStep(cur, params.tid, params.stepId, body || {}, actor),
820
+ { actor, originId });
821
+ const exec = findEntity(result.snapshot, "tickets", params.tid);
822
+ const step = exec && (exec.executionSteps || []).find(x => x.stepId === params.stepId || x.id === params.stepId);
823
+ writeJson(res, 200, {
824
+ revision: result.revision, savedAt: result.savedAt,
825
+ step, effectiveOutcome: exec && core.getEffectiveOutcome(exec)
826
+ });
827
+ });
828
+
829
+ route("POST", "/api/projects/:pid/tickets/:tid/execution-outcome", async (req, res, params) => {
830
+ const body = await readJsonBody(req);
831
+ const actor = actorFromReq(req);
832
+ const originId = originIdFromReq(req);
833
+ const result = await persistChange(storage, params.pid, "test_exec_set_outcome",
834
+ (cur) => core.ops.setTestExecOutcome(cur, params.tid, body && body.outcome, actor),
835
+ { actor, originId });
836
+ const exec = findEntity(result.snapshot, "tickets", params.tid);
837
+ writeJson(res, 200, {
838
+ revision: result.revision, savedAt: result.savedAt,
839
+ outcomeOverride: exec && exec.outcomeOverride,
840
+ effectiveOutcome: exec && core.getEffectiveOutcome(exec)
841
+ });
842
+ });
843
+
844
+ // history of executions for a definition (:tid = definitionId), compact summary.
845
+ route("GET", "/api/projects/:pid/tickets/:tid/executions", async (req, res, params) => {
846
+ const snap = await storage.loadProject(params.pid);
847
+ if (!snap) return writeError(res, 404, "project not found");
848
+ let url; try { url = new URL(req.url, "http://localhost"); } catch { url = null; }
849
+ const limitParam = url && url.searchParams.get("limit");
850
+ const limit = limitParam ? parseInt(limitParam, 10) : undefined;
851
+ const history = core.tickets.testExecHistory(snap, params.tid,
852
+ typeof limit === "number" && !isNaN(limit) ? { limit } : undefined);
853
+ const executions = history.map(t => ({
854
+ id: t.id, ticketKey: t.ticketKey, title: t.title, status: t.status,
855
+ runAt: t.runAt, env: t.env || null, effectiveOutcome: core.getEffectiveOutcome(t)
856
+ }));
857
+ writeJson(res, 200, { executions });
858
+ });
859
+
860
+ // -------------------- project config (SM-113 / A4) -----------------------
861
+ // get/set for workflow, kanban columns, link-types and governance — REST
862
+ // parity with set_workflow / set_kanban_columns / set_link_types /
863
+ // set_governance. set_* are FULL replaces; set_governance rejects unknown
864
+ // predicate names (HTTP 400 kind=UNKNOWN_PREDICATE).
865
+
866
+ route("GET", "/api/projects/:pid/workflow", async (req, res, params) => {
867
+ const snap = await storage.loadProject(params.pid);
868
+ if (!snap) return writeError(res, 404, "project not found");
869
+ let url; try { url = new URL(req.url, "http://localhost"); } catch { url = null; }
870
+ const type = url && url.searchParams.get("type");
871
+ if (type) return writeJson(res, 200, core.getWorkflowForType(snap.project, type));
872
+ writeJson(res, 200, snap.project.workflow || core.STORYMAPPER_DEFAULT_WORKFLOW);
873
+ });
874
+
875
+ route("PUT", "/api/projects/:pid/workflow", async (req, res, params) => {
876
+ const body = await readJsonBody(req);
877
+ const actor = actorFromReq(req);
878
+ const originId = originIdFromReq(req);
879
+ const result = await persistChange(storage, params.pid, "workflow_update",
880
+ (cur) => {
881
+ const wf = core.normalizeWorkflow(body);
882
+ if (!wf || wf.statuses.length === 0) {
883
+ throw Object.assign(new Error("workflow.statuses must be a non-empty array"), { statusCode: 400 });
884
+ }
885
+ return core.normalizeSnapshot(Object.assign({}, cur, {
886
+ project: Object.assign({}, cur.project, { workflow: wf })
887
+ }));
888
+ },
889
+ { actor, originId });
890
+ writeJson(res, 200, { revision: result.revision, savedAt: result.savedAt, workflow: result.snapshot.project.workflow });
891
+ });
892
+
893
+ route("GET", "/api/projects/:pid/kanban-columns", async (req, res, params) => {
894
+ const snap = await storage.loadProject(params.pid);
895
+ if (!snap) return writeError(res, 404, "project not found");
896
+ const cols = (snap.project.boards && snap.project.boards.kanban && snap.project.boards.kanban.columns) || [];
897
+ writeJson(res, 200, { columns: cols });
898
+ });
899
+
900
+ route("PUT", "/api/projects/:pid/kanban-columns", async (req, res, params) => {
901
+ const body = await readJsonBody(req);
902
+ const columns = Array.isArray(body) ? body : (body && body.columns);
903
+ const actor = actorFromReq(req);
904
+ const originId = originIdFromReq(req);
905
+ const result = await persistChange(storage, params.pid, "kanban_columns_update",
906
+ (cur) => {
907
+ const nextBoards = Object.assign({}, cur.project.boards || {}, {
908
+ kanban: { columns: Array.isArray(columns) ? columns : [] }
909
+ });
910
+ return core.normalizeSnapshot(Object.assign({}, cur, {
911
+ project: Object.assign({}, cur.project, { boards: nextBoards })
912
+ }));
913
+ },
914
+ { actor, originId });
915
+ const cols = (result.snapshot.project.boards && result.snapshot.project.boards.kanban
916
+ && result.snapshot.project.boards.kanban.columns) || [];
917
+ writeJson(res, 200, { revision: result.revision, savedAt: result.savedAt, columns: cols });
918
+ });
919
+
920
+ // link-types: GET added in the SM-110 section; PUT here (full replace).
921
+ route("PUT", "/api/projects/:pid/link-types", async (req, res, params) => {
922
+ const body = await readJsonBody(req);
923
+ const list = Array.isArray(body) ? body : (body && Array.isArray(body.linkTypes) ? body.linkTypes : []);
924
+ const actor = actorFromReq(req);
925
+ const originId = originIdFromReq(req);
926
+ const result = await persistChange(storage, params.pid, "link_types_update",
927
+ (cur) => core.ops.updateProject(cur, { linkTypes: list }, actor),
928
+ { actor, originId });
929
+ writeJson(res, 200, { revision: result.revision, savedAt: result.savedAt, linkTypes: result.snapshot.project.linkTypes });
930
+ });
931
+
932
+ route("GET", "/api/projects/:pid/governance", async (req, res, params) => {
933
+ const snap = await storage.loadProject(params.pid);
934
+ if (!snap) return writeError(res, 404, "project not found");
935
+ writeJson(res, 200, { governance: snap.project.governance || core.STORYMAPPER_DEFAULT_GOVERNANCE });
936
+ });
937
+
938
+ route("PUT", "/api/projects/:pid/governance", async (req, res, params) => {
939
+ const body = await readJsonBody(req);
940
+ const incoming = (body && typeof body === "object") ? (body.governance || body) : {};
941
+ // Reject unknown predicate names up front (parity with set_governance).
942
+ const predicateSet = new Set(Object.keys(core.GOVERNANCE_PREDICATES));
943
+ const gates = incoming.gates || {};
944
+ const unknown = [];
945
+ for (const errorKind of Object.keys(gates)) {
946
+ const cfg = gates[errorKind];
947
+ if (!cfg || typeof cfg.predicate !== "string") continue;
948
+ if (!predicateSet.has(cfg.predicate)) unknown.push({ errorKind, predicate: cfg.predicate });
949
+ }
950
+ if (unknown.length > 0) {
951
+ return writeError(res, 400, "governance references unknown predicate(s)",
952
+ { kind: "UNKNOWN_PREDICATE", unknown, knownPredicates: Array.from(predicateSet) });
953
+ }
954
+ const actor = actorFromReq(req);
955
+ const originId = originIdFromReq(req);
956
+ const result = await persistChange(storage, params.pid, "governance_update",
957
+ (cur) => core.ops.updateProject(cur, { governance: core.normalizeGovernance(incoming) }, actor),
958
+ { actor, originId });
959
+ writeJson(res, 200, { revision: result.revision, savedAt: result.savedAt, governance: result.snapshot.project.governance });
960
+ });
961
+
962
+ // -------------------- revisions / history -------------------------------
963
+ //
964
+ // E13: storage.listRevisions/getRevision/restoreRevision sind seit E2 da,
965
+ // die REST-Surface wurde erst hier nachgereicht. restoreRevision erzeugt
966
+ // eine NEUE Revision (op: "project_restore") — damit ist der Restore
967
+ // selbst undoable über einen erneuten Restore auf die vorige Revision.
968
+
969
+ // SM-212: limit + before go down into storage (SQL LIMIT) instead of
970
+ // select-all + JS slicing; clamping happens in storage.listRevisions.
971
+ route("GET", "/api/projects/:pid/revisions", async (req, res, params) => {
972
+ let url;
973
+ try { url = new URL(req.url, "http://localhost"); } catch { url = null; }
974
+ const limitParam = url && url.searchParams.get("limit");
975
+ const beforeParam = url && url.searchParams.get("before");
976
+ const opts = {};
977
+ // parseInt result is passed when finite (incl. 0 → storage clamps to 1).
978
+ if (limitParam != null && Number.isFinite(parseInt(limitParam, 10))) {
979
+ opts.limit = parseInt(limitParam, 10);
980
+ }
981
+ if (beforeParam) opts.beforeRevision = beforeParam;
982
+ writeJson(res, 200, await storage.listRevisions(params.pid, opts));
983
+ });
984
+
985
+ route("GET", "/api/projects/:pid/revisions/:rev", async (req, res, params) => {
986
+ const r = await storage.getRevision(params.pid, params.rev);
987
+ if (!r) return writeError(res, 404, "revision not found");
988
+ writeJson(res, 200, r);
989
+ });
990
+
991
+ route("POST", "/api/projects/:pid/revisions/:rev/restore", async (req, res, params) => {
992
+ const actor = actorFromReq(req);
993
+ try {
994
+ const result = await storage.restoreRevision(params.pid, params.rev, { actor });
995
+ writeJson(res, 200, {
996
+ revision: result.revision,
997
+ savedAt: result.savedAt,
998
+ snapshot: result.snapshot
999
+ });
1000
+ } catch (err) {
1001
+ if (/not found/i.test(err.message || "")) return writeError(res, 404, err.message);
1002
+ throw err;
1003
+ }
1004
+ });
1005
+
1006
+ // -------------------- ticket status / DoR / DoD --------------------------
1007
+ route("POST", "/api/projects/:pid/tickets/:tid/status", async (req, res, params) => {
1008
+ const body = await readJsonBody(req);
1009
+ const actor = actorFromReq(req);
1010
+ const originId = originIdFromReq(req);
1011
+ const newStatus = body.status;
1012
+ const result = await persistChange(storage, params.pid, "ticket_change_status",
1013
+ (cur) => {
1014
+ const t = findEntity(cur, "tickets", params.tid);
1015
+ if (!t) throw Object.assign(new Error("ticket not found"), { statusCode: 404 });
1016
+ validation.changeStatusTransition(t, newStatus, cur.project);
1017
+ return core.ops.changeStatus(cur, params.tid, newStatus, actor);
1018
+ },
1019
+ { actor, originId });
1020
+ writeJson(res, 200, {
1021
+ revision: result.revision, savedAt: result.savedAt,
1022
+ ticket: findEntity(result.snapshot, "tickets", params.tid)
1023
+ });
1024
+ });
1025
+
1026
+ function dorDodHandler(kind, action) {
1027
+ const op = "ticket_" + kind.toLowerCase() + "_" + action;
1028
+ return async (req, res, params) => {
1029
+ const actor = actorFromReq(req);
1030
+ const originId = originIdFromReq(req);
1031
+ const opName = (kind === "DoR")
1032
+ ? (action === "check" ? "checkDorItem" : "uncheckDorItem")
1033
+ : (action === "check" ? "checkDodItem" : "uncheckDodItem");
1034
+ const result = await persistChange(storage, params.pid, op,
1035
+ (cur) => core.ops[opName](cur, params.tid, params.itemId, actor),
1036
+ { actor, originId });
1037
+ writeJson(res, 200, {
1038
+ revision: result.revision, savedAt: result.savedAt,
1039
+ ticket: findEntity(result.snapshot, "tickets", params.tid)
1040
+ });
1041
+ };
1042
+ }
1043
+
1044
+ route("POST", "/api/projects/:pid/tickets/:tid/dor/:itemId/check", dorDodHandler("DoR", "check"));
1045
+ route("POST", "/api/projects/:pid/tickets/:tid/dor/:itemId/uncheck", dorDodHandler("DoR", "uncheck"));
1046
+ route("POST", "/api/projects/:pid/tickets/:tid/dod/:itemId/check", dorDodHandler("DoD", "check"));
1047
+ route("POST", "/api/projects/:pid/tickets/:tid/dod/:itemId/uncheck", dorDodHandler("DoD", "uncheck"));
1048
+
1049
+ // -------------------- releases -------------------------------------------
1050
+ route("GET", "/api/projects/:pid/releases", async (req, res, params) => {
1051
+ const snap = await storage.loadProject(params.pid);
1052
+ if (!snap) return writeError(res, 404, "project not found");
1053
+ writeJson(res, 200, snap.releases.filter(r => !r.isDeleted));
1054
+ });
1055
+
1056
+ route("POST", "/api/projects/:pid/releases", async (req, res, params) => {
1057
+ const body = await readJsonBody(req);
1058
+ validation.releaseInput(body);
1059
+ const actor = actorFromReq(req);
1060
+ const originId = originIdFromReq(req);
1061
+ const result = await persistChange(storage, params.pid, "release_create",
1062
+ (cur) => core.ops.createRelease(cur, body, actor),
1063
+ { actor, originId });
1064
+ const release = result.snapshot.releases[result.snapshot.releases.length - 1];
1065
+ writeJson(res, 201, { revision: result.revision, savedAt: result.savedAt, release });
1066
+ });
1067
+
1068
+ route("PUT", "/api/projects/:pid/releases/:rid", async (req, res, params) => {
1069
+ const body = await readJsonBody(req);
1070
+ const actor = actorFromReq(req);
1071
+ const originId = originIdFromReq(req);
1072
+ const result = await persistChange(storage, params.pid, "release_update",
1073
+ (cur) => {
1074
+ validation.releaseInput(Object.assign({ name: "x" }, body));
1075
+ return core.ops.updateRelease(cur, params.rid, body, actor);
1076
+ },
1077
+ { actor, originId });
1078
+ writeJson(res, 200, {
1079
+ revision: result.revision, savedAt: result.savedAt,
1080
+ release: findEntity(result.snapshot, "releases", params.rid)
1081
+ });
1082
+ });
1083
+
1084
+ route("DELETE", "/api/projects/:pid/releases/:rid", async (req, res, params) => {
1085
+ const actor = actorFromReq(req);
1086
+ const originId = originIdFromReq(req);
1087
+ await persistChange(storage, params.pid, "release_delete",
1088
+ (cur) => {
1089
+ validation.releaseDelete(cur, params.rid); // SM-255: 422 on last release
1090
+ return core.ops.softDeleteRelease(cur, params.rid, actor);
1091
+ },
1092
+ { actor, originId });
1093
+ writeStatus(res, 204);
1094
+ });
1095
+
1096
+ route("POST", "/api/projects/:pid/releases/reorder", async (req, res, params) => {
1097
+ const body = await readJsonBody(req);
1098
+ if (!Array.isArray(body.orderedIds)) {
1099
+ return writeError(res, 400, "body.orderedIds must be an array");
1100
+ }
1101
+ const actor = actorFromReq(req);
1102
+ const originId = originIdFromReq(req);
1103
+ const result = await persistChange(storage, params.pid, "release_reorder",
1104
+ (cur) => core.ops.reorderReleases(cur, body.orderedIds, actor),
1105
+ { actor, originId });
1106
+ writeJson(res, 200, {
1107
+ revision: result.revision, savedAt: result.savedAt,
1108
+ releases: result.snapshot.releases.filter(r => !r.isDeleted)
1109
+ });
1110
+ });
1111
+
1112
+ // -------------------- process-steps --------------------------------------
1113
+ route("GET", "/api/projects/:pid/process-steps", async (req, res, params) => {
1114
+ const snap = await storage.loadProject(params.pid);
1115
+ if (!snap) return writeError(res, 404, "project not found");
1116
+ writeJson(res, 200, snap.processSteps.filter(p => !p.isDeleted));
1117
+ });
1118
+
1119
+ route("POST", "/api/projects/:pid/process-steps", async (req, res, params) => {
1120
+ const body = await readJsonBody(req);
1121
+ validation.processStepInput(body);
1122
+ const actor = actorFromReq(req);
1123
+ const originId = originIdFromReq(req);
1124
+ const result = await persistChange(storage, params.pid, "process_step_create",
1125
+ (cur) => core.ops.createProcessStep(cur, body, actor),
1126
+ { actor, originId });
1127
+ const processStep = result.snapshot.processSteps[result.snapshot.processSteps.length - 1];
1128
+ writeJson(res, 201, { revision: result.revision, savedAt: result.savedAt, processStep });
1129
+ });
1130
+
1131
+ route("PUT", "/api/projects/:pid/process-steps/:sid", async (req, res, params) => {
1132
+ const body = await readJsonBody(req);
1133
+ const actor = actorFromReq(req);
1134
+ const originId = originIdFromReq(req);
1135
+ const result = await persistChange(storage, params.pid, "process_step_update",
1136
+ (cur) => {
1137
+ validation.processStepInput(Object.assign({ name: "x" }, body));
1138
+ return core.ops.updateProcessStep(cur, params.sid, body, actor);
1139
+ },
1140
+ { actor, originId });
1141
+ writeJson(res, 200, {
1142
+ revision: result.revision, savedAt: result.savedAt,
1143
+ processStep: findEntity(result.snapshot, "processSteps", params.sid)
1144
+ });
1145
+ });
1146
+
1147
+ route("DELETE", "/api/projects/:pid/process-steps/:sid", async (req, res, params) => {
1148
+ const actor = actorFromReq(req);
1149
+ const originId = originIdFromReq(req);
1150
+ await persistChange(storage, params.pid, "process_step_delete",
1151
+ (cur) => core.ops.softDeleteProcessStep(cur, params.sid, actor),
1152
+ { actor, originId });
1153
+ writeStatus(res, 204);
1154
+ });
1155
+
1156
+ route("POST", "/api/projects/:pid/process-steps/reorder", async (req, res, params) => {
1157
+ const body = await readJsonBody(req);
1158
+ if (!Array.isArray(body.orderedIds)) {
1159
+ return writeError(res, 400, "body.orderedIds must be an array");
1160
+ }
1161
+ const actor = actorFromReq(req);
1162
+ const originId = originIdFromReq(req);
1163
+ const result = await persistChange(storage, params.pid, "process_step_reorder",
1164
+ (cur) => core.ops.reorderProcessSteps(cur, body.orderedIds, actor),
1165
+ { actor, originId });
1166
+ writeJson(res, 200, {
1167
+ revision: result.revision, savedAt: result.savedAt,
1168
+ processSteps: result.snapshot.processSteps.filter(p => !p.isDeleted)
1169
+ });
1170
+ });
1171
+
1172
+ // -------------------- attachments (SM-183) --------------------------------
1173
+ // Binary reference docs (PRDs, designs). Files live on disk next to the DB;
1174
+ // the row carries metadata + path. Upload = raw body + X-Filename header +
1175
+ // Content-Type mime + optional ?ticketId. Browser dropzone (S3) posts the
1176
+ // File blob directly; the MCP binary seam (S2) is a separate surface.
1177
+ route("POST", "/api/projects/:pid/attachments", async (req, res, params) => {
1178
+ let url; try { url = new URL(req.url, "http://localhost"); } catch (_) { url = null; }
1179
+ const ticketId = url ? url.searchParams.get("ticketId") : null;
1180
+ const fnHeader = req.headers["x-filename"];
1181
+ let filename = "file";
1182
+ if (typeof fnHeader === "string" && fnHeader.length) {
1183
+ try { filename = decodeURIComponent(fnHeader); } catch (_) { filename = fnHeader; }
1184
+ }
1185
+ const mimeType = (typeof req.headers["content-type"] === "string" && req.headers["content-type"])
1186
+ || "application/octet-stream";
1187
+ // Single source for the cap (shared with the storage guard) — reject the
1188
+ // over-size body at read time (413) before buffering it all.
1189
+ const maxBytes = (storage.constructor.ATTACHMENT_LIMITS || {}).MAX_BYTES;
1190
+ const buffer = await readRawBody(req, maxBytes);
1191
+ const meta = await storage.addAttachment(params.pid, { ticketId, filename, mimeType, buffer }, actorFromReq(req));
1192
+ writeJson(res, 201, { attachment: meta });
1193
+ });
1194
+
1195
+ route("GET", "/api/projects/:pid/attachments", async (req, res, params) => {
1196
+ let url; try { url = new URL(req.url, "http://localhost"); } catch (_) { url = null; }
1197
+ const ticketId = url ? url.searchParams.get("ticketId") : null;
1198
+ const list = await storage.listAttachments(params.pid, ticketId != null ? { ticketId } : {});
1199
+ writeJson(res, 200, { attachments: list });
1200
+ });
1201
+
1202
+ route("GET", "/api/projects/:pid/attachments/:aid", async (req, res, params) => {
1203
+ const meta = await storage.getAttachment(params.aid);
1204
+ if (!meta || meta.projectId !== params.pid) return writeError(res, 404, "attachment not found");
1205
+ if (!fs.existsSync(meta.absPath)) return writeError(res, 404, "attachment file missing");
1206
+ res.setHeader("Content-Type", meta.mimeType);
1207
+ res.setHeader("Content-Length", String(meta.size));
1208
+ res.setHeader("Content-Disposition", 'attachment; filename="' + meta.filename.replace(/"/g, "") + '"');
1209
+ res.setHeader("Cache-Control", "no-store");
1210
+ res.statusCode = 200;
1211
+ const stream = fs.createReadStream(meta.absPath);
1212
+ stream.on("error", () => {
1213
+ if (res.headersSent) { try { res.destroy(); } catch (_) { /* ignore */ } }
1214
+ else { try { writeError(res, 500, "internal error"); } catch (_) { /* ignore */ } }
1215
+ });
1216
+ stream.pipe(res);
1217
+ });
1218
+
1219
+ route("DELETE", "/api/projects/:pid/attachments/:aid", async (req, res, params) => {
1220
+ const meta = await storage.getAttachment(params.aid);
1221
+ if (!meta || meta.projectId !== params.pid) return writeError(res, 404, "attachment not found");
1222
+ await storage.removeAttachment(params.aid, actorFromReq(req));
1223
+ writeStatus(res, 204);
1224
+ });
1225
+
1226
+ // SM-203 R-7: convert an attachment to Markdown + pre-slice it, server-side.
1227
+ // Powers the Requirements reconciliation view's source pane (the browser
1228
+ // can't run the pdf2json/fflate ingestion). Returns {format, markdown,
1229
+ // sections:[{sectionPath, level, heading, body, charStart, charEnd}]}.
1230
+ route("GET", "/api/projects/:pid/attachments/:aid/ingest", async (req, res, params) => {
1231
+ const meta = await storage.getAttachment(params.aid);
1232
+ if (!meta || meta.projectId !== params.pid) return writeError(res, 404, "attachment not found");
1233
+ if (!fs.existsSync(meta.absPath)) return writeError(res, 404, "attachment file missing");
1234
+ let converted;
1235
+ try {
1236
+ const buffer = fs.readFileSync(meta.absPath);
1237
+ converted = await ingest.ingestToMarkdown(buffer, { filename: meta.filename, mimeType: meta.mimeType });
1238
+ } catch (e) {
1239
+ return writeError(res, e.statusCode || 500, e.message || "ingest failed");
1240
+ }
1241
+ const sections = slice.sliceMarkdown(converted.markdown);
1242
+ writeJson(res, 200, { format: converted.format, markdown: converted.markdown, sections: sections });
1243
+ });
1244
+
1245
+ // -------------------- dispatcher -----------------------------------------
1246
+ return async function dispatch(req, res) {
1247
+ // SM-211: CORS reflection + nosniff first — every response path (routes,
1248
+ // static, errors) inherits the headers set here. Foreign browser origins
1249
+ // get no ACAO (reads stay blocked) and no mutations at all.
1250
+ const sec = applyRequestSecurity(req, res);
1251
+ if (req.method === "OPTIONS") return writeStatus(res, 204);
1252
+ if (sec.foreign && req.method !== "GET" && req.method !== "HEAD") {
1253
+ return writeError(res, 403, "cross-origin request denied");
1254
+ }
1255
+ let url;
1256
+ try { url = new URL(req.url, "http://localhost"); }
1257
+ catch (e) { return writeError(res, 400, "invalid url"); }
1258
+
1259
+ // Internal MCP-bridge endpoints: loopback-only + no cross-origin browser
1260
+ // caller may forge change/switch broadcasts. (SM-147)
1261
+ if (url.pathname.startsWith("/api/internal/")) {
1262
+ if (!isLoopbackRemote(req) || !originIsLocal(req.headers.origin)) {
1263
+ return writeError(res, 403, "forbidden");
1264
+ }
1265
+ }
1266
+
1267
+ // Static-file branch (only GET, only safe paths under frontend/).
1268
+ if (req.method === "GET" && !url.pathname.startsWith("/api/")) {
1269
+ if (serveStatic(res, url.pathname)) return;
1270
+ }
1271
+
1272
+ for (const r of routes) {
1273
+ if (r.method !== req.method) continue;
1274
+ const m = matchPath(r.pattern, url.pathname);
1275
+ if (m.match) {
1276
+ try {
1277
+ await r.handler(req, res, m.params);
1278
+ } catch (err) {
1279
+ const status = err.statusCode || 500;
1280
+ const extras = {};
1281
+ if (err.kind) extras.kind = err.kind;
1282
+ if (err.missing) extras.missing = err.missing;
1283
+ if (status >= 500) {
1284
+ // Never leak raw err.message for server-side faults — it can carry
1285
+ // SQL fragments, dataDir paths or stack detail. Log it, send generic.
1286
+ console.error("[storymap] " + req.method + " " + url.pathname + " → 500:", err);
1287
+ writeError(res, status, "internal error", extras);
1288
+ } else {
1289
+ // Explicit 4xx are validation/ops errors with curated messages —
1290
+ // those are meant to reach the client.
1291
+ writeError(res, status, err.message || "error", extras);
1292
+ }
1293
+ }
1294
+ return;
1295
+ }
1296
+ }
1297
+ writeError(res, 404, "not found: " + req.method + " " + url.pathname);
1298
+ };
1299
+ }
1300
+
1301
+ // ---------------------------------------------------------------------------
1302
+ // Static-file handler — frontend/storymap.html at /, /js/*, /css/* …
1303
+ // ---------------------------------------------------------------------------
1304
+
1305
+ function serveStatic(res, pathname) {
1306
+ // SM-117 (Epic B / B2): the bookmarkable full-page editor lives at /editor;
1307
+ // map it to the ticket-editor.html template (query string is ignored here —
1308
+ // serveStatic only ever sees the pathname).
1309
+ const rel = pathname === "/" ? "/storymap.html"
1310
+ : pathname === "/editor" ? "/ticket-editor.html"
1311
+ : pathname;
1312
+ // Refuse anything with ".." in it to avoid escapes.
1313
+ if (rel.indexOf("..") >= 0) return false;
1314
+ const filePath = path.join(FRONTEND_DIR, rel);
1315
+ // Ensure final resolved path stays inside FRONTEND_DIR.
1316
+ const resolved = path.resolve(filePath);
1317
+ if (!resolved.startsWith(FRONTEND_DIR + path.sep) && resolved !== FRONTEND_DIR) return false;
1318
+ // SM-213: path.resolve does NOT dereference symlinks — a link placed under
1319
+ // frontend/ could point anywhere on disk. Realpath the target and require
1320
+ // it to live under frontend/ OR under the repo's shared/ dir (frontend/js/
1321
+ // core.js and core/graph.js are legit symlinks into shared/, SM-139).
1322
+ let real;
1323
+ try { real = fs.realpathSync(resolved); } catch (_) { return false; }
1324
+ const inside = (p, root) => root && (p === root || p.startsWith(root + path.sep));
1325
+ if (!inside(real, REAL_FRONTEND_DIR) && !inside(real, REAL_SHARED_DIR)) return false;
1326
+ let stat;
1327
+ try { stat = fs.statSync(real); } catch (_) { return false; }
1328
+ if (!stat.isFile()) return false;
1329
+ const ext = path.extname(resolved).toLowerCase();
1330
+ res.setHeader("Content-Type", MIME[ext] || "application/octet-stream");
1331
+ // SM-211: defense-in-depth CSP on the HTML documents (scripts/styles are
1332
+ // classical <script src>/<link> tags from 'self'; no inline scripts exist).
1333
+ // X-Frame-Options doubles the frame-ancestors directive for older engines.
1334
+ if (ext === ".html") {
1335
+ res.setHeader("Content-Security-Policy", SECURITY_HEADERS.CSP);
1336
+ res.setHeader("X-Frame-Options", SECURITY_HEADERS.FRAME_OPTIONS);
1337
+ }
1338
+ // `no-store` (statt no-cache) verbietet Browser-Caching komplett.
1339
+ // Während der Entwicklung kritisch — sonst sieht der User nach
1340
+ // Server-Restart weiterhin alte JS/HTML/CSS aus dem Browser-Cache.
1341
+ res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
1342
+ res.setHeader("Pragma", "no-cache");
1343
+ res.setHeader("Expires", "0");
1344
+ res.statusCode = 200;
1345
+ const stream = fs.createReadStream(resolved);
1346
+ stream.on("error", (err) => {
1347
+ // Async I/O failure (EMFILE, file vanished after statSync, permission
1348
+ // race). Without this listener the 'error' event is unhandled and takes
1349
+ // down the whole process. If headers were already flushed mid-stream we
1350
+ // can only tear down the socket; otherwise we can still send a 500.
1351
+ if (res.headersSent) {
1352
+ try { res.destroy(); } catch (_) { /* ignore */ }
1353
+ } else {
1354
+ try { writeError(res, 500, "internal error"); } catch (_) { /* ignore */ }
1355
+ }
1356
+ });
1357
+ stream.pipe(res);
1358
+ return true;
1359
+ }
1360
+
1361
+ // ---------------------------------------------------------------------------
1362
+ // WebSocket layer
1363
+ // ---------------------------------------------------------------------------
1364
+
1365
+ /**
1366
+ * Per-client state:
1367
+ * { ws, subscriptions: Set<projectId>, originId: string|null }
1368
+ *
1369
+ * Messages from client:
1370
+ * { type: "subscribe", projectId, originId? }
1371
+ * { type: "unsubscribe", projectId }
1372
+ * { type: "ping" }
1373
+ *
1374
+ * Messages to client:
1375
+ * { type: "hello", protocolVersion: 1 }
1376
+ * { type: "subscribed", projectId }
1377
+ * { type: "unsubscribed", projectId }
1378
+ * { type: "pong" }
1379
+ * { type: "change", projectId, revision, savedAt, op, actor }
1380
+ */
1381
+ // WebSocket connection health. A client that vanishes without a TCP close
1382
+ // (laptop sleep, network drop, killed tab) never fires 'close'/'error', so its
1383
+ // entry — and its subscription Set — would leak forever and be iterated on
1384
+ // every broadcast. A periodic ping/pong sweep terminates unresponsive sockets.
1385
+ const WS_HEARTBEAT = {
1386
+ INTERVAL_MS: 30 * 1000, // ping cadence; a client with no pong since the last tick is dead
1387
+ OPEN: 1 // WebSocket.readyState === OPEN
1388
+ };
1389
+
1390
+ function setupWebSocket(httpServer) {
1391
+ const wss = new WebSocketServer({ noServer: true });
1392
+ const clients = new Map(); // ws → { ws, subscriptions: Set, originId }
1393
+
1394
+ // Heartbeat sweep: terminate sockets that didn't pong since the last tick.
1395
+ const heartbeat = setInterval(() => {
1396
+ for (const state of clients.values()) {
1397
+ const ws = state.ws;
1398
+ if (ws.isAlive === false) {
1399
+ try { ws.terminate(); } catch (_) { /* ignore */ }
1400
+ clients.delete(ws);
1401
+ continue;
1402
+ }
1403
+ ws.isAlive = false;
1404
+ try { ws.ping(); } catch (_) { /* ignore */ }
1405
+ }
1406
+ }, WS_HEARTBEAT.INTERVAL_MS);
1407
+ if (typeof heartbeat.unref === "function") heartbeat.unref();
1408
+
1409
+ httpServer.on("upgrade", (req, socket, head) => {
1410
+ let url;
1411
+ try { url = new URL(req.url, "http://localhost"); }
1412
+ catch (e) { socket.destroy(); return; }
1413
+ if (url.pathname !== "/ws") { socket.destroy(); return; }
1414
+ // Reject cross-site WebSocket hijacking: only same-machine/loopback (or
1415
+ // header-less non-browser) clients may upgrade. (SM-147)
1416
+ if (!originIsLocal(req.headers.origin)) { socket.destroy(); return; }
1417
+ wss.handleUpgrade(req, socket, head, (ws) => {
1418
+ wss.emit("connection", ws, req);
1419
+ });
1420
+ });
1421
+
1422
+ wss.on("connection", (ws) => {
1423
+ const state = { ws, subscriptions: new Set(), originId: null };
1424
+ clients.set(ws, state);
1425
+ ws.isAlive = true;
1426
+ ws.on("pong", () => { ws.isAlive = true; });
1427
+
1428
+ function reply(obj) {
1429
+ try { ws.send(JSON.stringify(obj)); } catch (_) { /* ignore */ }
1430
+ }
1431
+
1432
+ reply({ type: "hello", protocolVersion: 1 });
1433
+
1434
+ ws.on("message", (raw) => {
1435
+ let msg;
1436
+ try { msg = JSON.parse(raw.toString()); }
1437
+ catch (e) { reply({ type: "error", message: "invalid JSON" }); return; }
1438
+ switch (msg.type) {
1439
+ case "subscribe":
1440
+ if (typeof msg.projectId === "string") {
1441
+ state.subscriptions.add(msg.projectId);
1442
+ if (typeof msg.originId === "string") state.originId = msg.originId;
1443
+ reply({ type: "subscribed", projectId: msg.projectId });
1444
+ } else {
1445
+ reply({ type: "error", message: "subscribe requires projectId" });
1446
+ }
1447
+ break;
1448
+ case "unsubscribe":
1449
+ if (typeof msg.projectId === "string") {
1450
+ state.subscriptions.delete(msg.projectId);
1451
+ reply({ type: "unsubscribed", projectId: msg.projectId });
1452
+ }
1453
+ break;
1454
+ case "ping":
1455
+ reply({ type: "pong" });
1456
+ break;
1457
+ case "switch_response":
1458
+ // E20.A: Browser's verdict to a switch_request. Cache + emit
1459
+ // (the cache lets the MCP long-poll resolve even when the
1460
+ // browser answers BEFORE the poll registers a bus listener).
1461
+ if (typeof msg.requestId === "string") {
1462
+ cacheSwitchResponse(msg.requestId, !!msg.accepted);
1463
+ bus.emit("switch_response", { requestId: msg.requestId, accepted: !!msg.accepted });
1464
+ }
1465
+ break;
1466
+ default:
1467
+ reply({ type: "error", message: "unknown message type: " + msg.type });
1468
+ }
1469
+ });
1470
+
1471
+ ws.on("close", () => { clients.delete(ws); });
1472
+ ws.on("error", () => { clients.delete(ws); });
1473
+ });
1474
+
1475
+ // Subscribe once to the bus; broadcast to matching clients.
1476
+ function broadcast(ev) {
1477
+ for (const state of clients.values()) {
1478
+ if (state.ws.readyState !== WS_HEARTBEAT.OPEN) continue;
1479
+ if (!state.subscriptions.has(ev.projectId)) continue;
1480
+ // Origin-Id echo filter: skip clients whose registered origin matches.
1481
+ if (state.originId && ev.originId && state.originId === ev.originId) continue;
1482
+ try {
1483
+ state.ws.send(JSON.stringify({
1484
+ type: "change",
1485
+ projectId: ev.projectId,
1486
+ revision: ev.revision,
1487
+ savedAt: ev.savedAt,
1488
+ op: ev.op,
1489
+ actor: ev.actor
1490
+ }));
1491
+ } catch (_) { /* ignore */ }
1492
+ }
1493
+ }
1494
+ bus.on("change", broadcast);
1495
+
1496
+ // E20.A: switch_request broadcasts to ALL connected clients (not filtered
1497
+ // by project subscription — the user might be on a different project but
1498
+ // still gets asked whether to switch).
1499
+ function broadcastSwitchRequest(ev) {
1500
+ for (const state of clients.values()) {
1501
+ try {
1502
+ state.ws.send(JSON.stringify({
1503
+ type: "switch_request",
1504
+ workspace: ev.workspace,
1505
+ reason: ev.reason,
1506
+ requestId: ev.requestId
1507
+ }));
1508
+ } catch (_) { /* ignore */ }
1509
+ }
1510
+ }
1511
+ bus.on("switch_request", broadcastSwitchRequest);
1512
+
1513
+ // cmapper-Pattern: separately exposed detach so multiple-server-instance
1514
+ // test runs (or restart cycles) don't accumulate bus listeners.
1515
+ function detachBus() {
1516
+ bus.off("change", broadcast);
1517
+ bus.off("switch_request", broadcastSwitchRequest);
1518
+ }
1519
+
1520
+ async function shutdown() {
1521
+ clearInterval(heartbeat);
1522
+ detachBus();
1523
+ for (const state of clients.values()) {
1524
+ try { state.ws.terminate(); } catch (_) { /* ignore */ }
1525
+ }
1526
+ clients.clear();
1527
+ await new Promise((resolve) => wss.close(() => resolve()));
1528
+ }
1529
+
1530
+ return { wss, shutdown, detachBus };
1531
+ }
1532
+
1533
+ // ---------------------------------------------------------------------------
1534
+ // Server lifecycle
1535
+ // ---------------------------------------------------------------------------
1536
+
1537
+ async function startServer(opts) {
1538
+ opts = opts || {};
1539
+ const dataDir = opts.dataDir;
1540
+ if (!dataDir) throw new Error("startServer: dataDir required");
1541
+ const port = typeof opts.port === "number" ? opts.port : 8770;
1542
+ const host = opts.host || "localhost";
1543
+
1544
+ const storage = new Storage(dataDir);
1545
+ await storage.init();
1546
+ const dispatch = buildRouter(storage);
1547
+
1548
+ const sockets = new Set();
1549
+ const httpServer = http.createServer((req, res) => {
1550
+ dispatch(req, res).catch(err => {
1551
+ // Last-resort guard: log full detail server-side, send a generic 500.
1552
+ try { console.error("[storymap] unhandled dispatch error:", err); } catch (_) { /* ignore */ }
1553
+ try { writeError(res, 500, "internal error"); } catch (_) { /* ignore */ }
1554
+ });
1555
+ });
1556
+ // SM-244: Node's default keepAliveTimeout is 5s — a browser that reuses an
1557
+ // idle keep-alive connection (e.g. after sitting in a modal for a few seconds)
1558
+ // for a snapshot PUT can hit a connection the server JUST closed, producing
1559
+ // an aborted request (ECONNRESET) and a lost save. Widen the keep-alive
1560
+ // window so the browser's persistent connection stays valid between actions.
1561
+ // headersTimeout must exceed keepAliveTimeout (Node ordering requirement).
1562
+ httpServer.keepAliveTimeout = 75 * 1000;
1563
+ httpServer.headersTimeout = 80 * 1000;
1564
+ httpServer.on("connection", (socket) => {
1565
+ sockets.add(socket);
1566
+ socket.on("close", () => sockets.delete(socket));
1567
+ });
1568
+
1569
+ const ws = setupWebSocket(httpServer);
1570
+
1571
+ await new Promise((resolve, reject) => {
1572
+ httpServer.once("error", reject);
1573
+ httpServer.listen(port, host, () => { httpServer.off("error", reject); resolve(); });
1574
+ });
1575
+
1576
+ async function shutdown() {
1577
+ // 1. tear down WS clients + listener first so they unblock httpServer.close()
1578
+ await ws.shutdown();
1579
+ // 2. stop accepting new connections
1580
+ await new Promise((resolve) => httpServer.close(() => resolve()));
1581
+ // 3. destroy any lingering keep-alive sockets
1582
+ for (const s of sockets) { try { s.destroy(); } catch (_) { /* ignore */ } }
1583
+ sockets.clear();
1584
+ // 4. close storage
1585
+ storage.close();
1586
+ }
1587
+
1588
+ return { httpServer, wss: ws.wss, storage, shutdown };
1589
+ }
1590
+
1591
+ module.exports = { startServer };
1592
+ // SM-213: switch-cache constants + test hooks (the map is module-global; the
1593
+ // hardening tests drive bound/TTL/sweep directly against it).
1594
+ module.exports.SWITCH_CACHE = SWITCH_CACHE;
1595
+ module.exports._switchCacheHooks = {
1596
+ cacheSwitchResponse,
1597
+ consumeSwitchResponse,
1598
+ map: _switchResponseCache
1599
+ };