@hasna/todos 0.5.1 → 0.6.1

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/dist/cli/index.js CHANGED
@@ -2332,6 +2332,36 @@ var init_database = __esm(() => {
2332
2332
  );
2333
2333
  CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
2334
2334
  INSERT OR IGNORE INTO _migrations (id) VALUES (5);
2335
+ `,
2336
+ `
2337
+ CREATE TABLE IF NOT EXISTS audit_log (
2338
+ id TEXT PRIMARY KEY,
2339
+ entity_type TEXT NOT NULL CHECK(entity_type IN ('task', 'plan', 'project', 'api_key', 'comment')),
2340
+ entity_id TEXT NOT NULL,
2341
+ action TEXT NOT NULL CHECK(action IN ('create', 'update', 'delete', 'start', 'complete', 'lock', 'unlock')),
2342
+ actor TEXT,
2343
+ changes TEXT,
2344
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
2345
+ );
2346
+ CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id);
2347
+ CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at);
2348
+
2349
+ CREATE TABLE IF NOT EXISTS webhooks (
2350
+ id TEXT PRIMARY KEY,
2351
+ url TEXT NOT NULL,
2352
+ events TEXT NOT NULL DEFAULT '[]',
2353
+ secret TEXT,
2354
+ active INTEGER NOT NULL DEFAULT 1,
2355
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
2356
+ );
2357
+
2358
+ CREATE TABLE IF NOT EXISTS rate_limits (
2359
+ key TEXT PRIMARY KEY,
2360
+ count INTEGER NOT NULL DEFAULT 0,
2361
+ window_start TEXT NOT NULL DEFAULT (datetime('now'))
2362
+ );
2363
+
2364
+ INSERT OR IGNORE INTO _migrations (id) VALUES (6);
2335
2365
  `
2336
2366
  ];
2337
2367
  });
@@ -2539,9 +2569,12 @@ function listTasks(filter = {}, db) {
2539
2569
  params.push(filter.plan_id);
2540
2570
  }
2541
2571
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
2572
+ const limitVal = filter.limit || 100;
2573
+ const offsetVal = filter.offset || 0;
2574
+ params.push(limitVal, offsetVal);
2542
2575
  const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY
2543
2576
  CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END,
2544
- created_at DESC`).all(...params);
2577
+ created_at DESC LIMIT ? OFFSET ?`).all(...params);
2545
2578
  return rows.map(rowToTask);
2546
2579
  }
2547
2580
  function updateTask(id, input, db) {
@@ -8082,6 +8115,163 @@ var init_api_keys = __esm(() => {
8082
8115
  init_database();
8083
8116
  });
8084
8117
 
8118
+ // src/db/audit.ts
8119
+ function rowToEntry(row) {
8120
+ return {
8121
+ ...row,
8122
+ changes: row.changes ? JSON.parse(row.changes) : null
8123
+ };
8124
+ }
8125
+ function logAudit(entityType, entityId, action, actor, changes, db) {
8126
+ const d = db || getDatabase();
8127
+ d.run(`INSERT INTO audit_log (id, entity_type, entity_id, action, actor, changes, created_at)
8128
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [uuid(), entityType, entityId, action, actor || null, changes ? JSON.stringify(changes) : null, now()]);
8129
+ }
8130
+ function getAuditLog(entityType, entityId, limit = 50, offset = 0, db) {
8131
+ const d = db || getDatabase();
8132
+ const conditions = [];
8133
+ const params = [];
8134
+ if (entityType) {
8135
+ conditions.push("entity_type = ?");
8136
+ params.push(entityType);
8137
+ }
8138
+ if (entityId) {
8139
+ conditions.push("entity_id = ?");
8140
+ params.push(entityId);
8141
+ }
8142
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
8143
+ params.push(limit, offset);
8144
+ const rows = d.query(`SELECT * FROM audit_log ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params);
8145
+ return rows.map(rowToEntry);
8146
+ }
8147
+ var init_audit = __esm(() => {
8148
+ init_database();
8149
+ });
8150
+
8151
+ // src/db/webhooks.ts
8152
+ function rowToWebhook(row) {
8153
+ return {
8154
+ ...row,
8155
+ events: JSON.parse(row.events),
8156
+ active: row.active === 1
8157
+ };
8158
+ }
8159
+ function createWebhook(input, db) {
8160
+ const d = db || getDatabase();
8161
+ const id = uuid();
8162
+ d.run(`INSERT INTO webhooks (id, url, events, secret, created_at)
8163
+ VALUES (?, ?, ?, ?, ?)`, [id, input.url, JSON.stringify(input.events || []), input.secret || null, now()]);
8164
+ return getWebhook(id, d);
8165
+ }
8166
+ function getWebhook(id, db) {
8167
+ const d = db || getDatabase();
8168
+ const row = d.query("SELECT * FROM webhooks WHERE id = ?").get(id);
8169
+ return row ? rowToWebhook(row) : null;
8170
+ }
8171
+ function listWebhooks(db) {
8172
+ const d = db || getDatabase();
8173
+ return d.query("SELECT * FROM webhooks ORDER BY created_at DESC").all().map(rowToWebhook);
8174
+ }
8175
+ function deleteWebhook(id, db) {
8176
+ const d = db || getDatabase();
8177
+ return d.run("DELETE FROM webhooks WHERE id = ?", [id]).changes > 0;
8178
+ }
8179
+ async function dispatchWebhooks(event, payload, db) {
8180
+ const d = db || getDatabase();
8181
+ const rows = d.query("SELECT * FROM webhooks WHERE active = 1").all();
8182
+ const webhooks = rows.map(rowToWebhook).filter((w) => w.events.length === 0 || w.events.includes(event));
8183
+ for (const webhook of webhooks) {
8184
+ try {
8185
+ const headers = { "Content-Type": "application/json" };
8186
+ if (webhook.secret) {
8187
+ const encoder = new TextEncoder;
8188
+ const key = await crypto.subtle.importKey("raw", encoder.encode(webhook.secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
8189
+ const body = JSON.stringify({ event, data: payload, timestamp: now() });
8190
+ const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
8191
+ headers["X-Webhook-Signature"] = Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
8192
+ fetch(webhook.url, {
8193
+ method: "POST",
8194
+ headers,
8195
+ body
8196
+ }).catch(() => {});
8197
+ } else {
8198
+ fetch(webhook.url, {
8199
+ method: "POST",
8200
+ headers,
8201
+ body: JSON.stringify({ event, data: payload, timestamp: now() })
8202
+ }).catch(() => {});
8203
+ }
8204
+ } catch {}
8205
+ }
8206
+ }
8207
+ var init_webhooks = __esm(() => {
8208
+ init_database();
8209
+ });
8210
+
8211
+ // src/lib/rate-limit.ts
8212
+ function cleanup() {
8213
+ const now2 = Date.now();
8214
+ if (now2 - lastCleanup < CLEANUP_INTERVAL)
8215
+ return;
8216
+ lastCleanup = now2;
8217
+ for (const [key, entry] of windows) {
8218
+ if (entry.resetAt < now2)
8219
+ windows.delete(key);
8220
+ }
8221
+ }
8222
+ function checkRateLimit(key, maxRequests, windowMs) {
8223
+ cleanup();
8224
+ const now2 = Date.now();
8225
+ const entry = windows.get(key);
8226
+ if (!entry || entry.resetAt < now2) {
8227
+ windows.set(key, { count: 1, resetAt: now2 + windowMs });
8228
+ return { allowed: true, remaining: maxRequests - 1, resetAt: now2 + windowMs };
8229
+ }
8230
+ entry.count++;
8231
+ if (entry.count > maxRequests) {
8232
+ return { allowed: false, remaining: 0, resetAt: entry.resetAt };
8233
+ }
8234
+ return { allowed: true, remaining: maxRequests - entry.count, resetAt: entry.resetAt };
8235
+ }
8236
+ var windows, CLEANUP_INTERVAL = 60000, lastCleanup;
8237
+ var init_rate_limit = __esm(() => {
8238
+ windows = new Map;
8239
+ lastCleanup = Date.now();
8240
+ });
8241
+
8242
+ // src/lib/env.ts
8243
+ import { existsSync as existsSync6, readFileSync as readFileSync2 } from "fs";
8244
+ import { join as join6 } from "path";
8245
+ function loadEnv() {
8246
+ const paths = [
8247
+ join6(process.cwd(), ".env"),
8248
+ join6(process.cwd(), ".env.local")
8249
+ ];
8250
+ for (const path of paths) {
8251
+ if (!existsSync6(path))
8252
+ continue;
8253
+ const content = readFileSync2(path, "utf-8");
8254
+ for (const line of content.split(`
8255
+ `)) {
8256
+ const trimmed = line.trim();
8257
+ if (!trimmed || trimmed.startsWith("#"))
8258
+ continue;
8259
+ const eqIdx = trimmed.indexOf("=");
8260
+ if (eqIdx === -1)
8261
+ continue;
8262
+ const key = trimmed.slice(0, eqIdx).trim();
8263
+ let value = trimmed.slice(eqIdx + 1).trim();
8264
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
8265
+ value = value.slice(1, -1);
8266
+ }
8267
+ if (!process.env[key]) {
8268
+ process.env[key] = value;
8269
+ }
8270
+ }
8271
+ }
8272
+ }
8273
+ var init_env = () => {};
8274
+
8085
8275
  // src/server/serve.ts
8086
8276
  var exports_serve = {};
8087
8277
  __export(exports_serve, {
@@ -8089,27 +8279,27 @@ __export(exports_serve, {
8089
8279
  createFetchHandler: () => createFetchHandler
8090
8280
  });
8091
8281
  import { execSync } from "child_process";
8092
- import { existsSync as existsSync6, readFileSync as readFileSync2 } from "fs";
8093
- import { join as join6, dirname as dirname2, extname } from "path";
8282
+ import { existsSync as existsSync7, readFileSync as readFileSync3 } from "fs";
8283
+ import { join as join7, dirname as dirname2, extname } from "path";
8094
8284
  import { fileURLToPath } from "url";
8095
8285
  function resolveDashboardDir() {
8096
8286
  const candidates = [];
8097
8287
  try {
8098
8288
  const scriptDir = dirname2(fileURLToPath(import.meta.url));
8099
- candidates.push(join6(scriptDir, "..", "dashboard", "dist"));
8100
- candidates.push(join6(scriptDir, "..", "..", "dashboard", "dist"));
8289
+ candidates.push(join7(scriptDir, "..", "dashboard", "dist"));
8290
+ candidates.push(join7(scriptDir, "..", "..", "dashboard", "dist"));
8101
8291
  } catch {}
8102
8292
  if (process.argv[1]) {
8103
8293
  const mainDir = dirname2(process.argv[1]);
8104
- candidates.push(join6(mainDir, "..", "dashboard", "dist"));
8105
- candidates.push(join6(mainDir, "..", "..", "dashboard", "dist"));
8294
+ candidates.push(join7(mainDir, "..", "dashboard", "dist"));
8295
+ candidates.push(join7(mainDir, "..", "..", "dashboard", "dist"));
8106
8296
  }
8107
- candidates.push(join6(process.cwd(), "dashboard", "dist"));
8297
+ candidates.push(join7(process.cwd(), "dashboard", "dist"));
8108
8298
  for (const candidate of candidates) {
8109
- if (existsSync6(candidate))
8299
+ if (existsSync7(candidate))
8110
8300
  return candidate;
8111
8301
  }
8112
- return join6(process.cwd(), "dashboard", "dist");
8302
+ return join7(process.cwd(), "dashboard", "dist");
8113
8303
  }
8114
8304
  function randomPort() {
8115
8305
  return 20000 + Math.floor(Math.random() * 20000);
@@ -8126,8 +8316,8 @@ function json(data, status = 200, port) {
8126
8316
  }
8127
8317
  function getPackageVersion() {
8128
8318
  try {
8129
- const pkgPath = join6(dirname2(fileURLToPath(import.meta.url)), "..", "..", "package.json");
8130
- return JSON.parse(readFileSync2(pkgPath, "utf-8")).version || "0.0.0";
8319
+ const pkgPath = join7(dirname2(fileURLToPath(import.meta.url)), "..", "..", "package.json");
8320
+ return JSON.parse(readFileSync3(pkgPath, "utf-8")).version || "0.0.0";
8131
8321
  } catch {
8132
8322
  return "0.0.0";
8133
8323
  }
@@ -8140,7 +8330,7 @@ async function parseJsonBody(req) {
8140
8330
  }
8141
8331
  }
8142
8332
  function serveStaticFile(filePath) {
8143
- if (!existsSync6(filePath))
8333
+ if (!existsSync7(filePath))
8144
8334
  return null;
8145
8335
  const ext = extname(filePath);
8146
8336
  const contentType = MIME_TYPES[ext] || "application/octet-stream";
@@ -8149,8 +8339,9 @@ function serveStaticFile(filePath) {
8149
8339
  });
8150
8340
  }
8151
8341
  function createFetchHandler(getPort, dashboardDir, dashboardExists) {
8342
+ loadEnv();
8152
8343
  const dir = dashboardDir || resolveDashboardDir();
8153
- const hasDashboard = dashboardExists ?? existsSync6(dir);
8344
+ const hasDashboard = dashboardExists ?? existsSync7(dir);
8154
8345
  return async (req) => {
8155
8346
  const url = new URL(req.url);
8156
8347
  let path = url.pathname;
@@ -8162,17 +8353,37 @@ function createFetchHandler(getPort, dashboardDir, dashboardExists) {
8162
8353
  if (path.startsWith("/api/") && !path.startsWith("/api/system/") && !path.startsWith("/api/keys")) {
8163
8354
  const hasKeys = hasAnyApiKeys();
8164
8355
  if (hasKeys) {
8165
- const authHeader = req.headers.get("authorization");
8166
- const apiKey = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
8167
- if (!apiKey) {
8168
- return json({ error: "API key required. Pass via Authorization: Bearer <key>" }, 401, port);
8169
- }
8170
- const valid = await validateApiKey(apiKey);
8171
- if (!valid) {
8172
- return json({ error: "Invalid or expired API key" }, 403, port);
8356
+ const origin = req.headers.get("origin") || "";
8357
+ const referer = req.headers.get("referer") || "";
8358
+ const isSameOrigin = origin.includes(`localhost:${port}`) || referer.includes(`localhost:${port}`);
8359
+ if (!isSameOrigin) {
8360
+ const authHeader = req.headers.get("authorization");
8361
+ const apiKey = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
8362
+ if (!apiKey) {
8363
+ return json({ error: "API key required. Pass via Authorization: Bearer <key>" }, 401, port);
8364
+ }
8365
+ const valid = await validateApiKey(apiKey);
8366
+ if (!valid) {
8367
+ return json({ error: "Invalid or expired API key" }, 403, port);
8368
+ }
8173
8369
  }
8174
8370
  }
8175
8371
  }
8372
+ if (path.startsWith("/api/")) {
8373
+ const rateLimitKey = req.headers.get("authorization") || req.headers.get("x-forwarded-for") || "anonymous";
8374
+ const rateResult = checkRateLimit(rateLimitKey, 100, 60000);
8375
+ if (!rateResult.allowed) {
8376
+ return new Response(JSON.stringify({ error: "Rate limit exceeded. Try again later." }), {
8377
+ status: 429,
8378
+ headers: {
8379
+ "Content-Type": "application/json",
8380
+ "Retry-After": String(Math.ceil((rateResult.resetAt - Date.now()) / 1000)),
8381
+ "X-RateLimit-Remaining": "0",
8382
+ ...SECURITY_HEADERS
8383
+ }
8384
+ });
8385
+ }
8386
+ }
8176
8387
  if (path === "/api/tasks" && method === "GET") {
8177
8388
  try {
8178
8389
  const filter = {};
@@ -8188,6 +8399,12 @@ function createFetchHandler(getPort, dashboardDir, dashboardExists) {
8188
8399
  filter.project_id = projectId;
8189
8400
  if (planId)
8190
8401
  filter.plan_id = planId;
8402
+ const limit = parseInt(url.searchParams.get("limit") || "100", 10);
8403
+ const offset = parseInt(url.searchParams.get("offset") || "0", 10);
8404
+ if (limit)
8405
+ filter.limit = Math.min(limit, 500);
8406
+ if (offset)
8407
+ filter.offset = offset;
8191
8408
  const tasks = listTasks(filter);
8192
8409
  const projectCache = new Map;
8193
8410
  const planCache = new Map;
@@ -8265,6 +8482,8 @@ function createFetchHandler(getPort, dashboardDir, dashboardExists) {
8265
8482
  agent_id: parsed.data.agent_id,
8266
8483
  status: parsed.data.status
8267
8484
  });
8485
+ logAudit("task", task.id, "create", parsed.data.agent_id);
8486
+ dispatchWebhooks("task.created", task);
8268
8487
  return json(task, 201, port);
8269
8488
  } catch (e) {
8270
8489
  return json({ error: e instanceof Error ? e.message : "Failed to create task" }, 500, port);
@@ -8298,6 +8517,8 @@ function createFetchHandler(getPort, dashboardDir, dashboardExists) {
8298
8517
  tags: parsed.data.tags,
8299
8518
  metadata: parsed.data.metadata
8300
8519
  });
8520
+ logAudit("task", id, "update", undefined, parsed.data);
8521
+ dispatchWebhooks("task.updated", task);
8301
8522
  return json(task, 200, port);
8302
8523
  } catch (e) {
8303
8524
  const status = e instanceof Error && e.name === "VersionConflictError" ? 409 : 500;
@@ -8311,6 +8532,8 @@ function createFetchHandler(getPort, dashboardDir, dashboardExists) {
8311
8532
  const deleted = deleteTask(id);
8312
8533
  if (!deleted)
8313
8534
  return json({ error: "Task not found" }, 404, port);
8535
+ logAudit("task", id, "delete");
8536
+ dispatchWebhooks("task.deleted", { id });
8314
8537
  return json({ deleted: true }, 200, port);
8315
8538
  } catch (e) {
8316
8539
  return json({ error: e instanceof Error ? e.message : "Failed to delete task" }, 500, port);
@@ -8328,6 +8551,7 @@ function createFetchHandler(getPort, dashboardDir, dashboardExists) {
8328
8551
  return json({ error: "Invalid request body" }, 400, port);
8329
8552
  const agentId = parsed.data.agent_id || "dashboard";
8330
8553
  const task = startTask(id, agentId);
8554
+ logAudit("task", id, "start", agentId);
8331
8555
  return json(task, 200, port);
8332
8556
  } catch (e) {
8333
8557
  const status = e instanceof Error && e.name === "TaskNotFoundError" ? 404 : e instanceof Error && e.name === "LockError" ? 409 : 500;
@@ -8346,6 +8570,7 @@ function createFetchHandler(getPort, dashboardDir, dashboardExists) {
8346
8570
  return json({ error: "Invalid request body" }, 400, port);
8347
8571
  const agentId = parsed.data.agent_id;
8348
8572
  const task = completeTask(id, agentId);
8573
+ logAudit("task", id, "complete", agentId);
8349
8574
  return json(task, 200, port);
8350
8575
  } catch (e) {
8351
8576
  const status = e instanceof Error && e.name === "TaskNotFoundError" ? 404 : e instanceof Error && e.name === "LockError" ? 409 : 500;
@@ -8419,6 +8644,7 @@ function createFetchHandler(getPort, dashboardDir, dashboardExists) {
8419
8644
  description: parsed.data.description,
8420
8645
  task_list_id: parsed.data.task_list_id
8421
8646
  });
8647
+ logAudit("project", project.id, "create");
8422
8648
  return json(project, 201, port);
8423
8649
  } catch (e) {
8424
8650
  return json({ error: e instanceof Error ? e.message : "Failed to create project" }, 500, port);
@@ -8500,6 +8726,8 @@ function createFetchHandler(getPort, dashboardDir, dashboardExists) {
8500
8726
  return json({ error: "Invalid request body" }, 400, port);
8501
8727
  }
8502
8728
  const plan = createPlan(parsed.data);
8729
+ logAudit("plan", plan.id, "create");
8730
+ dispatchWebhooks("plan.created", plan);
8503
8731
  return json(plan, 201, port);
8504
8732
  } catch (e) {
8505
8733
  return json({ error: e instanceof Error ? e.message : "Failed to create plan" }, 500, port);
@@ -8532,6 +8760,7 @@ function createFetchHandler(getPort, dashboardDir, dashboardExists) {
8532
8760
  return json({ error: "Invalid request body" }, 400, port);
8533
8761
  }
8534
8762
  const plan = updatePlan(id, parsed.data);
8763
+ logAudit("plan", id, "update", undefined, parsed.data);
8535
8764
  return json(plan, 200, port);
8536
8765
  } catch (e) {
8537
8766
  const status = e instanceof Error && e.name === "PlanNotFoundError" ? 404 : 500;
@@ -8545,6 +8774,7 @@ function createFetchHandler(getPort, dashboardDir, dashboardExists) {
8545
8774
  const deleted = deletePlan(id);
8546
8775
  if (!deleted)
8547
8776
  return json({ error: "Plan not found" }, 404, port);
8777
+ logAudit("plan", id, "delete");
8548
8778
  return json({ deleted: true }, 200, port);
8549
8779
  } catch (e) {
8550
8780
  return json({ error: e instanceof Error ? e.message : "Failed to delete plan" }, 500, port);
@@ -8619,6 +8849,7 @@ function createFetchHandler(getPort, dashboardDir, dashboardExists) {
8619
8849
  return json({ error: "Invalid request body" }, 400, port);
8620
8850
  }
8621
8851
  const apiKey = await createApiKey(parsed.data);
8852
+ logAudit("api_key", apiKey.id, "create");
8622
8853
  return json(apiKey, 201, port);
8623
8854
  } catch (e) {
8624
8855
  return json({ error: e instanceof Error ? e.message : "Failed to create API key" }, 500, port);
@@ -8644,6 +8875,56 @@ function createFetchHandler(getPort, dashboardDir, dashboardExists) {
8644
8875
  return json({ error: e instanceof Error ? e.message : "Failed to check auth status" }, 500, port);
8645
8876
  }
8646
8877
  }
8878
+ if (path === "/api/audit" && method === "GET") {
8879
+ try {
8880
+ const entityType = url.searchParams.get("entity_type") || undefined;
8881
+ const entityId = url.searchParams.get("entity_id") || undefined;
8882
+ const limit = parseInt(url.searchParams.get("limit") || "50", 10);
8883
+ const offset = parseInt(url.searchParams.get("offset") || "0", 10);
8884
+ const entries = getAuditLog(entityType, entityId, Math.min(limit, 200), offset);
8885
+ return json(entries, 200, port);
8886
+ } catch (e) {
8887
+ return json({ error: e instanceof Error ? e.message : "Failed to get audit log" }, 500, port);
8888
+ }
8889
+ }
8890
+ if (path === "/api/webhooks" && method === "GET") {
8891
+ try {
8892
+ const webhooks = listWebhooks();
8893
+ return json(webhooks, 200, port);
8894
+ } catch (e) {
8895
+ return json({ error: e instanceof Error ? e.message : "Failed to list webhooks" }, 500, port);
8896
+ }
8897
+ }
8898
+ if (path === "/api/webhooks" && method === "POST") {
8899
+ try {
8900
+ const body = await parseJsonBody(req);
8901
+ if (!body)
8902
+ return json({ error: "Invalid JSON" }, 400, port);
8903
+ if (!body.url || typeof body.url !== "string") {
8904
+ return json({ error: "Missing required field: url" }, 400, port);
8905
+ }
8906
+ const webhook = createWebhook({
8907
+ url: body.url,
8908
+ events: Array.isArray(body.events) ? body.events : undefined,
8909
+ secret: typeof body.secret === "string" ? body.secret : undefined
8910
+ });
8911
+ return json(webhook, 201, port);
8912
+ } catch (e) {
8913
+ return json({ error: e instanceof Error ? e.message : "Failed to create webhook" }, 500, port);
8914
+ }
8915
+ }
8916
+ const webhookDeleteMatch = path.match(/^\/api\/webhooks\/([^/]+)$/);
8917
+ if (webhookDeleteMatch && method === "DELETE") {
8918
+ try {
8919
+ const id = webhookDeleteMatch[1];
8920
+ const deleted = deleteWebhook(id);
8921
+ if (!deleted)
8922
+ return json({ error: "Webhook not found" }, 404, port);
8923
+ return json({ deleted: true }, 200, port);
8924
+ } catch (e) {
8925
+ return json({ error: e instanceof Error ? e.message : "Failed to delete webhook" }, 500, port);
8926
+ }
8927
+ }
8647
8928
  if (method === "OPTIONS") {
8648
8929
  return new Response(null, {
8649
8930
  headers: {
@@ -8655,12 +8936,12 @@ function createFetchHandler(getPort, dashboardDir, dashboardExists) {
8655
8936
  }
8656
8937
  if (hasDashboard && (method === "GET" || method === "HEAD")) {
8657
8938
  if (path !== "/") {
8658
- const filePath = join6(dir, path);
8939
+ const filePath = join7(dir, path);
8659
8940
  const res2 = serveStaticFile(filePath);
8660
8941
  if (res2)
8661
8942
  return res2;
8662
8943
  }
8663
- const indexPath = join6(dir, "index.html");
8944
+ const indexPath = join7(dir, "index.html");
8664
8945
  const res = serveStaticFile(indexPath);
8665
8946
  if (res)
8666
8947
  return res;
@@ -8671,7 +8952,7 @@ function createFetchHandler(getPort, dashboardDir, dashboardExists) {
8671
8952
  async function startServer(port, options) {
8672
8953
  const shouldOpen = options?.open ?? true;
8673
8954
  const dashboardDir = resolveDashboardDir();
8674
- const dashboardExists = existsSync6(dashboardDir);
8955
+ const dashboardExists = existsSync7(dashboardDir);
8675
8956
  if (!dashboardExists) {
8676
8957
  console.error(`
8677
8958
  Dashboard not found at: ${dashboardDir}`);
@@ -8735,6 +9016,10 @@ var init_serve = __esm(() => {
8735
9016
  init_comments();
8736
9017
  init_api_keys();
8737
9018
  init_search();
9019
+ init_audit();
9020
+ init_webhooks();
9021
+ init_rate_limit();
9022
+ init_env();
8738
9023
  MIME_TYPES = {
8739
9024
  ".html": "text/html; charset=utf-8",
8740
9025
  ".js": "application/javascript",
@@ -9890,13 +10175,13 @@ init_sync();
9890
10175
  init_config();
9891
10176
  import chalk from "chalk";
9892
10177
  import { execSync as execSync2 } from "child_process";
9893
- import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
9894
- import { basename, dirname as dirname3, join as join7, resolve as resolve2 } from "path";
10178
+ import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
10179
+ import { basename, dirname as dirname3, join as join8, resolve as resolve2 } from "path";
9895
10180
  import { fileURLToPath as fileURLToPath2 } from "url";
9896
10181
  function getPackageVersion2() {
9897
10182
  try {
9898
- const pkgPath = join7(dirname3(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
9899
- return JSON.parse(readFileSync3(pkgPath, "utf-8")).version || "0.0.0";
10183
+ const pkgPath = join8(dirname3(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
10184
+ return JSON.parse(readFileSync4(pkgPath, "utf-8")).version || "0.0.0";
9900
10185
  } catch {
9901
10186
  return "0.0.0";
9902
10187
  }
@@ -10512,8 +10797,8 @@ hooks.command("install").description("Install Claude Code hooks for auto-sync").
10512
10797
  if (p)
10513
10798
  todosBin = p;
10514
10799
  } catch {}
10515
- const hooksDir = join7(process.cwd(), ".claude", "hooks");
10516
- if (!existsSync7(hooksDir))
10800
+ const hooksDir = join8(process.cwd(), ".claude", "hooks");
10801
+ if (!existsSync8(hooksDir))
10517
10802
  mkdirSync3(hooksDir, { recursive: true });
10518
10803
  const hookScript = `#!/usr/bin/env bash
10519
10804
  # Auto-generated by: todos hooks install
@@ -10538,11 +10823,11 @@ esac
10538
10823
 
10539
10824
  exit 0
10540
10825
  `;
10541
- const hookPath = join7(hooksDir, "todos-sync.sh");
10826
+ const hookPath = join8(hooksDir, "todos-sync.sh");
10542
10827
  writeFileSync2(hookPath, hookScript);
10543
10828
  execSync2(`chmod +x "${hookPath}"`);
10544
10829
  console.log(chalk.green(`Hook script created: ${hookPath}`));
10545
- const settingsPath = join7(process.cwd(), ".claude", "settings.json");
10830
+ const settingsPath = join8(process.cwd(), ".claude", "settings.json");
10546
10831
  const settings = readJsonFile2(settingsPath);
10547
10832
  if (!settings["hooks"]) {
10548
10833
  settings["hooks"] = {};
@@ -10589,40 +10874,40 @@ function getMcpBinaryPath() {
10589
10874
  if (p)
10590
10875
  return p;
10591
10876
  } catch {}
10592
- const bunBin = join7(HOME2, ".bun", "bin", "todos-mcp");
10593
- if (existsSync7(bunBin))
10877
+ const bunBin = join8(HOME2, ".bun", "bin", "todos-mcp");
10878
+ if (existsSync8(bunBin))
10594
10879
  return bunBin;
10595
10880
  return "todos-mcp";
10596
10881
  }
10597
10882
  function readJsonFile2(path) {
10598
- if (!existsSync7(path))
10883
+ if (!existsSync8(path))
10599
10884
  return {};
10600
10885
  try {
10601
- return JSON.parse(readFileSync3(path, "utf-8"));
10886
+ return JSON.parse(readFileSync4(path, "utf-8"));
10602
10887
  } catch {
10603
10888
  return {};
10604
10889
  }
10605
10890
  }
10606
10891
  function writeJsonFile2(path, data) {
10607
10892
  const dir = dirname3(path);
10608
- if (!existsSync7(dir))
10893
+ if (!existsSync8(dir))
10609
10894
  mkdirSync3(dir, { recursive: true });
10610
10895
  writeFileSync2(path, JSON.stringify(data, null, 2) + `
10611
10896
  `);
10612
10897
  }
10613
10898
  function readTomlFile(path) {
10614
- if (!existsSync7(path))
10899
+ if (!existsSync8(path))
10615
10900
  return "";
10616
- return readFileSync3(path, "utf-8");
10901
+ return readFileSync4(path, "utf-8");
10617
10902
  }
10618
10903
  function writeTomlFile(path, content) {
10619
10904
  const dir = dirname3(path);
10620
- if (!existsSync7(dir))
10905
+ if (!existsSync8(dir))
10621
10906
  mkdirSync3(dir, { recursive: true });
10622
10907
  writeFileSync2(path, content);
10623
10908
  }
10624
10909
  function registerClaude(binPath, global) {
10625
- const configPath = global ? join7(HOME2, ".claude", ".mcp.json") : join7(process.cwd(), ".mcp.json");
10910
+ const configPath = global ? join8(HOME2, ".claude", ".mcp.json") : join8(process.cwd(), ".mcp.json");
10626
10911
  const config = readJsonFile2(configPath);
10627
10912
  if (!config["mcpServers"]) {
10628
10913
  config["mcpServers"] = {};
@@ -10637,7 +10922,7 @@ function registerClaude(binPath, global) {
10637
10922
  console.log(chalk.green(`Claude Code (${scope}): registered in ${configPath}`));
10638
10923
  }
10639
10924
  function unregisterClaude(global) {
10640
- const configPath = global ? join7(HOME2, ".claude", ".mcp.json") : join7(process.cwd(), ".mcp.json");
10925
+ const configPath = global ? join8(HOME2, ".claude", ".mcp.json") : join8(process.cwd(), ".mcp.json");
10641
10926
  const config = readJsonFile2(configPath);
10642
10927
  const servers = config["mcpServers"];
10643
10928
  if (!servers || !("todos" in servers)) {
@@ -10650,7 +10935,7 @@ function unregisterClaude(global) {
10650
10935
  console.log(chalk.green(`Claude Code (${scope}): unregistered from ${configPath}`));
10651
10936
  }
10652
10937
  function registerCodex(binPath) {
10653
- const configPath = join7(HOME2, ".codex", "config.toml");
10938
+ const configPath = join8(HOME2, ".codex", "config.toml");
10654
10939
  let content = readTomlFile(configPath);
10655
10940
  content = removeTomlBlock(content, "mcp_servers.todos");
10656
10941
  const block = `
@@ -10664,7 +10949,7 @@ args = []
10664
10949
  console.log(chalk.green(`Codex CLI: registered in ${configPath}`));
10665
10950
  }
10666
10951
  function unregisterCodex() {
10667
- const configPath = join7(HOME2, ".codex", "config.toml");
10952
+ const configPath = join8(HOME2, ".codex", "config.toml");
10668
10953
  let content = readTomlFile(configPath);
10669
10954
  if (!content.includes("[mcp_servers.todos]")) {
10670
10955
  console.log(chalk.dim(`Codex CLI: todos not found in ${configPath}`));
@@ -10697,7 +10982,7 @@ function removeTomlBlock(content, blockName) {
10697
10982
  `);
10698
10983
  }
10699
10984
  function registerGemini(binPath) {
10700
- const configPath = join7(HOME2, ".gemini", "settings.json");
10985
+ const configPath = join8(HOME2, ".gemini", "settings.json");
10701
10986
  const config = readJsonFile2(configPath);
10702
10987
  if (!config["mcpServers"]) {
10703
10988
  config["mcpServers"] = {};
@@ -10711,7 +10996,7 @@ function registerGemini(binPath) {
10711
10996
  console.log(chalk.green(`Gemini CLI: registered in ${configPath}`));
10712
10997
  }
10713
10998
  function unregisterGemini() {
10714
- const configPath = join7(HOME2, ".gemini", "settings.json");
10999
+ const configPath = join8(HOME2, ".gemini", "settings.json");
10715
11000
  const config = readJsonFile2(configPath);
10716
11001
  const servers = config["mcpServers"];
10717
11002
  if (!servers || !("todos" in servers)) {
package/dist/index.d.ts CHANGED
@@ -135,6 +135,8 @@ export interface TaskFilter {
135
135
  session_id?: string;
136
136
  tags?: string[];
137
137
  include_subtasks?: boolean;
138
+ limit?: number;
139
+ offset?: number;
138
140
  }
139
141
 
140
142
  // Task dependency
package/dist/index.js CHANGED
@@ -177,6 +177,36 @@ var MIGRATIONS = [
177
177
  );
178
178
  CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
179
179
  INSERT OR IGNORE INTO _migrations (id) VALUES (5);
180
+ `,
181
+ `
182
+ CREATE TABLE IF NOT EXISTS audit_log (
183
+ id TEXT PRIMARY KEY,
184
+ entity_type TEXT NOT NULL CHECK(entity_type IN ('task', 'plan', 'project', 'api_key', 'comment')),
185
+ entity_id TEXT NOT NULL,
186
+ action TEXT NOT NULL CHECK(action IN ('create', 'update', 'delete', 'start', 'complete', 'lock', 'unlock')),
187
+ actor TEXT,
188
+ changes TEXT,
189
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
190
+ );
191
+ CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id);
192
+ CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at);
193
+
194
+ CREATE TABLE IF NOT EXISTS webhooks (
195
+ id TEXT PRIMARY KEY,
196
+ url TEXT NOT NULL,
197
+ events TEXT NOT NULL DEFAULT '[]',
198
+ secret TEXT,
199
+ active INTEGER NOT NULL DEFAULT 1,
200
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
201
+ );
202
+
203
+ CREATE TABLE IF NOT EXISTS rate_limits (
204
+ key TEXT PRIMARY KEY,
205
+ count INTEGER NOT NULL DEFAULT 0,
206
+ window_start TEXT NOT NULL DEFAULT (datetime('now'))
207
+ );
208
+
209
+ INSERT OR IGNORE INTO _migrations (id) VALUES (6);
180
210
  `
181
211
  ];
182
212
  var _db = null;
@@ -499,9 +529,12 @@ function listTasks(filter = {}, db) {
499
529
  params.push(filter.plan_id);
500
530
  }
501
531
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
532
+ const limitVal = filter.limit || 100;
533
+ const offsetVal = filter.offset || 0;
534
+ params.push(limitVal, offsetVal);
502
535
  const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY
503
536
  CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END,
504
- created_at DESC`).all(...params);
537
+ created_at DESC LIMIT ? OFFSET ?`).all(...params);
505
538
  return rows.map(rowToTask);
506
539
  }
507
540
  function updateTask(id, input, db) {
@@ -1530,6 +1563,91 @@ function syncWithAgents(agents, taskListIdByAgent, projectId, direction = "both"
1530
1563
  }
1531
1564
  return { pushed, pulled, errors };
1532
1565
  }
1566
+ // src/db/audit.ts
1567
+ function rowToEntry(row) {
1568
+ return {
1569
+ ...row,
1570
+ changes: row.changes ? JSON.parse(row.changes) : null
1571
+ };
1572
+ }
1573
+ function logAudit(entityType, entityId, action, actor, changes, db) {
1574
+ const d = db || getDatabase();
1575
+ d.run(`INSERT INTO audit_log (id, entity_type, entity_id, action, actor, changes, created_at)
1576
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [uuid(), entityType, entityId, action, actor || null, changes ? JSON.stringify(changes) : null, now()]);
1577
+ }
1578
+ function getAuditLog(entityType, entityId, limit = 50, offset = 0, db) {
1579
+ const d = db || getDatabase();
1580
+ const conditions = [];
1581
+ const params = [];
1582
+ if (entityType) {
1583
+ conditions.push("entity_type = ?");
1584
+ params.push(entityType);
1585
+ }
1586
+ if (entityId) {
1587
+ conditions.push("entity_id = ?");
1588
+ params.push(entityId);
1589
+ }
1590
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
1591
+ params.push(limit, offset);
1592
+ const rows = d.query(`SELECT * FROM audit_log ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params);
1593
+ return rows.map(rowToEntry);
1594
+ }
1595
+ // src/db/webhooks.ts
1596
+ function rowToWebhook(row) {
1597
+ return {
1598
+ ...row,
1599
+ events: JSON.parse(row.events),
1600
+ active: row.active === 1
1601
+ };
1602
+ }
1603
+ function createWebhook(input, db) {
1604
+ const d = db || getDatabase();
1605
+ const id = uuid();
1606
+ d.run(`INSERT INTO webhooks (id, url, events, secret, created_at)
1607
+ VALUES (?, ?, ?, ?, ?)`, [id, input.url, JSON.stringify(input.events || []), input.secret || null, now()]);
1608
+ return getWebhook(id, d);
1609
+ }
1610
+ function getWebhook(id, db) {
1611
+ const d = db || getDatabase();
1612
+ const row = d.query("SELECT * FROM webhooks WHERE id = ?").get(id);
1613
+ return row ? rowToWebhook(row) : null;
1614
+ }
1615
+ function listWebhooks(db) {
1616
+ const d = db || getDatabase();
1617
+ return d.query("SELECT * FROM webhooks ORDER BY created_at DESC").all().map(rowToWebhook);
1618
+ }
1619
+ function deleteWebhook(id, db) {
1620
+ const d = db || getDatabase();
1621
+ return d.run("DELETE FROM webhooks WHERE id = ?", [id]).changes > 0;
1622
+ }
1623
+ async function dispatchWebhooks(event, payload, db) {
1624
+ const d = db || getDatabase();
1625
+ const rows = d.query("SELECT * FROM webhooks WHERE active = 1").all();
1626
+ const webhooks = rows.map(rowToWebhook).filter((w) => w.events.length === 0 || w.events.includes(event));
1627
+ for (const webhook of webhooks) {
1628
+ try {
1629
+ const headers = { "Content-Type": "application/json" };
1630
+ if (webhook.secret) {
1631
+ const encoder = new TextEncoder;
1632
+ const key = await crypto.subtle.importKey("raw", encoder.encode(webhook.secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
1633
+ const body = JSON.stringify({ event, data: payload, timestamp: now() });
1634
+ const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
1635
+ headers["X-Webhook-Signature"] = Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
1636
+ fetch(webhook.url, {
1637
+ method: "POST",
1638
+ headers,
1639
+ body
1640
+ }).catch(() => {});
1641
+ } else {
1642
+ fetch(webhook.url, {
1643
+ method: "POST",
1644
+ headers,
1645
+ body: JSON.stringify({ event, data: payload, timestamp: now() })
1646
+ }).catch(() => {});
1647
+ }
1648
+ } catch {}
1649
+ }
1650
+ }
1533
1651
  export {
1534
1652
  validateApiKey,
1535
1653
  updateTask,
@@ -1545,8 +1663,10 @@ export {
1545
1663
  resolvePartialId,
1546
1664
  resetDatabase,
1547
1665
  removeDependency,
1666
+ logAudit,
1548
1667
  lockTask,
1549
1668
  loadConfig,
1669
+ listWebhooks,
1550
1670
  listTasks,
1551
1671
  listSessions,
1552
1672
  listProjects,
@@ -1554,6 +1674,7 @@ export {
1554
1674
  listComments,
1555
1675
  listApiKeys,
1556
1676
  hasAnyApiKeys,
1677
+ getWebhook,
1557
1678
  getTaskWithRelations,
1558
1679
  getTaskDependents,
1559
1680
  getTaskDependencies,
@@ -1564,7 +1685,10 @@ export {
1564
1685
  getPlan,
1565
1686
  getDatabase,
1566
1687
  getComment,
1688
+ getAuditLog,
1567
1689
  ensureProject,
1690
+ dispatchWebhooks,
1691
+ deleteWebhook,
1568
1692
  deleteTask,
1569
1693
  deleteSession,
1570
1694
  deleteProject,
@@ -1572,6 +1696,7 @@ export {
1572
1696
  deleteComment,
1573
1697
  deleteApiKey,
1574
1698
  defaultSyncAgents,
1699
+ createWebhook,
1575
1700
  createTask,
1576
1701
  createSession,
1577
1702
  createProject,
package/dist/mcp/index.js CHANGED
@@ -4219,6 +4219,36 @@ var MIGRATIONS = [
4219
4219
  );
4220
4220
  CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
4221
4221
  INSERT OR IGNORE INTO _migrations (id) VALUES (5);
4222
+ `,
4223
+ `
4224
+ CREATE TABLE IF NOT EXISTS audit_log (
4225
+ id TEXT PRIMARY KEY,
4226
+ entity_type TEXT NOT NULL CHECK(entity_type IN ('task', 'plan', 'project', 'api_key', 'comment')),
4227
+ entity_id TEXT NOT NULL,
4228
+ action TEXT NOT NULL CHECK(action IN ('create', 'update', 'delete', 'start', 'complete', 'lock', 'unlock')),
4229
+ actor TEXT,
4230
+ changes TEXT,
4231
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
4232
+ );
4233
+ CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id);
4234
+ CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at);
4235
+
4236
+ CREATE TABLE IF NOT EXISTS webhooks (
4237
+ id TEXT PRIMARY KEY,
4238
+ url TEXT NOT NULL,
4239
+ events TEXT NOT NULL DEFAULT '[]',
4240
+ secret TEXT,
4241
+ active INTEGER NOT NULL DEFAULT 1,
4242
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
4243
+ );
4244
+
4245
+ CREATE TABLE IF NOT EXISTS rate_limits (
4246
+ key TEXT PRIMARY KEY,
4247
+ count INTEGER NOT NULL DEFAULT 0,
4248
+ window_start TEXT NOT NULL DEFAULT (datetime('now'))
4249
+ );
4250
+
4251
+ INSERT OR IGNORE INTO _migrations (id) VALUES (6);
4222
4252
  `
4223
4253
  ];
4224
4254
  var _db = null;
@@ -4455,9 +4485,12 @@ function listTasks(filter = {}, db) {
4455
4485
  params.push(filter.plan_id);
4456
4486
  }
4457
4487
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
4488
+ const limitVal = filter.limit || 100;
4489
+ const offsetVal = filter.offset || 0;
4490
+ params.push(limitVal, offsetVal);
4458
4491
  const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY
4459
4492
  CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END,
4460
- created_at DESC`).all(...params);
4493
+ created_at DESC LIMIT ? OFFSET ?`).all(...params);
4461
4494
  return rows.map(rowToTask);
4462
4495
  }
4463
4496
  function updateTask(id, input, db) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/todos",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",