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.
- package/LICENSE +21 -0
- package/README.md +194 -0
- package/agents/fy-design-reviewer.md +77 -0
- package/agents/fy-feature-verifier.md +24 -0
- package/agents/fy-general-reviewer.md +81 -0
- package/agents/fy-guidelines-reviewer.md +46 -0
- package/agents/fy-implementer.md +20 -0
- package/agents/fy-performance-reviewer.md +46 -0
- package/agents/fy-plan-reviewer.md +74 -0
- package/agents/fy-plan-verifier.md +44 -0
- package/agents/fy-quality-reviewer.md +50 -0
- package/agents/fy-requirements-reviewer.md +46 -0
- package/agents/fy-researcher.md +6 -0
- package/agents/fy-security-reviewer.md +46 -0
- package/agents/fy-task-verifier.md +42 -0
- package/agents/fy-testing-reviewer.md +60 -0
- package/assets/images/kanban-board.png +0 -0
- package/assets/images/settings-dropdown.png +0 -0
- package/assets/images/settings.png +0 -0
- package/assets/images/widget.png +0 -0
- package/docs/CONFIGURATION.md +266 -0
- package/index.ts +8 -0
- package/package.json +85 -0
- package/skills/fy-design/SKILL.md +108 -0
- package/skills/fy-design-review/SKILL.md +45 -0
- package/skills/fy-finish/SKILL.md +28 -0
- package/skills/fy-implement/SKILL.md +36 -0
- package/skills/fy-plan/SKILL.md +77 -0
- package/skills/fy-plan-review/SKILL.md +45 -0
- package/skills/fy-research/SKILL.md +38 -0
- package/skills/fy-review/SKILL.md +88 -0
- package/skills/fy-verify/SKILL.md +23 -0
- package/src/commands/auto-agent-commands.ts +748 -0
- package/src/commands/kanban-commands.ts +278 -0
- package/src/commands/workflow-commands.ts +531 -0
- package/src/compaction/compact-handler.ts +371 -0
- package/src/compaction/compact-message.ts +52 -0
- package/src/compaction/compact-trigger.ts +121 -0
- package/src/compaction/safe-editor-write.ts +18 -0
- package/src/events/agent/agent-end.ts +19 -0
- package/src/events/agent/agent-settled.ts +20 -0
- package/src/events/agent/agent-start.ts +18 -0
- package/src/events/agent/turn-end.ts +20 -0
- package/src/events/agent/turn-start.ts +19 -0
- package/src/events/event-deps.ts +33 -0
- package/src/events/index.ts +91 -0
- package/src/events/input/input.ts +143 -0
- package/src/events/session/context.ts +68 -0
- package/src/events/session/model-select.ts +20 -0
- package/src/events/session/session-compact.ts +20 -0
- package/src/events/session/session-shutdown.ts +26 -0
- package/src/events/session/session-start.ts +45 -0
- package/src/events/session/session-tree.ts +17 -0
- package/src/events/tool/tool-call.ts +58 -0
- package/src/events/tool/tool-result.ts +54 -0
- package/src/git/git-queries.ts +154 -0
- package/src/git/resolve-base-branch.ts +173 -0
- package/src/git/worktrees/worktree-helpers.ts +142 -0
- package/src/git/worktrees/worktree-interception.ts +66 -0
- package/src/git/worktrees/worktree-lifecycle.ts +380 -0
- package/src/guardrails/completion-gating.ts +63 -0
- package/src/guardrails/file-classifier.ts +461 -0
- package/src/guardrails/force-add-guard.ts +312 -0
- package/src/guardrails/guardrail-tracker.ts +73 -0
- package/src/guardrails/guardrail-violations.ts +47 -0
- package/src/guardrails/guardrails-engine.ts +663 -0
- package/src/guardrails/session-guardrails.ts +75 -0
- package/src/guardrails/shell-decompose.ts +285 -0
- package/src/guardrails/tdd-enforcement.ts +122 -0
- package/src/guardrails/test-output.ts +272 -0
- package/src/index.ts +598 -0
- package/src/integrations/parallel-work-guardrail-integration.ts +29 -0
- package/src/integrations/subagent-integration.ts +166 -0
- package/src/integrations/todo-integration.ts +58 -0
- package/src/kanban/auto-agent/auto-agent-cleanup.ts +37 -0
- package/src/kanban/auto-agent/auto-agent-events.ts +142 -0
- package/src/kanban/auto-agent/auto-agent-grace-period.ts +83 -0
- package/src/kanban/auto-agent/auto-agent-lifecycle.ts +497 -0
- package/src/kanban/auto-agent/auto-agent-notify.ts +43 -0
- package/src/kanban/auto-agent/auto-agent-pause.ts +44 -0
- package/src/kanban/auto-agent/auto-agent-state-machine.ts +629 -0
- package/src/kanban/data/kanban-database.ts +851 -0
- package/src/kanban/data/kanban-db-schema.ts +137 -0
- package/src/kanban/data/kanban-detect-project.ts +43 -0
- package/src/kanban/data/kanban-move-feature.ts +43 -0
- package/src/kanban/data/kanban-types.ts +64 -0
- package/src/kanban/ensure-feature.ts +226 -0
- package/src/kanban/kanban-board-ui/app.js +1125 -0
- package/src/kanban/kanban-board-ui/csv-parser.d.ts +5 -0
- package/src/kanban/kanban-board-ui/csv-parser.js +80 -0
- package/src/kanban/kanban-board-ui/index.html +127 -0
- package/src/kanban/kanban-board-ui/style.css +820 -0
- package/src/kanban/kanban-bridge.ts +216 -0
- package/src/kanban/kanban-context.ts +43 -0
- package/src/kanban/kanban-events.ts +34 -0
- package/src/kanban/kanban-generate-title.ts +259 -0
- package/src/kanban/kanban-generate-topic.ts +170 -0
- package/src/kanban/kanban-operations.ts +170 -0
- package/src/kanban/kanban-server.ts +931 -0
- package/src/kanban/kanban-turn-handlers.ts +55 -0
- package/src/kanban/model-capture.ts +34 -0
- package/src/log.ts +47 -0
- package/src/phases/env-sync.ts +64 -0
- package/src/phases/execution-mode.ts +79 -0
- package/src/phases/implement-to-verify.ts +31 -0
- package/src/phases/phase-progression.ts +249 -0
- package/src/phases/phase-transitions.ts +226 -0
- package/src/phases/workflow-router.ts +98 -0
- package/src/prompts/finish-instructions.ts +143 -0
- package/src/prompts/skill-block-builder.ts +73 -0
- package/src/prompts/skill-expansion.ts +262 -0
- package/src/prompts/task-gate-skill.ts +83 -0
- package/src/prompts/template-engine.ts +433 -0
- package/src/prompts/text-blocks.ts +279 -0
- package/src/review/review-context.ts +56 -0
- package/src/review/review-counter.ts +66 -0
- package/src/review/review-empty-loop-tracking.ts +52 -0
- package/src/review/review-loops.ts +217 -0
- package/src/review/review-report.ts +101 -0
- package/src/settings/model-overrides.ts +269 -0
- package/src/settings/settings-schema.ts +458 -0
- package/src/settings/settings-types.ts +81 -0
- package/src/settings/settings-ui.ts +222 -0
- package/src/shared/errors.ts +24 -0
- package/src/shared/global.d.ts +22 -0
- package/src/shared/session-entries.ts +24 -0
- package/src/shared/types.ts +195 -0
- package/src/shared/workflow-refs.ts +253 -0
- package/src/shared/workflow-types.ts +180 -0
- package/src/snippets/vendored/subscribe-to-dialog-coordinator.ts +98 -0
- package/src/snippets/vendored/subscribe-to-notifications.ts +150 -0
- package/src/snippets/vendored/subscribe-to-parallel-work-guardrail.ts +35 -0
- package/src/snippets/vendored/subscribe-to-subagent.ts +67 -0
- package/src/snippets/vendored/subscribe-to-todo.ts +63 -0
- package/src/state/archive-artifacts.ts +602 -0
- package/src/state/artifact-junction.ts +462 -0
- package/src/state/artifact-paths.ts +230 -0
- package/src/state/feature-activation.ts +239 -0
- package/src/state/feature-management.ts +325 -0
- package/src/state/feature-record-store.ts +39 -0
- package/src/state/feature-session.ts +263 -0
- package/src/state/feature-state.ts +439 -0
- package/src/state/post-turn-dispatch.ts +106 -0
- package/src/state/session-lifecycle.ts +346 -0
- package/src/state/state-persistence.ts +122 -0
- package/src/state/worth-notes.ts +89 -0
- package/src/tools/add-to-backlog.ts +117 -0
- package/src/tools/phase-ready.ts +742 -0
- package/src/tools/task-ready-advance.ts +235 -0
- package/src/tools/text-result.ts +15 -0
- package/src/ui/featyard-widget.ts +250 -0
- package/src/ui/manage-features-dialog.ts +246 -0
- 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
|
+
}
|