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.
- package/ARCHITECTURE.md +334 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +239 -0
- package/frontend/css/storymap.css +4637 -0
- package/frontend/js/adapters.js +423 -0
- package/frontend/js/card-animate.js +384 -0
- package/frontend/js/core/graph.js +825 -0
- package/frontend/js/core.js +3908 -0
- package/frontend/js/dialog-ticket-import.js +506 -0
- package/frontend/js/dnd.js +322 -0
- package/frontend/js/filter.js +215 -0
- package/frontend/js/main.js +2499 -0
- package/frontend/js/project-io.js +109 -0
- package/frontend/js/query-autocomplete.js +196 -0
- package/frontend/js/query-engine.js +339 -0
- package/frontend/js/query.js +280 -0
- package/frontend/js/renderer-card.js +639 -0
- package/frontend/js/renderer-dependencies.js +974 -0
- package/frontend/js/renderer-kanban.js +505 -0
- package/frontend/js/renderer-storymap.js +2530 -0
- package/frontend/js/renderer-ticket-editor.js +455 -0
- package/frontend/js/renderer-ticket-modal.js +758 -0
- package/frontend/js/save-pipeline.js +170 -0
- package/frontend/js/smartbar-autocomplete.js +162 -0
- package/frontend/js/store.js +197 -0
- package/frontend/js/ticket-editor-boot.js +24 -0
- package/frontend/js/ticket-form.js +2095 -0
- package/frontend/js/ticket-import.js +477 -0
- package/frontend/js/ui-shell.js +441 -0
- package/frontend/js/view-process-steps.js +233 -0
- package/frontend/js/view-requirements.js +361 -0
- package/frontend/js/view-settings.js +1864 -0
- package/frontend/js/view-table.js +659 -0
- package/frontend/js/wheel-pan.js +65 -0
- package/frontend/storymap.html +87 -0
- package/frontend/ticket-editor.html +29 -0
- package/package.json +76 -0
- package/server/bus.js +16 -0
- package/server/core/graph.js +10 -0
- package/server/core.js +10 -0
- package/server/identity.js +134 -0
- package/server/index.js +283 -0
- package/server/ingest.js +212 -0
- package/server/mcp.js +2510 -0
- package/server/server.js +1599 -0
- package/server/slice.js +103 -0
- package/server/storage.js +571 -0
- package/server/validation.js +225 -0
- package/shared/core/graph.js +825 -0
- package/shared/core.js +3908 -0
- package/shared/project-io.js +109 -0
- package/shared/query-autocomplete.js +196 -0
- package/shared/query-engine.js +339 -0
- package/shared/query.js +280 -0
- package/shared/ticket-import.js +477 -0
- package/skill/SKILL.md +458 -0
- package/skill/reference/anatomy.md +196 -0
- package/skill/reference/spec-evolution.md +52 -0
- package/skill/reference/tools.md +156 -0
- package/skill/reference/workflows.md +203 -0
package/server/server.js
ADDED
|
@@ -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
|
+
};
|