codetrap 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,500 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { CATEGORIES, SCOPES, SEVERITIES } from "../lib/constants";
3
+ import type { CandidateTrap } from "../domain/session";
4
+ import { TrapStore } from "../lib/store";
5
+ import { TrapOperations } from "../lib/trap-operations";
6
+ import { SessionOperations, type SessionAcceptResult } from "../lib/session-operations";
7
+ import { SessionStore } from "../lib/session-store";
8
+ import { toListJson, toTrapDetailsJson } from "../lib/output-json";
9
+ import { WEB_INDEX_HTML } from "./static";
10
+ import {
11
+ addWebProject,
12
+ loadWebProjectRegistry,
13
+ resolveWebProjectRoot,
14
+ type WebProject,
15
+ } from "./project-registry";
16
+
17
+ export interface WebServerOptions {
18
+ cwd?: string;
19
+ project?: string;
20
+ host?: string;
21
+ port?: number;
22
+ token?: string;
23
+ home?: string;
24
+ }
25
+
26
+ type WebContext = {
27
+ token: string;
28
+ cwd: string;
29
+ home?: string;
30
+ currentProjectRoot: string | null;
31
+ };
32
+
33
+ type WebCandidateReview =
34
+ | { status: "pending"; label: string }
35
+ | {
36
+ status: "accepted";
37
+ label: string;
38
+ trap_id: number;
39
+ scope: string;
40
+ trap_present: true;
41
+ trap_status: string;
42
+ trap_title: string;
43
+ }
44
+ | {
45
+ status: "accepted_missing";
46
+ label: string;
47
+ trap_id?: number;
48
+ scope?: string;
49
+ trap_present: false;
50
+ }
51
+ | {
52
+ status: "rejected";
53
+ label: string;
54
+ rejected_at?: string;
55
+ rejection_reason?: string;
56
+ };
57
+
58
+ type WebCandidate = CandidateTrap & { review: WebCandidateReview };
59
+
60
+ export async function startWebServerFromArgs(args: string[], cwd = process.cwd()): Promise<void> {
61
+ const options = webServerOptionsFromArgs(args, cwd);
62
+ const token = options.token ?? randomBytes(18).toString("base64url");
63
+ const currentProjectRoot = registerInitialProject(options);
64
+ const handler = createWebHandler({
65
+ token,
66
+ cwd: options.cwd ?? cwd,
67
+ home: options.home,
68
+ currentProjectRoot,
69
+ });
70
+ const host = options.host ?? "127.0.0.1";
71
+ const server = serveOnAvailablePort({
72
+ host,
73
+ port: options.port ?? 4737,
74
+ fetch: handler,
75
+ });
76
+ const url = `http://${host}:${server.port}/?token=${encodeURIComponent(token)}`;
77
+ console.log(`codetrap web listening on ${url}`);
78
+ setInterval(() => undefined, 60_000);
79
+ await new Promise(() => {});
80
+ }
81
+
82
+ export function createWebHandler(context: WebContext): (request: Request) => Promise<Response> {
83
+ return async (request: Request): Promise<Response> => {
84
+ const url = new URL(request.url);
85
+ try {
86
+ if (url.pathname.startsWith("/api/")) {
87
+ authorize(request, context.token);
88
+ return await routeApi(request, url, context);
89
+ }
90
+ if (url.pathname === "/" || url.pathname === "/index.html") {
91
+ return htmlResponse(WEB_INDEX_HTML);
92
+ }
93
+ if (url.pathname === "/favicon.ico") {
94
+ return new Response(null, { status: 204 });
95
+ }
96
+ return jsonResponse({ error: "Not found" }, 404);
97
+ } catch (error) {
98
+ const status = error instanceof WebHttpError || error instanceof WebPayloadError ? error.status : 500;
99
+ const payload = error instanceof WebPayloadError
100
+ ? error.payload
101
+ : { error: error instanceof Error ? error.message : String(error) };
102
+ return jsonResponse(payload, status);
103
+ }
104
+ };
105
+ }
106
+
107
+ export function webServerOptionsFromArgs(args: string[], cwd = process.cwd()): WebServerOptions {
108
+ const options: WebServerOptions = { cwd };
109
+ for (let index = 0; index < args.length; index++) {
110
+ const arg = args[index];
111
+ if (!arg.startsWith("--")) continue;
112
+ const key = arg.slice(2);
113
+ const value = args[index + 1] && !args[index + 1].startsWith("--") ? args[++index] : "true";
114
+ if (key === "project") options.project = value;
115
+ if (key === "host") options.host = value;
116
+ if (key === "port") options.port = parsePort(value);
117
+ }
118
+ return options;
119
+ }
120
+
121
+ async function routeApi(request: Request, url: URL, context: WebContext): Promise<Response> {
122
+ if (request.method === "GET" && url.pathname === "/api/bootstrap") {
123
+ const registry = loadWebProjectRegistry(context.home);
124
+ return jsonResponse({
125
+ projects: registry.projects,
126
+ current_project_root: context.currentProjectRoot,
127
+ options: {
128
+ categories: [...CATEGORIES],
129
+ severities: [...SEVERITIES],
130
+ scopes: [...SCOPES],
131
+ },
132
+ });
133
+ }
134
+
135
+ if (request.method === "GET" && url.pathname === "/api/projects") {
136
+ return jsonResponse(loadWebProjectRegistry(context.home));
137
+ }
138
+
139
+ if (request.method === "POST" && url.pathname === "/api/projects") {
140
+ const body = await readJsonBody(request);
141
+ const path = stringBodyField(body, "path");
142
+ const project = addWebProject(path, context.home);
143
+ return jsonResponse({ project, projects: loadWebProjectRegistry(context.home).projects });
144
+ }
145
+
146
+ if (request.method === "GET" && url.pathname === "/api/sessions") {
147
+ const projectRoot = projectRootFromQuery(url, context);
148
+ const sessions = sessionOperations(projectRoot, context.home).sessions.listSessions({ status: "all", limit: 100 });
149
+ return jsonResponse({ project_root: projectRoot, sessions });
150
+ }
151
+
152
+ if (request.method === "GET" && url.pathname === "/api/candidates") {
153
+ const projectRoot = projectRootFromQuery(url, context);
154
+ const sessionId = requiredQuery(url, "session");
155
+ const ops = sessionOperations(projectRoot, context.home);
156
+ const session = ops.sessions.showSession(sessionId).session;
157
+ const document = ops.sessions.candidateDocument(sessionId);
158
+ return jsonResponse({
159
+ project_root: projectRoot,
160
+ session,
161
+ candidates: webCandidates(document.candidates, ops.traps),
162
+ });
163
+ }
164
+
165
+ if (request.method === "GET" && url.pathname === "/api/traps") {
166
+ const projectRoot = projectRootFromQuery(url, context);
167
+ const groups = trapOperations(projectRoot, context.home).listTraps({
168
+ category: optionalQuery(url, "category"),
169
+ scope: optionalQuery(url, "scope"),
170
+ status: optionalQuery(url, "status"),
171
+ module: optionalQuery(url, "module"),
172
+ owner: optionalQuery(url, "owner"),
173
+ limit: optionalNumberQuery(url, "limit"),
174
+ offset: optionalNumberQuery(url, "offset"),
175
+ });
176
+ return jsonResponse({
177
+ project_root: projectRoot,
178
+ traps: toListJson(groups),
179
+ });
180
+ }
181
+
182
+ if (request.method === "GET" && url.pathname === "/api/trap") {
183
+ const projectRoot = projectRootFromQuery(url, context);
184
+ const id = numberQuery(url, "id");
185
+ const scope = url.searchParams.get("scope") ?? undefined;
186
+ const details = trapOperations(projectRoot, context.home).getTrapDetails(id, scope);
187
+ if (!details) throw new WebHttpError(404, `Trap #${id} not found.`);
188
+ return jsonResponse(toTrapDetailsJson(details));
189
+ }
190
+
191
+ if (request.method === "POST" && url.pathname === "/api/candidate/save") {
192
+ const body = await readJsonBody(request);
193
+ const projectRoot = projectRootFromBody(body, context);
194
+ const result = sessionOperations(projectRoot, context.home).sessions.saveCandidate({
195
+ candidateId: stringBodyField(body, "candidateId"),
196
+ sessionId: optionalStringBodyField(body, "sessionId"),
197
+ edit: recordBodyField(body, "trap"),
198
+ });
199
+ return jsonResponse({ success: true, session: result.session, candidate: result.candidate });
200
+ }
201
+
202
+ if (request.method === "POST" && url.pathname === "/api/candidate/accept") {
203
+ const body = await readJsonBody(request);
204
+ const projectRoot = projectRootFromBody(body, context);
205
+ const result = await sessionOperations(projectRoot, context.home).sessions.acceptCandidate({
206
+ candidateId: stringBodyField(body, "candidateId"),
207
+ sessionId: optionalStringBodyField(body, "sessionId"),
208
+ acceptAnyway: booleanBodyField(body, "acceptAnyway"),
209
+ supersedesId: optionalNumberBodyField(body, "supersedesId"),
210
+ });
211
+ if (!result.success) {
212
+ throw new WebPayloadError(409, conflictPayload(result));
213
+ }
214
+ return jsonResponse({
215
+ success: true,
216
+ session: result.session,
217
+ candidate: result.candidate,
218
+ trap_id: result.trap_id,
219
+ scope: result.scope,
220
+ evidence_id: result.evidence_id,
221
+ superseded_id: result.superseded_id,
222
+ });
223
+ }
224
+
225
+ if (request.method === "POST" && url.pathname === "/api/candidate/reject") {
226
+ const body = await readJsonBody(request);
227
+ const projectRoot = projectRootFromBody(body, context);
228
+ const result = sessionOperations(projectRoot, context.home).sessions.rejectCandidate({
229
+ candidateId: stringBodyField(body, "candidateId"),
230
+ sessionId: optionalStringBodyField(body, "sessionId"),
231
+ reason: optionalStringBodyField(body, "reason"),
232
+ });
233
+ return jsonResponse({ success: true, session: result.session, candidate: result.candidate });
234
+ }
235
+
236
+ if (request.method === "POST" && url.pathname === "/api/session/delete") {
237
+ const body = await readJsonBody(request);
238
+ const projectRoot = projectRootFromBody(body, context);
239
+ const result = sessionOperations(projectRoot, context.home).sessions.deleteSession(
240
+ stringBodyField(body, "sessionId")
241
+ );
242
+ return jsonResponse({ success: true, ...result });
243
+ }
244
+
245
+ if (request.method === "POST" && url.pathname === "/api/session/cleanup") {
246
+ const body = await readJsonBody(request);
247
+ const projectRoot = projectRootFromBody(body, context);
248
+ const ops = sessionOperations(projectRoot, context.home);
249
+ const result = ops.sessions.cleanupDeletedTrapCandidates(
250
+ optionalStringBodyField(body, "sessionId")
251
+ );
252
+ return jsonResponse({
253
+ success: true,
254
+ session: result.session,
255
+ removed_count: result.removed_count,
256
+ removed_candidate_ids: result.removed_candidate_ids,
257
+ candidates: webCandidates(result.candidates, ops.traps),
258
+ });
259
+ }
260
+
261
+ throw new WebHttpError(404, "Not found");
262
+ }
263
+
264
+ function sessionOperations(projectRoot: string, home?: string): { traps: TrapOperations; sessions: SessionOperations } {
265
+ const traps = trapOperations(projectRoot, home);
266
+ return {
267
+ traps,
268
+ sessions: new SessionOperations(new SessionStore(projectRoot), traps),
269
+ };
270
+ }
271
+
272
+ function trapOperations(projectRoot: string, home?: string): TrapOperations {
273
+ return new TrapOperations(new TrapStore(projectRoot, undefined, home));
274
+ }
275
+
276
+ function webCandidates(candidates: CandidateTrap[], traps: TrapOperations): WebCandidate[] {
277
+ return candidates.map((candidate) => ({
278
+ ...candidate,
279
+ review: candidateReview(candidate, traps),
280
+ }));
281
+ }
282
+
283
+ function candidateReview(candidate: CandidateTrap, traps: TrapOperations): WebCandidateReview {
284
+ if (candidate.status === "proposed") {
285
+ return { status: "pending", label: "pending review" };
286
+ }
287
+
288
+ if (candidate.status === "rejected") {
289
+ return {
290
+ status: "rejected",
291
+ label: "rejected",
292
+ rejected_at: candidate.rejected_at,
293
+ rejection_reason: candidate.rejection_reason,
294
+ };
295
+ }
296
+
297
+ const trapId = candidate.accepted_trap_id;
298
+ const scope = candidate.accepted_scope ?? acceptedScopeFallback(candidate);
299
+ if (trapId === undefined) {
300
+ return {
301
+ status: "accepted_missing",
302
+ label: "accepted -> trap link missing",
303
+ scope,
304
+ trap_present: false,
305
+ };
306
+ }
307
+
308
+ const details = traps.getTrapDetails(trapId, scope);
309
+ if (!details) {
310
+ return {
311
+ status: "accepted_missing",
312
+ label: `accepted -> trap #${trapId} deleted`,
313
+ trap_id: trapId,
314
+ scope,
315
+ trap_present: false,
316
+ };
317
+ }
318
+
319
+ return {
320
+ status: "accepted",
321
+ label: `accepted -> trap #${trapId}`,
322
+ trap_id: trapId,
323
+ scope: details.scope,
324
+ trap_present: true,
325
+ trap_status: details.trap.status,
326
+ trap_title: details.trap.title,
327
+ };
328
+ }
329
+
330
+ function acceptedScopeFallback(candidate: CandidateTrap): string {
331
+ return candidate.trap.scope === "global" ? "global" : "project";
332
+ }
333
+
334
+ function registerInitialProject(options: WebServerOptions): string | null {
335
+ const path = options.project ?? options.cwd;
336
+ if (!path) return null;
337
+ try {
338
+ return addWebProject(path, options.home).root;
339
+ } catch (error) {
340
+ if (options.project) throw error;
341
+ return null;
342
+ }
343
+ }
344
+
345
+ function serveOnAvailablePort(args: {
346
+ host: string;
347
+ port: number;
348
+ fetch: (request: Request) => Promise<Response>;
349
+ }): { port: number; server: ReturnType<typeof Bun.serve> } {
350
+ for (let port = args.port; port < args.port + 50; port++) {
351
+ try {
352
+ const server = Bun.serve({
353
+ hostname: args.host,
354
+ port,
355
+ fetch: args.fetch,
356
+ });
357
+ return { port: server.port ?? port, server };
358
+ } catch (error) {
359
+ if (String(error).includes("EADDRINUSE")) continue;
360
+ throw error;
361
+ }
362
+ }
363
+ throw new Error(`No available port found starting at ${args.port}.`);
364
+ }
365
+
366
+ function authorize(request: Request, token: string): void {
367
+ if (request.headers.get("X-Codetrap-Token") !== token) {
368
+ throw new WebHttpError(401, "Unauthorized");
369
+ }
370
+ }
371
+
372
+ async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
373
+ const value = await request.json().catch(() => null);
374
+ if (!isRecord(value)) throw new WebHttpError(400, "JSON object body is required.");
375
+ return value;
376
+ }
377
+
378
+ function projectRootFromQuery(url: URL, context: WebContext): string {
379
+ const project = url.searchParams.get("project") ?? context.currentProjectRoot;
380
+ if (!project) throw new WebHttpError(400, "project is required.");
381
+ return resolveWebProjectRoot(project, context.home);
382
+ }
383
+
384
+ function projectRootFromBody(body: Record<string, unknown>, context: WebContext): string {
385
+ const project = optionalStringBodyField(body, "projectRoot") ?? context.currentProjectRoot;
386
+ if (!project) throw new WebHttpError(400, "projectRoot is required.");
387
+ return resolveWebProjectRoot(project, context.home);
388
+ }
389
+
390
+ function requiredQuery(url: URL, key: string): string {
391
+ const value = url.searchParams.get(key);
392
+ if (!value) throw new WebHttpError(400, `${key} is required.`);
393
+ return value;
394
+ }
395
+
396
+ function numberQuery(url: URL, key: string): number {
397
+ const value = Number.parseInt(requiredQuery(url, key), 10);
398
+ if (Number.isNaN(value)) throw new WebHttpError(400, `${key} must be a number.`);
399
+ return value;
400
+ }
401
+
402
+ function optionalQuery(url: URL, key: string): string | undefined {
403
+ const value = url.searchParams.get(key)?.trim();
404
+ return value || undefined;
405
+ }
406
+
407
+ function optionalNumberQuery(url: URL, key: string): number | undefined {
408
+ const value = optionalQuery(url, key);
409
+ if (!value) return undefined;
410
+ const parsed = Number.parseInt(value, 10);
411
+ if (!Number.isInteger(parsed) || parsed <= 0) throw new WebHttpError(400, `${key} must be a positive integer.`);
412
+ return parsed;
413
+ }
414
+
415
+ function stringBodyField(body: Record<string, unknown>, key: string): string {
416
+ const value = optionalStringBodyField(body, key);
417
+ if (!value) throw new WebHttpError(400, `${key} is required.`);
418
+ return value;
419
+ }
420
+
421
+ function optionalStringBodyField(body: Record<string, unknown>, key: string): string | undefined {
422
+ const value = body[key];
423
+ if (value === undefined || value === null) return undefined;
424
+ if (typeof value !== "string") throw new WebHttpError(400, `${key} must be a string.`);
425
+ const trimmed = value.trim();
426
+ return trimmed || undefined;
427
+ }
428
+
429
+ function numberBodyField(body: Record<string, unknown>, key: string): number {
430
+ const value = optionalNumberBodyField(body, key);
431
+ if (value === undefined) throw new WebHttpError(400, `${key} is required.`);
432
+ return value;
433
+ }
434
+
435
+ function optionalNumberBodyField(body: Record<string, unknown>, key: string): number | undefined {
436
+ const value = body[key];
437
+ if (value === undefined || value === null || value === "") return undefined;
438
+ if (typeof value !== "number" || !Number.isInteger(value)) {
439
+ throw new WebHttpError(400, `${key} must be an integer.`);
440
+ }
441
+ return value;
442
+ }
443
+
444
+ function booleanBodyField(body: Record<string, unknown>, key: string): boolean {
445
+ const value = body[key];
446
+ return typeof value === "boolean" ? value : false;
447
+ }
448
+
449
+ function recordBodyField(body: Record<string, unknown>, key: string): Record<string, unknown> {
450
+ const value = body[key];
451
+ if (!isRecord(value)) throw new WebHttpError(400, `${key} must be an object.`);
452
+ return value;
453
+ }
454
+
455
+ function conflictPayload(result: Exclude<SessionAcceptResult, { success: true }>): Record<string, unknown> {
456
+ return {
457
+ success: false,
458
+ error: "Possible active trap conflict found.",
459
+ session_id: result.session_id,
460
+ candidate_id: result.candidate_id,
461
+ possible_conflicts: result.possible_conflicts,
462
+ };
463
+ }
464
+
465
+ function parsePort(value: string): number {
466
+ const port = Number.parseInt(value, 10);
467
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
468
+ throw new Error(`Invalid --port: ${value}`);
469
+ }
470
+ return port;
471
+ }
472
+
473
+ function jsonResponse(value: unknown, status = 200): Response {
474
+ return new Response(JSON.stringify(value, null, 2), {
475
+ status,
476
+ headers: { "content-type": "application/json; charset=utf-8" },
477
+ });
478
+ }
479
+
480
+ function htmlResponse(value: string): Response {
481
+ return new Response(value, {
482
+ headers: { "content-type": "text/html; charset=utf-8" },
483
+ });
484
+ }
485
+
486
+ class WebHttpError extends Error {
487
+ constructor(public readonly status: number, message: string) {
488
+ super(message);
489
+ }
490
+ }
491
+
492
+ class WebPayloadError extends Error {
493
+ constructor(public readonly status: number, public readonly payload: Record<string, unknown>) {
494
+ super(String(payload.error ?? "Request failed"));
495
+ }
496
+ }
497
+
498
+ function isRecord(value: unknown): value is Record<string, unknown> {
499
+ return typeof value === "object" && value !== null && !Array.isArray(value);
500
+ }