avtc-pi-featyard 1.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 (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +194 -0
  3. package/agents/fy-design-reviewer.md +77 -0
  4. package/agents/fy-feature-verifier.md +24 -0
  5. package/agents/fy-general-reviewer.md +81 -0
  6. package/agents/fy-guidelines-reviewer.md +46 -0
  7. package/agents/fy-implementer.md +20 -0
  8. package/agents/fy-performance-reviewer.md +46 -0
  9. package/agents/fy-plan-reviewer.md +74 -0
  10. package/agents/fy-plan-verifier.md +44 -0
  11. package/agents/fy-quality-reviewer.md +50 -0
  12. package/agents/fy-requirements-reviewer.md +46 -0
  13. package/agents/fy-researcher.md +6 -0
  14. package/agents/fy-security-reviewer.md +46 -0
  15. package/agents/fy-task-verifier.md +42 -0
  16. package/agents/fy-testing-reviewer.md +60 -0
  17. package/assets/images/kanban-board.png +0 -0
  18. package/assets/images/settings-dropdown.png +0 -0
  19. package/assets/images/settings.png +0 -0
  20. package/assets/images/widget.png +0 -0
  21. package/docs/CONFIGURATION.md +266 -0
  22. package/index.ts +8 -0
  23. package/package.json +85 -0
  24. package/skills/fy-design/SKILL.md +108 -0
  25. package/skills/fy-design-review/SKILL.md +45 -0
  26. package/skills/fy-finish/SKILL.md +28 -0
  27. package/skills/fy-implement/SKILL.md +36 -0
  28. package/skills/fy-plan/SKILL.md +77 -0
  29. package/skills/fy-plan-review/SKILL.md +45 -0
  30. package/skills/fy-research/SKILL.md +38 -0
  31. package/skills/fy-review/SKILL.md +88 -0
  32. package/skills/fy-verify/SKILL.md +23 -0
  33. package/src/commands/auto-agent-commands.ts +748 -0
  34. package/src/commands/kanban-commands.ts +278 -0
  35. package/src/commands/workflow-commands.ts +531 -0
  36. package/src/compaction/compact-handler.ts +371 -0
  37. package/src/compaction/compact-message.ts +52 -0
  38. package/src/compaction/compact-trigger.ts +121 -0
  39. package/src/compaction/safe-editor-write.ts +18 -0
  40. package/src/events/agent/agent-end.ts +19 -0
  41. package/src/events/agent/agent-settled.ts +20 -0
  42. package/src/events/agent/agent-start.ts +18 -0
  43. package/src/events/agent/turn-end.ts +20 -0
  44. package/src/events/agent/turn-start.ts +19 -0
  45. package/src/events/event-deps.ts +33 -0
  46. package/src/events/index.ts +91 -0
  47. package/src/events/input/input.ts +143 -0
  48. package/src/events/session/context.ts +68 -0
  49. package/src/events/session/model-select.ts +20 -0
  50. package/src/events/session/session-compact.ts +20 -0
  51. package/src/events/session/session-shutdown.ts +26 -0
  52. package/src/events/session/session-start.ts +45 -0
  53. package/src/events/session/session-tree.ts +17 -0
  54. package/src/events/tool/tool-call.ts +58 -0
  55. package/src/events/tool/tool-result.ts +54 -0
  56. package/src/git/git-queries.ts +154 -0
  57. package/src/git/resolve-base-branch.ts +173 -0
  58. package/src/git/worktrees/worktree-helpers.ts +142 -0
  59. package/src/git/worktrees/worktree-interception.ts +66 -0
  60. package/src/git/worktrees/worktree-lifecycle.ts +380 -0
  61. package/src/guardrails/completion-gating.ts +63 -0
  62. package/src/guardrails/file-classifier.ts +461 -0
  63. package/src/guardrails/force-add-guard.ts +312 -0
  64. package/src/guardrails/guardrail-tracker.ts +73 -0
  65. package/src/guardrails/guardrail-violations.ts +47 -0
  66. package/src/guardrails/guardrails-engine.ts +663 -0
  67. package/src/guardrails/session-guardrails.ts +75 -0
  68. package/src/guardrails/shell-decompose.ts +285 -0
  69. package/src/guardrails/tdd-enforcement.ts +122 -0
  70. package/src/guardrails/test-output.ts +272 -0
  71. package/src/index.ts +598 -0
  72. package/src/integrations/parallel-work-guardrail-integration.ts +29 -0
  73. package/src/integrations/subagent-integration.ts +166 -0
  74. package/src/integrations/todo-integration.ts +58 -0
  75. package/src/kanban/auto-agent/auto-agent-cleanup.ts +37 -0
  76. package/src/kanban/auto-agent/auto-agent-events.ts +142 -0
  77. package/src/kanban/auto-agent/auto-agent-grace-period.ts +83 -0
  78. package/src/kanban/auto-agent/auto-agent-lifecycle.ts +497 -0
  79. package/src/kanban/auto-agent/auto-agent-notify.ts +43 -0
  80. package/src/kanban/auto-agent/auto-agent-pause.ts +44 -0
  81. package/src/kanban/auto-agent/auto-agent-state-machine.ts +629 -0
  82. package/src/kanban/data/kanban-database.ts +851 -0
  83. package/src/kanban/data/kanban-db-schema.ts +137 -0
  84. package/src/kanban/data/kanban-detect-project.ts +43 -0
  85. package/src/kanban/data/kanban-move-feature.ts +43 -0
  86. package/src/kanban/data/kanban-types.ts +64 -0
  87. package/src/kanban/ensure-feature.ts +226 -0
  88. package/src/kanban/kanban-board-ui/app.js +1125 -0
  89. package/src/kanban/kanban-board-ui/csv-parser.d.ts +5 -0
  90. package/src/kanban/kanban-board-ui/csv-parser.js +80 -0
  91. package/src/kanban/kanban-board-ui/index.html +127 -0
  92. package/src/kanban/kanban-board-ui/style.css +820 -0
  93. package/src/kanban/kanban-bridge.ts +216 -0
  94. package/src/kanban/kanban-context.ts +43 -0
  95. package/src/kanban/kanban-events.ts +34 -0
  96. package/src/kanban/kanban-generate-title.ts +259 -0
  97. package/src/kanban/kanban-generate-topic.ts +170 -0
  98. package/src/kanban/kanban-operations.ts +170 -0
  99. package/src/kanban/kanban-server.ts +931 -0
  100. package/src/kanban/kanban-turn-handlers.ts +55 -0
  101. package/src/kanban/model-capture.ts +34 -0
  102. package/src/log.ts +47 -0
  103. package/src/phases/env-sync.ts +64 -0
  104. package/src/phases/execution-mode.ts +79 -0
  105. package/src/phases/implement-to-verify.ts +31 -0
  106. package/src/phases/phase-progression.ts +249 -0
  107. package/src/phases/phase-transitions.ts +226 -0
  108. package/src/phases/workflow-router.ts +98 -0
  109. package/src/prompts/finish-instructions.ts +143 -0
  110. package/src/prompts/skill-block-builder.ts +73 -0
  111. package/src/prompts/skill-expansion.ts +262 -0
  112. package/src/prompts/task-gate-skill.ts +83 -0
  113. package/src/prompts/template-engine.ts +433 -0
  114. package/src/prompts/text-blocks.ts +279 -0
  115. package/src/review/review-context.ts +56 -0
  116. package/src/review/review-counter.ts +66 -0
  117. package/src/review/review-empty-loop-tracking.ts +52 -0
  118. package/src/review/review-loops.ts +217 -0
  119. package/src/review/review-report.ts +101 -0
  120. package/src/settings/model-overrides.ts +269 -0
  121. package/src/settings/settings-schema.ts +458 -0
  122. package/src/settings/settings-types.ts +81 -0
  123. package/src/settings/settings-ui.ts +222 -0
  124. package/src/shared/errors.ts +24 -0
  125. package/src/shared/global.d.ts +22 -0
  126. package/src/shared/session-entries.ts +24 -0
  127. package/src/shared/types.ts +195 -0
  128. package/src/shared/workflow-refs.ts +253 -0
  129. package/src/shared/workflow-types.ts +180 -0
  130. package/src/snippets/vendored/subscribe-to-dialog-coordinator.ts +98 -0
  131. package/src/snippets/vendored/subscribe-to-notifications.ts +150 -0
  132. package/src/snippets/vendored/subscribe-to-parallel-work-guardrail.ts +35 -0
  133. package/src/snippets/vendored/subscribe-to-subagent.ts +67 -0
  134. package/src/snippets/vendored/subscribe-to-todo.ts +63 -0
  135. package/src/state/archive-artifacts.ts +602 -0
  136. package/src/state/artifact-junction.ts +462 -0
  137. package/src/state/artifact-paths.ts +230 -0
  138. package/src/state/feature-activation.ts +239 -0
  139. package/src/state/feature-management.ts +325 -0
  140. package/src/state/feature-record-store.ts +39 -0
  141. package/src/state/feature-session.ts +263 -0
  142. package/src/state/feature-state.ts +439 -0
  143. package/src/state/post-turn-dispatch.ts +106 -0
  144. package/src/state/session-lifecycle.ts +346 -0
  145. package/src/state/state-persistence.ts +122 -0
  146. package/src/state/worth-notes.ts +89 -0
  147. package/src/tools/add-to-backlog.ts +117 -0
  148. package/src/tools/phase-ready.ts +742 -0
  149. package/src/tools/task-ready-advance.ts +235 -0
  150. package/src/tools/text-result.ts +15 -0
  151. package/src/ui/featyard-widget.ts +250 -0
  152. package/src/ui/manage-features-dialog.ts +246 -0
  153. package/src/ui/select-dialog.ts +27 -0
@@ -0,0 +1,931 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ import { randomBytes, timingSafeEqual } from "node:crypto";
5
+ import { readFile, writeFile } from "node:fs/promises";
6
+ import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
7
+ import { extname, join, resolve } from "node:path";
8
+ import { log, NO_ERROR } from "../log.js";
9
+ import {
10
+ fifoPriority,
11
+ type KanbanDatabase,
12
+ NO_DONE_HIDE_AFTER_MS,
13
+ PRIORITY_SPACING,
14
+ topPriority,
15
+ } from "./data/kanban-database.js";
16
+ import { type Feature, isLane, LANE_ORDER, type Lane, type Project } from "./data/kanban-types.js";
17
+
18
+ /** Validate a position parameter ("top" | "bottom" | undefined).
19
+ * Returns an error string if invalid, or null if valid.
20
+ * When `required` is true, undefined is also invalid. */
21
+ /** Position is optional (not required) */
22
+ const OPTIONAL_POSITION = false;
23
+
24
+ function validatePosition(position: unknown, required: boolean): string | null {
25
+ if (position === undefined) return required ? "position is required" : null;
26
+ if (position !== "top" && position !== "bottom") return "position must be 'top' or 'bottom'";
27
+ return null;
28
+ }
29
+
30
+ const MIME_TYPES: Record<string, string> = {
31
+ ".html": "text/html",
32
+ ".css": "text/css",
33
+ ".js": "application/javascript",
34
+ ".json": "application/json",
35
+ ".png": "image/png",
36
+ };
37
+
38
+ /** Session identifier for all web UI operations (locks, moves, etc.). */
39
+ const WEB_UI_SESSION = "web-ui";
40
+
41
+ /** Timeout for each per-row LLM title generation call in the batch import endpoint. */
42
+ const PER_ROW_LLM_TIMEOUT_MS = 60_000;
43
+
44
+ /** Max characters sent to the LLM for title generation — longer descriptions are truncated. */
45
+ const MAX_LLM_DESCRIPTION_LENGTH = 2000;
46
+
47
+ /** Max request body size for the batch import endpoint (10 MB). */
48
+ const MAX_BATCH_BODY_SIZE = 10 * (1 << 20);
49
+
50
+ /** Max number of rows per batch import request. */
51
+ const MAX_BATCH_ROWS = 500;
52
+
53
+ const FEATURE_ID_RE = /^\/api\/features\/(\d+)$/;
54
+ const FEATURE_MOVE_RE = /^\/api\/features\/(\d+)\/move$/;
55
+ const FEATURE_RELEASE_RE = /^\/api\/features\/(\d+)\/release$/;
56
+ const FEATURE_LOCK_RE = /^\/api\/features\/(\d+)\/lock$/;
57
+ const FEATURE_HISTORY_RE = /^\/api\/features\/(\d+)\/history$/;
58
+ const BOARD_EXPORT_RE = /^\/api\/board\/(\d+)\/export$/;
59
+ const BOARD_RE = /^\/api\/board\/(\d+)$/;
60
+ const PROJECT_FEATURES_RE = /^\/api\/projects\/(\d+)\/features$/;
61
+
62
+ /** Per-endpoint rate limiting for expensive (LLM-invoking) routes. */
63
+ const RATE_LIMITS: Record<string, { maxRequests: number; windowMs: number }> = {
64
+ "/api/generate-title": { maxRequests: 10, windowMs: 60_000 }, // 10 title generations per minute
65
+ "/api/features/batch": { maxRequests: 5, windowMs: 60_000 }, // 5 batch imports per minute
66
+ };
67
+ const rateLimitBuckets = new Map<string, { count: number; resetAt: number }>();
68
+
69
+ function checkRateLimit(pathname: string): boolean {
70
+ const limit = RATE_LIMITS[pathname];
71
+ if (!limit) return true; // no limit configured
72
+ const now = Date.now();
73
+ let bucket = rateLimitBuckets.get(pathname);
74
+ if (!bucket || now >= bucket.resetAt) {
75
+ bucket = { count: 1, resetAt: now + limit.windowMs };
76
+ rateLimitBuckets.set(pathname, bucket);
77
+ return true;
78
+ }
79
+ if (bucket.count >= limit.maxRequests) return false;
80
+ bucket.count++;
81
+ return true;
82
+ }
83
+
84
+ /** Reset rate limit buckets (for testing). */
85
+ export function resetRateLimits(): void {
86
+ rateLimitBuckets.clear();
87
+ }
88
+
89
+ /*
90
+ * V1 REST API endpoints (implemented):
91
+ * GET /api/projects — List all projects
92
+ * POST /api/features — Create feature
93
+ * GET /api/features/:id — Get feature by ID
94
+ * DELETE /api/features/:id — Delete feature
95
+ * POST /api/features/reorder — Reorder features within a lane (locked features silently skipped)
96
+ * POST /api/features/:id/move — Move feature to lane
97
+ * GET /api/features/:id/history — Get feature history
98
+ * GET /api/board/:projectId — Get full board state
99
+ *
100
+ * V2 scope (not yet implemented):
101
+ * GET /api/tags — List tags
102
+ * POST /api/tags — Create tag
103
+ * GET /api/projects/:id — Get project by ID
104
+ * PUT /api/features/:id — Update feature (title, description, priority)
105
+ */
106
+
107
+ export interface ServerOptions {
108
+ doneHideAfterMs?: number | null;
109
+ generateTitle?: (description: string, signal?: AbortSignal) => Promise<string>;
110
+ /** Directory to persist server metadata (auth token). When provided, the auth token is written to `{dataDir}/auth_token.txt`. */
111
+ dataDir?: string;
112
+ }
113
+
114
+ /**
115
+ * Inject the auth token into HTML content so the SPA frontend can authenticate API calls.
116
+ */
117
+ function injectAuthToken(html: Buffer, token: string): Buffer {
118
+ return Buffer.from(
119
+ html
120
+ .toString("utf-8")
121
+ .replace("</head>", `<script>window.__KANBAN_AUTH_TOKEN = ${JSON.stringify(token)};</script></head>`),
122
+ );
123
+ }
124
+
125
+ /** Quote a single CSV field per RFC 4180, with formula injection defense. */
126
+ export function csvQuote(value: string | null | undefined): string {
127
+ if (value == null) return "";
128
+ // Defend against CSV formula injection: prefix dangerous leading chars with '
129
+ const dangerous = /^[=+@\t\r -]/;
130
+ let v = value;
131
+ if (dangerous.test(v)) {
132
+ v = `'${v}`;
133
+ }
134
+ if (/[,"\r\n]/.test(v)) {
135
+ return `"${v.replace(/"/g, '""')}"`;
136
+ }
137
+ return v;
138
+ }
139
+
140
+ /** Generate a slug in {YYYY-MM-DD}-{feature} format. */
141
+ export function generateSlug(title: string): string {
142
+ const date = new Date().toISOString().slice(0, 10);
143
+ const feature =
144
+ title
145
+ .toLowerCase()
146
+ .replace(/[^a-z0-9]+/g, "-")
147
+ .replace(/^-+|-+$/g, "") || "untitled";
148
+ return `${date}-${feature}`;
149
+ }
150
+
151
+ /** Generate a CSV string from headers and rows. */
152
+ export function generateCsv(headers: string[], rows: string[][]): string {
153
+ const lines = [headers.join(",")];
154
+ for (const row of rows) {
155
+ lines.push(row.map(csvQuote).join(","));
156
+ }
157
+ return `${lines.join("\n")}\n`;
158
+ }
159
+
160
+ export async function createServer(
161
+ db: KanbanDatabase,
162
+ port: number,
163
+ staticDir: string | null,
164
+ opts: ServerOptions | null,
165
+ ): Promise<{ server: Server; port: number; authToken: string }> {
166
+ const authToken = randomBytes(32).toString("hex");
167
+ const server = createHttpServer((req, res) => {
168
+ handleRequest(req, res, db, staticDir, authToken, opts).catch((err) => {
169
+ log.error(`[kanban-server] unhandled error: ${err}`, NO_ERROR);
170
+ res.statusCode = 500;
171
+ res.setHeader("Content-Type", "application/json");
172
+ res.end(JSON.stringify({ error: "Internal server error" }));
173
+ });
174
+ });
175
+
176
+ return new Promise((resolve, reject) => {
177
+ server.on("error", reject);
178
+ server.listen(port, "localhost", async () => {
179
+ const addr = server.address();
180
+ const actualPort = typeof addr === "object" && addr ? addr.port : port;
181
+ // Persist auth token so other sessions can connect to this server
182
+ if (opts?.dataDir) {
183
+ try {
184
+ await writeFile(join(opts.dataDir, "auth_token.txt"), authToken, {
185
+ encoding: "utf-8",
186
+ mode: 0o600,
187
+ });
188
+ } catch (err) {
189
+ log.warn(`[kanban-server] failed to write auth token: ${err}`);
190
+ }
191
+ }
192
+ resolve({ server, port: actualPort, authToken });
193
+ });
194
+ });
195
+ }
196
+
197
+ function validateAuthToken(req: IncomingMessage, authToken: string): boolean {
198
+ const header = req.headers.authorization;
199
+ if (!header) return false;
200
+ const match = /^Bearer\s+(.+)$/.exec(header);
201
+ if (!match) return false;
202
+ const token = match[1];
203
+ if (token.length !== authToken.length) return false;
204
+ return timingSafeEqual(Buffer.from(token), Buffer.from(authToken));
205
+ }
206
+
207
+ async function handleRequest(
208
+ req: IncomingMessage,
209
+ res: ServerResponse,
210
+ db: KanbanDatabase,
211
+ staticDir: string | null | undefined,
212
+ authToken: string,
213
+ opts: ServerOptions | null,
214
+ ) {
215
+ const url = new URL(req.url ?? "/", "http://localhost");
216
+ const pathname = url.pathname;
217
+
218
+ // Defense-in-depth security headers for all responses
219
+ res.setHeader("X-Content-Type-Options", "nosniff");
220
+ res.setHeader("X-Frame-Options", "DENY");
221
+ res.setHeader(
222
+ "Content-Security-Policy",
223
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'",
224
+ );
225
+
226
+ // API routes — require bearer token
227
+ if (pathname.startsWith("/api/")) {
228
+ if (!validateAuthToken(req, authToken)) {
229
+ res.statusCode = 401;
230
+ res.setHeader("Content-Type", "application/json");
231
+ res.end(JSON.stringify({ error: "Unauthorized" }));
232
+ return;
233
+ }
234
+ await handleApiRoute(req, res, db, pathname, opts);
235
+ return;
236
+ }
237
+
238
+ // Static files (SPA)
239
+ if (staticDir) {
240
+ const resolvedStaticDir = resolve(staticDir);
241
+ // Strip leading slash so resolve treats path as relative to staticDir
242
+ const relativePath = pathname === "/" ? "index.html" : pathname.slice(1);
243
+ const filePath = resolve(resolvedStaticDir, relativePath);
244
+ // Path traversal protection: ensure resolved path stays within staticDir
245
+ // Normalize separators for cross-platform comparison
246
+ const normalizedStatic = resolvedStaticDir.replace(/\\/g, "/");
247
+ const normalizedFile = filePath.replace(/\\/g, "/");
248
+ if (!normalizedFile.startsWith(`${normalizedStatic}/`) && normalizedFile !== normalizedStatic) {
249
+ res.writeHead(403, { "Content-Type": "text/plain" });
250
+ res.end("Forbidden");
251
+ return;
252
+ }
253
+ try {
254
+ let content: Buffer = await readFile(filePath);
255
+ const mime = MIME_TYPES[extname(filePath)] ?? "application/octet-stream";
256
+ res.setHeader("Content-Type", mime);
257
+ // Inject auth token into HTML for SPA
258
+ if (filePath.endsWith("index.html")) {
259
+ content = injectAuthToken(content, authToken);
260
+ }
261
+ res.end(content);
262
+ } catch {
263
+ // SPA fallback: serve index.html for unknown routes
264
+ try {
265
+ let indexContent: Buffer = await readFile(join(staticDir, "index.html"));
266
+ indexContent = injectAuthToken(indexContent, authToken);
267
+ res.setHeader("Content-Type", "text/html");
268
+ res.end(indexContent);
269
+ } catch {
270
+ res.statusCode = 404;
271
+ res.end("Not found");
272
+ }
273
+ }
274
+ return;
275
+ }
276
+
277
+ res.statusCode = 404;
278
+ res.end("Not found");
279
+ }
280
+
281
+ /** Handle POST /api/features/reorder — reorder features within a lane.
282
+ * Locked features are silently skipped and their positions are not preserved. */
283
+ async function handleReorderRequest(req: IncomingMessage, res: ServerResponse, db: KanbanDatabase) {
284
+ const parsed = await parseJsonBody<{ featureIds: number[]; projectId: number; lane: string }>(
285
+ req,
286
+ res,
287
+ NO_MAX_BODY_SIZE,
288
+ );
289
+ if (!parsed) return;
290
+ const { featureIds, projectId, lane } = parsed;
291
+ if (!lane || !isLane(lane)) {
292
+ res.statusCode = 400;
293
+ res.end(JSON.stringify({ error: lane ? `lane must be one of: ${LANE_ORDER.join(", ")}` : "lane is required" }));
294
+ return;
295
+ }
296
+ if (typeof projectId !== "number" || !Number.isInteger(projectId) || projectId <= 0) {
297
+ res.statusCode = 400;
298
+ res.end(JSON.stringify({ error: "projectId must be a positive integer" }));
299
+ return;
300
+ }
301
+ if (!Array.isArray(featureIds) || featureIds.length === 0) {
302
+ res.statusCode = 400;
303
+ res.end(JSON.stringify({ error: "featureIds must be a non-empty array" }));
304
+ return;
305
+ }
306
+ if (featureIds.length > 1000) {
307
+ res.statusCode = 400;
308
+ res.end(JSON.stringify({ error: "featureIds must not exceed 1000 items" }));
309
+ return;
310
+ }
311
+ for (const id of featureIds) {
312
+ if (typeof id !== "number" || !Number.isInteger(id) || id <= 0) {
313
+ res.statusCode = 400;
314
+ res.end(JSON.stringify({ error: "each featureId must be a positive integer" }));
315
+ return;
316
+ }
317
+ }
318
+
319
+ // Look up, validate, filter, and re-prioritize — all inside one transaction
320
+ const reorderedIds: number[] = [];
321
+ const skippedIds: number[] = [];
322
+
323
+ db.runInTransaction(() => {
324
+ // Batch-lookup all features in one query (instead of N individual getFeature calls)
325
+ const uniqueIds = [...new Set(featureIds)];
326
+ const features = db.getFeaturesByIds(uniqueIds);
327
+ const featureMap = new Map(features.map((f) => [f.id, f]));
328
+ const seenIds = new Set<number>();
329
+
330
+ for (const id of featureIds) {
331
+ if (seenIds.has(id)) continue;
332
+ seenIds.add(id);
333
+ const feature = featureMap.get(id);
334
+ if (!feature || feature.project_id !== projectId) {
335
+ skippedIds.push(id);
336
+ } else if (feature.lane !== lane || feature.locked_at) {
337
+ skippedIds.push(feature.id);
338
+ } else {
339
+ reorderedIds.push(feature.id);
340
+ }
341
+ }
342
+
343
+ // Assign priorities using cached prepared statement
344
+ // Spec: 0 or 1 cards → silent no-op, no DB writes
345
+ if (reorderedIds.length <= 1) {
346
+ reorderedIds.length = 0; // Clear so response reflects no-op
347
+ return;
348
+ }
349
+ const now = new Date().toISOString();
350
+ for (let i = 0; i < reorderedIds.length; i++) {
351
+ const priority = (reorderedIds.length - i) * PRIORITY_SPACING;
352
+ db.updateFeaturePriority(reorderedIds[i], priority, now);
353
+ }
354
+ });
355
+
356
+ res.end(JSON.stringify({ ok: true, reordered: reorderedIds.length, reorderedIds, skippedIds }));
357
+ }
358
+
359
+ async function handleApiRoute(
360
+ req: IncomingMessage,
361
+ res: ServerResponse,
362
+ db: KanbanDatabase,
363
+ pathname: string,
364
+ opts: ServerOptions | null,
365
+ ) {
366
+ const doneHideAfterMs = opts?.doneHideAfterMs ?? null;
367
+ res.setHeader("Content-Type", "application/json");
368
+ const method = req.method ?? "GET";
369
+
370
+ // Rate-limit expensive (LLM-invoking) endpoints
371
+ if (!checkRateLimit(pathname)) {
372
+ res.statusCode = 429;
373
+ res.end(JSON.stringify({ error: "Rate limit exceeded. Please try again later." }));
374
+ return;
375
+ }
376
+
377
+ // POST /api/generate-title
378
+ if (pathname === "/api/generate-title" && method === "POST") {
379
+ if (!opts?.generateTitle) {
380
+ res.statusCode = 503;
381
+ res.end(JSON.stringify({ error: "AI-powered title generation requires an active pi session" }));
382
+ return;
383
+ }
384
+ const parsed = await parseJsonBody<{ description: string }>(req, res, NO_MAX_BODY_SIZE);
385
+ if (!parsed) return;
386
+ if (typeof parsed.description !== "string" || parsed.description.trim().length === 0) {
387
+ res.statusCode = 400;
388
+ res.end(JSON.stringify({ error: "description must be a non-empty string" }));
389
+ return;
390
+ }
391
+ try {
392
+ const controller = new AbortController();
393
+ const timeout = setTimeout(() => controller.abort(), PER_ROW_LLM_TIMEOUT_MS);
394
+ let title: string;
395
+ try {
396
+ title = await opts.generateTitle(parsed.description.slice(0, MAX_LLM_DESCRIPTION_LENGTH), controller.signal);
397
+ } finally {
398
+ clearTimeout(timeout);
399
+ }
400
+ res.end(JSON.stringify({ title }));
401
+ } catch (err) {
402
+ const message = err instanceof Error ? err.message : String(err);
403
+ log.error(`[kanban-server] generate-title failed: ${message}`, NO_ERROR);
404
+ res.statusCode = 500;
405
+ res.end(JSON.stringify({ error: "Title generation failed" }));
406
+ }
407
+ return;
408
+ }
409
+
410
+ // GET /api/projects
411
+ if (pathname === "/api/projects" && method === "GET") {
412
+ const projects = db.listProjects();
413
+ res.end(JSON.stringify(projects));
414
+ return;
415
+ }
416
+
417
+ // POST /api/features
418
+ if (pathname === "/api/features" && method === "POST") {
419
+ const parsed = await parseJsonBody<Record<string, unknown>>(req, res, NO_MAX_BODY_SIZE);
420
+ if (!parsed) return;
421
+ const { projectId, slug, title, lane, description, priority, position } = parsed;
422
+ if (typeof projectId !== "number" || !Number.isInteger(projectId) || projectId <= 0) {
423
+ res.statusCode = 400;
424
+ res.end(JSON.stringify({ error: "projectId must be a positive integer" }));
425
+ return;
426
+ }
427
+ if (slug !== undefined && slug !== null && typeof slug !== "string") {
428
+ res.statusCode = 400;
429
+ res.end(JSON.stringify({ error: "slug must be a string or null" }));
430
+ return;
431
+ }
432
+ if (typeof title !== "string" || title.length === 0) {
433
+ res.statusCode = 400;
434
+ res.end(JSON.stringify({ error: "title must be a non-empty string" }));
435
+ return;
436
+ }
437
+ if (lane !== undefined && (typeof lane !== "string" || !isLane(lane))) {
438
+ res.statusCode = 400;
439
+ res.end(JSON.stringify({ error: `lane must be one of: ${LANE_ORDER.join(", ")}` }));
440
+ return;
441
+ }
442
+ // Validate description type
443
+ if (description !== undefined && description !== null && typeof description !== "string") {
444
+ res.statusCode = 400;
445
+ res.end(JSON.stringify({ error: "description must be a string or null" }));
446
+ return;
447
+ }
448
+ // Validate provided slug is safe: lowercase alphanumeric and hyphens only, no path traversal
449
+ // Empty string is treated as null (auto-generate) downstream
450
+ const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
451
+ if (slug !== undefined && slug !== null && slug !== "") {
452
+ if (!SLUG_RE.test(slug)) {
453
+ res.statusCode = 400;
454
+ res.end(JSON.stringify({ error: "slug must contain only lowercase alphanumeric characters and hyphens" }));
455
+ return;
456
+ }
457
+ }
458
+ // Auto-generate slug from title if not provided
459
+ const resolvedSlug = slug ?? generateSlug(title);
460
+ // Resolve priority from position parameter
461
+ let resolvedPriority: number;
462
+ if (position !== undefined) {
463
+ const posErr = validatePosition(position, OPTIONAL_POSITION);
464
+ if (posErr) {
465
+ res.statusCode = 400;
466
+ res.end(JSON.stringify({ error: posErr }));
467
+ return;
468
+ }
469
+ const resolvedLane: Lane = lane ?? "backlog";
470
+ const bounds = db.getLanePriorityBounds(projectId, resolvedLane);
471
+ if (position === "top") {
472
+ resolvedPriority = topPriority(bounds);
473
+ } else {
474
+ resolvedPriority = fifoPriority(bounds);
475
+ }
476
+ } else {
477
+ resolvedPriority = typeof priority === "number" ? priority : 0;
478
+ }
479
+ try {
480
+ const id = db.createFeature({
481
+ projectId,
482
+ slug: resolvedSlug,
483
+ title,
484
+ lane: lane ?? "backlog",
485
+ description: description as string | null | undefined,
486
+ priority: resolvedPriority,
487
+ });
488
+ const feature = db.getFeature(id);
489
+ res.statusCode = 201;
490
+ res.end(JSON.stringify(feature));
491
+ } catch (err) {
492
+ const message = err instanceof Error ? err.message : String(err);
493
+ log.error(`[kanban-server] createFeature failed: ${message}`, NO_ERROR);
494
+ res.statusCode = 500;
495
+ res.end(JSON.stringify({ error: "Failed to create feature" }));
496
+ }
497
+ return;
498
+ }
499
+
500
+ // POST /api/features/batch
501
+ if (pathname === "/api/features/batch" && method === "POST") {
502
+ if (!opts?.generateTitle) {
503
+ res.statusCode = 503;
504
+ res.end(JSON.stringify({ error: "AI-powered import requires an active pi session" }));
505
+ return;
506
+ }
507
+ const parsed = await parseJsonBody<{ projectId: number; rows: { description: string }[] }>(
508
+ req,
509
+ res,
510
+ MAX_BATCH_BODY_SIZE,
511
+ );
512
+ if (!parsed) return;
513
+ if (typeof parsed.projectId !== "number" || !Number.isInteger(parsed.projectId) || parsed.projectId <= 0) {
514
+ res.statusCode = 400;
515
+ res.end(JSON.stringify({ error: "projectId must be a positive integer" }));
516
+ return;
517
+ }
518
+ if (!Array.isArray(parsed.rows)) {
519
+ res.statusCode = 400;
520
+ res.end(JSON.stringify({ error: "rows must be an array" }));
521
+ return;
522
+ }
523
+ if (parsed.rows.length > MAX_BATCH_ROWS) {
524
+ res.statusCode = 413;
525
+ res.end(JSON.stringify({ error: `Too many rows (max ${MAX_BATCH_ROWS})` }));
526
+ return;
527
+ }
528
+
529
+ const imported: Feature[] = [];
530
+ const skipped: { row: number; description: string; reason: string }[] = [];
531
+
532
+ for (let i = 0; i < parsed.rows.length; i++) {
533
+ const row = parsed.rows[i];
534
+ if (
535
+ !row ||
536
+ typeof row !== "object" ||
537
+ typeof row.description !== "string" ||
538
+ row.description.trim().length === 0
539
+ ) {
540
+ const desc =
541
+ row && typeof row === "object" && typeof row.description === "string" ? row.description : String(row ?? "");
542
+ skipped.push({ row: i + 1, description: desc, reason: "Empty description" });
543
+ continue;
544
+ }
545
+ let title: string;
546
+ try {
547
+ const controller = new AbortController();
548
+ const timeout = setTimeout(() => controller.abort(), PER_ROW_LLM_TIMEOUT_MS);
549
+ try {
550
+ title = await opts.generateTitle(row.description.slice(0, MAX_LLM_DESCRIPTION_LENGTH), controller.signal);
551
+ } finally {
552
+ clearTimeout(timeout);
553
+ }
554
+ } catch (err) {
555
+ if (err instanceof Error) {
556
+ log.error(`[batch] Row ${i + 1} title generation failed: ${err.message}`, NO_ERROR);
557
+ }
558
+ skipped.push({ row: i + 1, description: row.description, reason: "Title generation failed" });
559
+ continue;
560
+ }
561
+ try {
562
+ const slug = generateSlug(title);
563
+ // Handle slug collision with retry
564
+ let finalSlug = slug;
565
+ for (let attempt = 0; attempt <= 10; attempt++) {
566
+ try {
567
+ const id = db.createFeature({
568
+ projectId: parsed.projectId,
569
+ slug: finalSlug,
570
+ title,
571
+ description: row.description,
572
+ lane: "backlog",
573
+ priority: 0,
574
+ });
575
+ const feature = db.getFeature(id);
576
+ if (feature) imported.push(feature);
577
+ break;
578
+ } catch (e) {
579
+ if (attempt < 10 && e instanceof Error && e.message.includes("UNIQUE")) {
580
+ finalSlug = `${slug}-${attempt + 1}`;
581
+ } else if (attempt >= 10) {
582
+ skipped.push({ row: i + 1, description: row.description, reason: "Slug collision after 10 retries" });
583
+ break;
584
+ } else {
585
+ throw e;
586
+ }
587
+ }
588
+ }
589
+ } catch (err) {
590
+ if (err instanceof Error) {
591
+ log.error(`[batch] Row ${i + 1} database error: ${err.message}`, NO_ERROR);
592
+ }
593
+ skipped.push({ row: i + 1, description: row.description, reason: "Database error" });
594
+ }
595
+ }
596
+
597
+ res.end(
598
+ JSON.stringify({
599
+ imported: imported.length,
600
+ skipped: skipped.length,
601
+ skippedRows: skipped,
602
+ features: imported,
603
+ }),
604
+ );
605
+ return;
606
+ }
607
+
608
+ // POST /api/features/reorder
609
+ if (pathname === "/api/features/reorder" && method === "POST") {
610
+ return handleReorderRequest(req, res, db);
611
+ }
612
+
613
+ // GET /api/features/:id
614
+ const featureMatch = pathname.match(FEATURE_ID_RE);
615
+ if (featureMatch && method === "GET") {
616
+ const id = Number(featureMatch[1]);
617
+ const feature = db.getFeature(id);
618
+ if (!feature) {
619
+ res.statusCode = 404;
620
+ res.end(JSON.stringify({ error: "Feature not found" }));
621
+ return;
622
+ }
623
+ res.end(JSON.stringify(feature));
624
+ return;
625
+ }
626
+
627
+ // POST /api/features/:id/move
628
+ const moveMatch = pathname.match(FEATURE_MOVE_RE);
629
+ if (moveMatch && method === "POST") {
630
+ const id = Number(moveMatch[1]);
631
+ const parsed = await parseJsonBody<{ toLane?: string; changedBy?: string; note?: string; position?: string }>(
632
+ req,
633
+ res,
634
+ null,
635
+ );
636
+ if (!parsed) return;
637
+ const { toLane, changedBy, note, position } = parsed;
638
+ if (!isLane(toLane)) {
639
+ res.statusCode = 400;
640
+ res.end(JSON.stringify({ error: "Invalid toLane" }));
641
+ return;
642
+ }
643
+ if (changedBy !== undefined && typeof changedBy !== "string") {
644
+ res.statusCode = 400;
645
+ res.end(JSON.stringify({ error: "changedBy must be a string" }));
646
+ return;
647
+ }
648
+ if (note !== undefined && typeof note !== "string") {
649
+ res.statusCode = 400;
650
+ res.end(JSON.stringify({ error: "note must be a string" }));
651
+ return;
652
+ }
653
+ const posErr = validatePosition(position, OPTIONAL_POSITION);
654
+ if (posErr) {
655
+ res.statusCode = 400;
656
+ res.end(JSON.stringify({ error: posErr }));
657
+ return;
658
+ }
659
+ // Get feature before move to capture fromLane and slug
660
+ const featureBefore = db.getFeature(id);
661
+ if (!featureBefore) {
662
+ res.statusCode = 404;
663
+ res.end(JSON.stringify({ error: "Feature not found" }));
664
+ return;
665
+ }
666
+ const fromLane = featureBefore.lane;
667
+ // Same-lane moves: reject if position specified (use reorder endpoint),
668
+ // otherwise return feature as-is (no-op)
669
+ if (fromLane === toLane) {
670
+ if (position !== undefined) {
671
+ res.statusCode = 400;
672
+ res.end(JSON.stringify({ error: "Cannot specify position for same-lane move; use the reorder endpoint" }));
673
+ return;
674
+ }
675
+ res.end(JSON.stringify(featureBefore));
676
+ return;
677
+ }
678
+ db.runInTransaction(() => {
679
+ // Get bounds BEFORE move so the moved card's own priority isn't included
680
+ const targetBounds = fromLane !== toLane ? db.getLanePriorityBounds(featureBefore.project_id, toLane) : null;
681
+ db.moveFeature({ featureId: id, toLane, changedBy: changedBy ?? "api", note, fromLane });
682
+ // Assign priority in target lane for cross-lane moves
683
+ if (targetBounds) {
684
+ if (position === "top") {
685
+ db.assignTopPriority(id, featureBefore.project_id, toLane, targetBounds);
686
+ } else {
687
+ db.assignFifoPriority(id, featureBefore.project_id, toLane, targetBounds);
688
+ }
689
+ }
690
+ });
691
+ const feature = db.getFeature(id);
692
+ res.end(JSON.stringify(feature));
693
+ return;
694
+ }
695
+
696
+ // POST /api/features/:id/release
697
+ const releaseMatch = pathname.match(FEATURE_RELEASE_RE);
698
+ if (releaseMatch && method === "POST") {
699
+ const id = Number(releaseMatch[1]);
700
+ const feature = db.getFeature(id);
701
+ if (!feature) {
702
+ res.statusCode = 404;
703
+ res.end(JSON.stringify({ error: "Feature not found" }));
704
+ return;
705
+ }
706
+ if (!feature.locked_at) {
707
+ res.statusCode = 409;
708
+ res.end(JSON.stringify({ error: "Feature is not locked" }));
709
+ return;
710
+ }
711
+ db.unlockFeature(id);
712
+ const updated = db.getFeature(id);
713
+ res.end(JSON.stringify(updated));
714
+ return;
715
+ }
716
+
717
+ // POST /api/features/:id/lock
718
+ const lockMatch = pathname.match(FEATURE_LOCK_RE);
719
+ if (lockMatch && method === "POST") {
720
+ const id = Number(lockMatch[1]);
721
+ const feature = db.getFeature(id);
722
+ if (!feature) {
723
+ res.statusCode = 404;
724
+ res.end(JSON.stringify({ error: "Feature not found" }));
725
+ return;
726
+ }
727
+ if (feature.locked_at) {
728
+ res.statusCode = 409;
729
+ res.end(JSON.stringify({ error: "Feature is already locked" }));
730
+ return;
731
+ }
732
+ const sessionId = WEB_UI_SESSION;
733
+ const locked = db.lockFeature(id, sessionId);
734
+ if (!locked) {
735
+ res.statusCode = 409;
736
+ res.end(JSON.stringify({ error: "Failed to acquire lock" }));
737
+ return;
738
+ }
739
+ const updated = db.getFeature(id);
740
+ res.end(JSON.stringify(updated));
741
+ return;
742
+ }
743
+
744
+ // DELETE /api/features/:id
745
+ const deleteMatch = pathname.match(FEATURE_ID_RE);
746
+ if (deleteMatch && method === "DELETE") {
747
+ const id = Number(deleteMatch[1]);
748
+ const feature = db.getFeature(id);
749
+ if (!feature) {
750
+ res.statusCode = 404;
751
+ res.end(JSON.stringify({ error: "Feature not found" }));
752
+ return;
753
+ }
754
+ const deleted = db.deleteFeature(id);
755
+ if (!deleted) {
756
+ res.statusCode = 409;
757
+ res.end(JSON.stringify({ error: "Feature is locked. Release the lock first." }));
758
+ return;
759
+ }
760
+ res.end(JSON.stringify({ ok: true }));
761
+ return;
762
+ }
763
+
764
+ // PATCH /api/features/:id
765
+ const patchMatch = pathname.match(FEATURE_ID_RE);
766
+ if (patchMatch && method === "PATCH") {
767
+ const id = Number(patchMatch[1]);
768
+ const feature = db.getFeature(id);
769
+ if (!feature) {
770
+ res.statusCode = 404;
771
+ res.end(JSON.stringify({ error: "Feature not found" }));
772
+ return;
773
+ }
774
+ const parsed = await parseJsonBody<{ title?: string; description?: string; priority?: number }>(
775
+ req,
776
+ res,
777
+ NO_MAX_BODY_SIZE,
778
+ );
779
+ if (!parsed) return;
780
+ // Validate input types
781
+ if (parsed.title !== undefined && (typeof parsed.title !== "string" || parsed.title.length === 0)) {
782
+ res.statusCode = 400;
783
+ res.end(JSON.stringify({ error: "title must be a non-empty string" }));
784
+ return;
785
+ }
786
+ if (parsed.description !== undefined && parsed.description !== null && typeof parsed.description !== "string") {
787
+ res.statusCode = 400;
788
+ res.end(JSON.stringify({ error: "description must be a string or null" }));
789
+ return;
790
+ }
791
+ if (parsed.priority !== undefined && (typeof parsed.priority !== "number" || !Number.isInteger(parsed.priority))) {
792
+ res.statusCode = 400;
793
+ res.end(JSON.stringify({ error: "priority must be an integer" }));
794
+ return;
795
+ }
796
+ db.updateFeature({ featureId: id, ...parsed });
797
+ const updated = db.getFeature(id);
798
+ res.end(JSON.stringify(updated));
799
+ return;
800
+ }
801
+
802
+ // GET /api/features/:id/history
803
+ const historyMatch = pathname.match(FEATURE_HISTORY_RE);
804
+ if (historyMatch && method === "GET") {
805
+ const id = Number(historyMatch[1]);
806
+ const history = db.getFeatureHistory(id);
807
+ res.end(JSON.stringify(history));
808
+ return;
809
+ }
810
+
811
+ // GET /api/board/:projectId/export
812
+ const exportMatch = pathname.match(BOARD_EXPORT_RE);
813
+ if (exportMatch && method === "GET") {
814
+ const projectId = Number(exportMatch[1]);
815
+ const urlObj = new URL(req.url ?? "/", "http://localhost");
816
+ const lanesParam = urlObj.searchParams.get("lanes");
817
+ const requestedLanes = lanesParam ? lanesParam.split(",").filter(isLane) : [...LANE_ORDER];
818
+
819
+ if (requestedLanes.length === 0) {
820
+ res.statusCode = 400;
821
+ res.end(JSON.stringify({ error: "No valid lanes specified" }));
822
+ return;
823
+ }
824
+
825
+ // Get project name for filename
826
+ const projects = db.listProjects();
827
+ const project = projects.find((p: Project) => p.id === projectId);
828
+ const projectName = (project ? project.name.replace(/[^a-zA-Z0-9-]/g, "_") : "unknown").slice(0, 100);
829
+ const date = new Date().toISOString().slice(0, 10);
830
+ const filename = `kanban-${projectName}-${date}.csv`;
831
+
832
+ const headers = ["Title", "Description", "Lane", "Priority", "Slug", "Created", "Updated"];
833
+ const rows: string[][] = [];
834
+ for (const lane of requestedLanes) {
835
+ const features = db.listFeatures(projectId, lane, doneHideAfterMs ?? NO_DONE_HIDE_AFTER_MS);
836
+ for (const f of features) {
837
+ rows.push([f.title, f.description ?? "", f.lane, String(f.priority), f.slug ?? "", f.created_at, f.updated_at]);
838
+ }
839
+ }
840
+
841
+ const csv = generateCsv(headers, rows);
842
+
843
+ res.setHeader("Content-Type", "text/csv; charset=utf-8");
844
+ res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
845
+ res.end(csv);
846
+ return;
847
+ }
848
+
849
+ // GET /api/board/:projectId
850
+ const boardMatch = pathname.match(BOARD_RE);
851
+ if (boardMatch && method === "GET") {
852
+ const projectId = Number(boardMatch[1]);
853
+ const allFeatures = db.listAllFeatures(projectId, doneHideAfterMs ?? NO_DONE_HIDE_AFTER_MS);
854
+ const board: Record<string, Feature[]> = {};
855
+ for (const lane of LANE_ORDER) {
856
+ board[lane] = allFeatures.filter((f) => f.lane === lane);
857
+ }
858
+ res.end(JSON.stringify(board));
859
+ return;
860
+ }
861
+
862
+ // GET /api/tags
863
+ if (pathname === "/api/tags" && method === "GET") {
864
+ const tags = db.listTags();
865
+ res.end(JSON.stringify(tags));
866
+ return;
867
+ }
868
+
869
+ // GET /api/projects/:id/features
870
+ const projectFeaturesMatch = pathname.match(PROJECT_FEATURES_RE);
871
+ if (projectFeaturesMatch && method === "GET") {
872
+ const projectId = Number(projectFeaturesMatch[1]);
873
+ const features = db.listAllFeatures(projectId, NO_DONE_HIDE_AFTER_MS);
874
+ res.end(JSON.stringify(features));
875
+ return;
876
+ }
877
+
878
+ res.statusCode = 404;
879
+ res.end(JSON.stringify({ error: "Not found" }));
880
+ }
881
+
882
+ const MAX_BODY_SIZE = 1 << 20; // 1 MB
883
+
884
+ export function readBody(req: IncomingMessage, maxSize: number | null): Promise<string> {
885
+ const limit = maxSize ?? MAX_BODY_SIZE;
886
+ return new Promise((resolve, reject) => {
887
+ const chunks: Buffer[] = [];
888
+ let totalSize = 0;
889
+ let tooLarge = false;
890
+ req.on("data", (chunk: Buffer) => {
891
+ if (tooLarge) return; // drain remaining data without buffering
892
+ totalSize += chunk.length;
893
+ if (totalSize > limit) {
894
+ tooLarge = true;
895
+ // Stop receiving data — the handler will send 413 and close the connection
896
+ req.removeAllListeners("data");
897
+ reject(new Error("Request body too large"));
898
+ return;
899
+ }
900
+ chunks.push(chunk);
901
+ });
902
+ req.on("end", () => {
903
+ if (tooLarge) return; // already rejected
904
+ resolve(Buffer.concat(chunks).toString("utf-8"));
905
+ });
906
+ req.on("error", reject);
907
+ });
908
+ }
909
+
910
+ /** Pass as `maxSize` when no body size limit is needed. */
911
+ const NO_MAX_BODY_SIZE: number | null = null;
912
+
913
+ /** Parse JSON request body with standard error handling for 413/400 responses. */
914
+ async function parseJsonBody<T = Record<string, unknown>>(
915
+ req: IncomingMessage,
916
+ res: ServerResponse,
917
+ maxSize: number | null,
918
+ ): Promise<T | null> {
919
+ try {
920
+ return JSON.parse(await readBody(req, maxSize)) as T;
921
+ } catch (err) {
922
+ if (err instanceof Error && err.message === "Request body too large") {
923
+ res.statusCode = 413;
924
+ res.end(JSON.stringify({ error: "Request body too large" }));
925
+ } else {
926
+ res.statusCode = 400;
927
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
928
+ }
929
+ return null;
930
+ }
931
+ }