feat: API Key Auth + erweiterte MCP Tools + Dokumentation
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
- API Key Middleware für alle /api/ Endpoints (X-API-Key, Bearer, Query) - API Key Management Routes (generate, revoke, status) - MCP Tools erweitert: 12 Tools (war 3) inkl. bulk ops, dashboard, search - Audit Logging für alle API/MCP Aktionen - docs/API-REFERENCE.md - vollständige API Dokumentation - docs/ARCHITECTURE.md - Architektur-Übersicht - Vorbereitung für NotiBot-Integration
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* API Key Authentication Middleware
|
||||
*
|
||||
* Allows external tools (like NotiBot) to authenticate via API key
|
||||
* instead of session cookies. Supports:
|
||||
* - Header: X-API-Key: <key>
|
||||
* - Header: Authorization: Bearer <key>
|
||||
* - Query param: ?apiKey=<key>
|
||||
*
|
||||
* If an API key is present and valid, the request is authenticated
|
||||
* as that user (req.user is set, req.isAuthenticated() returns true).
|
||||
* If no API key is present, falls through to normal session auth.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { storage } from "./storage.js";
|
||||
import { User } from "../shared/schema.js";
|
||||
|
||||
export function apiKeyAuth() {
|
||||
return async (req: Request, _res: Response, next: NextFunction) => {
|
||||
// Skip if already authenticated via session
|
||||
if (req.isAuthenticated && req.isAuthenticated()) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Extract API key from various sources
|
||||
let apiKey: string | undefined;
|
||||
|
||||
// 1. X-API-Key header
|
||||
const xApiKey = req.headers["x-api-key"];
|
||||
if (typeof xApiKey === "string") {
|
||||
apiKey = xApiKey;
|
||||
}
|
||||
|
||||
// 2. Authorization: Bearer <key>
|
||||
if (!apiKey) {
|
||||
const auth = req.headers["authorization"];
|
||||
if (typeof auth === "string" && auth.startsWith("Bearer ")) {
|
||||
apiKey = auth.substring(7);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Query parameter
|
||||
if (!apiKey && typeof req.query.apiKey === "string") {
|
||||
apiKey = req.query.apiKey;
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return next(); // No API key, fall through to session auth
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await storage.getUserByApiKey(apiKey);
|
||||
if (user && user.isActive) {
|
||||
// Attach user to request (mimics passport behavior)
|
||||
(req as any).user = user;
|
||||
(req as any).isAuthenticated = () => true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[API Key Auth] Error:", err);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { registerRoutes } from "./routes.js";
|
||||
import { initializeDatabase, closeDatabase } from "./db.js";
|
||||
import { storage } from "./storage";
|
||||
import { initializeVapid } from "./push.js";
|
||||
import { apiKeyAuth } from "./api-key-auth.js";
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", true);
|
||||
|
||||
+424
-68
@@ -29,14 +29,12 @@ export class McpServer {
|
||||
if (!key && req.query.apiKey) {
|
||||
key = req.query.apiKey as string;
|
||||
}
|
||||
// Bearer token support
|
||||
if (!key && req.headers["authorization"]) {
|
||||
const auth = req.headers["authorization"];
|
||||
if (auth.startsWith("Bearer ")) {
|
||||
key = auth.substring(7);
|
||||
}
|
||||
}
|
||||
|
||||
if (!key) return null;
|
||||
return (await storage.getUserByApiKey(key)) || null;
|
||||
}
|
||||
@@ -52,7 +50,7 @@ export class McpServer {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"Access-Control-Allow-Origin": "*", // Allow local connections
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
});
|
||||
|
||||
const sessionId = randomBytes(8).toString("hex");
|
||||
@@ -61,7 +59,6 @@ export class McpServer {
|
||||
const endpoint = `/api/mcp/messages`;
|
||||
res.write(`event: endpoint\ndata: ${endpoint}\n\n`);
|
||||
|
||||
// Keep alive
|
||||
const interval = setInterval(() => {
|
||||
res.write(": keepalive\n\n");
|
||||
}, 15000);
|
||||
@@ -104,58 +101,16 @@ export class McpServer {
|
||||
case "initialize":
|
||||
return {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {}
|
||||
},
|
||||
serverInfo: {
|
||||
name: "TaskFlow MCP",
|
||||
version: "1.0.0"
|
||||
}
|
||||
capabilities: { tools: {}, resources: {} },
|
||||
serverInfo: { name: "TaskFlow MCP", version: "2.0.0" }
|
||||
};
|
||||
case "tools/list": // MCP method
|
||||
case "listTools": // Legacy fallback
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: "list_tasks",
|
||||
description: "List all tasks for the user",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["todo", "inProgress", "done"], description: "Filter by status" },
|
||||
limit: { type: "number", description: "Limit number of tasks" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "create_task",
|
||||
description: "Create a new task",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Title of the task" },
|
||||
description: { type: "string", description: "Description" },
|
||||
priority: { type: "string", enum: ["low", "medium", "high"] }
|
||||
},
|
||||
required: ["title"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "complete_task",
|
||||
description: "Mark a task as completed",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Task ID" }
|
||||
},
|
||||
required: ["id"]
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
case "tools/call": // MCP method
|
||||
case "callTool": // Legacy
|
||||
|
||||
case "tools/list":
|
||||
case "listTools":
|
||||
return { tools: this.getToolDefinitions() };
|
||||
|
||||
case "tools/call":
|
||||
case "callTool":
|
||||
return await this.handleToolCall(req.params.name, req.params.arguments, user);
|
||||
|
||||
case "notifications/initialized":
|
||||
@@ -166,43 +121,444 @@ export class McpServer {
|
||||
}
|
||||
}
|
||||
|
||||
private getToolDefinitions() {
|
||||
return [
|
||||
// ===== TASK MANAGEMENT =====
|
||||
{
|
||||
name: "list_tasks",
|
||||
description: "List all tasks. Optional filters: status, priority, labelId, limit.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["todo", "inProgress", "done"], description: "Filter by status" },
|
||||
priority: { type: "string", enum: ["low", "medium", "high"], description: "Filter by priority" },
|
||||
labelId: { type: "string", description: "Filter by label ID" },
|
||||
limit: { type: "number", description: "Max results" },
|
||||
includeCompleted: { type: "boolean", description: "Include done tasks (default: false)" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_task",
|
||||
description: "Get a single task by ID with full details.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { id: { type: "string", description: "Task ID" } },
|
||||
required: ["id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "search_tasks",
|
||||
description: "Search tasks by title/description text.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { query: { type: "string", description: "Search text" } },
|
||||
required: ["query"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "create_task",
|
||||
description: "Create a new task. Returns the created task with ID.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Task title" },
|
||||
description: { type: "string", description: "Task description (markdown supported)" },
|
||||
priority: { type: "string", enum: ["low", "medium", "high"], description: "Priority level" },
|
||||
status: { type: "string", enum: ["todo", "inProgress", "done"], description: "Initial status" },
|
||||
dueDate: { type: "string", description: "Due date (ISO 8601)" },
|
||||
labelId: { type: "string", description: "Label/category ID" },
|
||||
estimatedDuration: { type: "number", description: "Estimated minutes" },
|
||||
energyLevel: { type: "string", enum: ["low", "medium", "high"], description: "Required energy level" },
|
||||
parentTaskId: { type: "string", description: "Parent task ID (for subtasks)" },
|
||||
notes: { type: "string", description: "Additional notes" }
|
||||
},
|
||||
required: ["title"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "update_task",
|
||||
description: "Update an existing task. Only provided fields are changed.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Task ID" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
priority: { type: "string", enum: ["low", "medium", "high"] },
|
||||
status: { type: "string", enum: ["todo", "inProgress", "done"] },
|
||||
dueDate: { type: "string", description: "Due date (ISO 8601) or null to clear" },
|
||||
labelId: { type: "string", description: "Label ID or null to clear" },
|
||||
estimatedDuration: { type: "number" },
|
||||
energyLevel: { type: "string", enum: ["low", "medium", "high"] },
|
||||
notes: { type: "string" }
|
||||
},
|
||||
required: ["id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "complete_task",
|
||||
description: "Mark a task as done.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { id: { type: "string", description: "Task ID" } },
|
||||
required: ["id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "delete_task",
|
||||
description: "Permanently delete a task.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { id: { type: "string", description: "Task ID" } },
|
||||
required: ["id"]
|
||||
}
|
||||
},
|
||||
|
||||
// ===== LABELS =====
|
||||
{
|
||||
name: "list_labels",
|
||||
description: "List all available labels/categories.",
|
||||
inputSchema: { type: "object", properties: {} }
|
||||
},
|
||||
{
|
||||
name: "create_label",
|
||||
description: "Create a new label/category.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Label name" },
|
||||
color: { type: "string", description: "Hex color (e.g. #ff5733)" },
|
||||
domain: { type: "string", enum: ["work", "personal", "neutral"], description: "Domain" }
|
||||
},
|
||||
required: ["name", "color"]
|
||||
}
|
||||
},
|
||||
|
||||
// ===== USER & STATS =====
|
||||
{
|
||||
name: "get_user_stats",
|
||||
description: "Get current user stats: XP, level, streak, task counts.",
|
||||
inputSchema: { type: "object", properties: {} }
|
||||
},
|
||||
{
|
||||
name: "get_dashboard",
|
||||
description: "Get a dashboard overview: active tasks, upcoming due, stats.",
|
||||
inputSchema: { type: "object", properties: {} }
|
||||
},
|
||||
|
||||
// ===== BULK OPERATIONS =====
|
||||
{
|
||||
name: "bulk_create_tasks",
|
||||
description: "Create multiple tasks at once. Returns array of created tasks.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tasks: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
priority: { type: "string", enum: ["low", "medium", "high"] },
|
||||
dueDate: { type: "string" },
|
||||
labelId: { type: "string" },
|
||||
estimatedDuration: { type: "number" }
|
||||
},
|
||||
required: ["title"]
|
||||
},
|
||||
description: "Array of task objects"
|
||||
}
|
||||
},
|
||||
required: ["tasks"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "bulk_update_tasks",
|
||||
description: "Update multiple tasks. Each item needs an id + fields to change.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
updates: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
status: { type: "string" },
|
||||
priority: { type: "string" },
|
||||
dueDate: { type: "string" }
|
||||
},
|
||||
required: ["id"]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["updates"]
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private async handleToolCall(name: string, args: any, user: User) {
|
||||
switch (name) {
|
||||
case "list_tasks": {
|
||||
let tasks = await storage.getTasksForUser(user.id);
|
||||
if (args?.status) {
|
||||
tasks = tasks.filter((t) => t.status === args.status);
|
||||
if (!args?.includeCompleted) {
|
||||
tasks = tasks.filter(t => t.status !== "done");
|
||||
}
|
||||
if (args?.limit) {
|
||||
tasks = tasks.slice(0, args.limit);
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify(tasks, null, 2) }] };
|
||||
if (args?.status) tasks = tasks.filter(t => t.status === args.status);
|
||||
if (args?.priority) tasks = tasks.filter(t => t.priority === args.priority);
|
||||
if (args?.labelId) tasks = tasks.filter(t => t.labelId === args.labelId);
|
||||
if (args?.limit) tasks = tasks.slice(0, args.limit);
|
||||
|
||||
// Return compact format
|
||||
const compact = tasks.map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
status: t.status,
|
||||
priority: t.priority,
|
||||
dueDate: t.dueDate,
|
||||
labelId: t.labelId,
|
||||
estimatedDuration: t.estimatedDuration,
|
||||
timeTracked: t.timeTracked,
|
||||
energyLevel: t.energyLevel
|
||||
}));
|
||||
return { content: [{ type: "text", text: JSON.stringify(compact, null, 2) }] };
|
||||
}
|
||||
|
||||
case "get_task": {
|
||||
if (!args.id) throw new Error("Task ID is required");
|
||||
const task = await storage.getTask(args.id);
|
||||
if (!task || task.userId !== user.id) throw new Error("Task not found");
|
||||
return { content: [{ type: "text", text: JSON.stringify(task, null, 2) }] };
|
||||
}
|
||||
|
||||
case "search_tasks": {
|
||||
if (!args.query) throw new Error("Search query is required");
|
||||
const tasks = await storage.searchTasks(args.query, user.id);
|
||||
const compact = tasks.map(t => ({
|
||||
id: t.id, title: t.title, status: t.status,
|
||||
priority: t.priority, dueDate: t.dueDate
|
||||
}));
|
||||
return { content: [{ type: "text", text: JSON.stringify(compact, null, 2) }] };
|
||||
}
|
||||
|
||||
case "create_task": {
|
||||
if (!args.title) throw new Error("Title is required");
|
||||
const task = await storage.createTask({
|
||||
title: args.title,
|
||||
description: args.description || "",
|
||||
priority: args.priority || "medium",
|
||||
status: "todo",
|
||||
status: args.status || "todo",
|
||||
isTracking: false,
|
||||
timeTracked: 0,
|
||||
energyLevel: "medium",
|
||||
estimatedDuration: 15,
|
||||
dueDate: null,
|
||||
notes: "",
|
||||
labelId: null,
|
||||
energyLevel: args.energyLevel || "medium",
|
||||
estimatedDuration: args.estimatedDuration || 15,
|
||||
dueDate: args.dueDate || null,
|
||||
notes: args.notes || "",
|
||||
labelId: args.labelId || null,
|
||||
parentTaskId: args.parentTaskId || null,
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
await storage.createAuditLog({
|
||||
userId: user.id,
|
||||
action: "CREATE",
|
||||
entityType: "TASK",
|
||||
entityId: task.id,
|
||||
details: { title: task.title, source: "MCP/API" },
|
||||
source: "API"
|
||||
});
|
||||
|
||||
return { content: [{ type: "text", text: JSON.stringify(task, null, 2) }] };
|
||||
}
|
||||
|
||||
case "update_task": {
|
||||
if (!args.id) throw new Error("Task ID is required");
|
||||
const task = await storage.getTask(args.id);
|
||||
if (!task || task.userId !== user.id) throw new Error("Task not found");
|
||||
|
||||
const { id, ...updates } = args;
|
||||
const updated = await storage.updateTask(id, updates);
|
||||
|
||||
await storage.createAuditLog({
|
||||
userId: user.id,
|
||||
action: "UPDATE",
|
||||
entityType: "TASK",
|
||||
entityId: id,
|
||||
details: { ...updates, source: "MCP/API" },
|
||||
source: "API"
|
||||
});
|
||||
|
||||
return { content: [{ type: "text", text: JSON.stringify(updated, null, 2) }] };
|
||||
}
|
||||
|
||||
case "complete_task": {
|
||||
if (!args.id) throw new Error("ID is required");
|
||||
if (!args.id) throw new Error("Task ID is required");
|
||||
const task = await storage.getTask(args.id);
|
||||
if (!task || task.userId !== user.id) throw new Error("Task not found");
|
||||
const updated = await storage.updateTask(args.id, { status: "done" });
|
||||
|
||||
await storage.createAuditLog({
|
||||
userId: user.id,
|
||||
action: "UPDATE",
|
||||
entityType: "TASK",
|
||||
entityId: args.id,
|
||||
details: { status: "done", source: "MCP/API" },
|
||||
source: "API"
|
||||
});
|
||||
|
||||
return { content: [{ type: "text", text: JSON.stringify(updated, null, 2) }] };
|
||||
}
|
||||
|
||||
case "delete_task": {
|
||||
if (!args.id) throw new Error("Task ID is required");
|
||||
const task = await storage.getTask(args.id);
|
||||
if (!task || task.userId !== user.id) throw new Error("Task not found");
|
||||
await storage.deleteTask(args.id);
|
||||
|
||||
await storage.createAuditLog({
|
||||
userId: user.id,
|
||||
action: "DELETE",
|
||||
entityType: "TASK",
|
||||
entityId: args.id,
|
||||
details: { title: task.title, source: "MCP/API" },
|
||||
source: "API"
|
||||
});
|
||||
|
||||
return { content: [{ type: "text", text: `Task "${task.title}" deleted.` }] };
|
||||
}
|
||||
|
||||
case "list_labels": {
|
||||
const labels = await storage.getAllLabels();
|
||||
const visible = labels.filter(l =>
|
||||
l.creatorId === user.id || l.creatorId === null
|
||||
);
|
||||
return { content: [{ type: "text", text: JSON.stringify(visible, null, 2) }] };
|
||||
}
|
||||
|
||||
case "create_label": {
|
||||
if (!args.name || !args.color) throw new Error("Name and color are required");
|
||||
const label = await storage.createLabel({
|
||||
name: args.name,
|
||||
color: args.color,
|
||||
domain: args.domain || "neutral",
|
||||
creatorId: user.id
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(label, null, 2) }] };
|
||||
}
|
||||
|
||||
case "get_user_stats": {
|
||||
const freshUser = await storage.getUser(user.id);
|
||||
if (!freshUser) throw new Error("User not found");
|
||||
const tasks = await storage.getTasksForUser(user.id);
|
||||
const active = tasks.filter(t => t.status !== "done");
|
||||
const done = tasks.filter(t => t.status === "done");
|
||||
const overdue = active.filter(t => t.dueDate && new Date(t.dueDate) < new Date());
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text", text: JSON.stringify({
|
||||
username: freshUser.username,
|
||||
xp: freshUser.xp,
|
||||
level: freshUser.level,
|
||||
streak: freshUser.currentStreak,
|
||||
tasks: {
|
||||
total: tasks.length,
|
||||
active: active.length,
|
||||
completed: done.length,
|
||||
overdue: overdue.length,
|
||||
byPriority: {
|
||||
high: active.filter(t => t.priority === "high").length,
|
||||
medium: active.filter(t => t.priority === "medium").length,
|
||||
low: active.filter(t => t.priority === "low").length
|
||||
}
|
||||
}
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
case "get_dashboard": {
|
||||
const tasks = await storage.getTasksForUser(user.id);
|
||||
const labels = await storage.getAllLabels();
|
||||
const now = new Date();
|
||||
const in48h = new Date(now.getTime() + 48 * 60 * 60 * 1000);
|
||||
|
||||
const active = tasks.filter(t => t.status !== "done");
|
||||
const upcoming = active
|
||||
.filter(t => t.dueDate && new Date(t.dueDate) <= in48h)
|
||||
.sort((a, b) => new Date(a.dueDate!).getTime() - new Date(b.dueDate!).getTime());
|
||||
const overdue = active.filter(t => t.dueDate && new Date(t.dueDate) < now);
|
||||
const highPriority = active.filter(t => t.priority === "high");
|
||||
|
||||
const labelMap = new Map(labels.map(l => [l.id, l.name]));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text", text: JSON.stringify({
|
||||
summary: {
|
||||
activeTasks: active.length,
|
||||
overdue: overdue.length,
|
||||
dueSoon: upcoming.length,
|
||||
highPriority: highPriority.length
|
||||
},
|
||||
overdueTasks: overdue.map(t => ({
|
||||
id: t.id, title: t.title, dueDate: t.dueDate,
|
||||
priority: t.priority, label: labelMap.get(t.labelId || "") || null
|
||||
})),
|
||||
upcomingTasks: upcoming.slice(0, 10).map(t => ({
|
||||
id: t.id, title: t.title, dueDate: t.dueDate,
|
||||
priority: t.priority, label: labelMap.get(t.labelId || "") || null
|
||||
})),
|
||||
highPriorityTasks: highPriority.map(t => ({
|
||||
id: t.id, title: t.title, dueDate: t.dueDate,
|
||||
label: labelMap.get(t.labelId || "") || null
|
||||
}))
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
case "bulk_create_tasks": {
|
||||
if (!args.tasks || !Array.isArray(args.tasks)) throw new Error("tasks array is required");
|
||||
const created = [];
|
||||
for (const t of args.tasks) {
|
||||
if (!t.title) continue;
|
||||
const task = await storage.createTask({
|
||||
title: t.title,
|
||||
description: t.description || "",
|
||||
priority: t.priority || "medium",
|
||||
status: "todo",
|
||||
isTracking: false,
|
||||
timeTracked: 0,
|
||||
energyLevel: t.energyLevel || "medium",
|
||||
estimatedDuration: t.estimatedDuration || 15,
|
||||
dueDate: t.dueDate || null,
|
||||
notes: "",
|
||||
labelId: t.labelId || null,
|
||||
userId: user.id
|
||||
});
|
||||
created.push({ id: task.id, title: task.title });
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify({ created: created.length, tasks: created }, null, 2) }] };
|
||||
}
|
||||
|
||||
case "bulk_update_tasks": {
|
||||
if (!args.updates || !Array.isArray(args.updates)) throw new Error("updates array is required");
|
||||
const results = [];
|
||||
for (const u of args.updates) {
|
||||
if (!u.id) continue;
|
||||
const task = await storage.getTask(u.id);
|
||||
if (!task || task.userId !== user.id) continue;
|
||||
const { id, ...updates } = u;
|
||||
const updated = await storage.updateTask(id, updates);
|
||||
results.push({ id: updated?.id, title: updated?.title, status: updated?.status });
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify({ updated: results.length, tasks: results }, null, 2) }] };
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Tool ${name} not implemented`);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const recurrenceService = new RecurrenceService(storage);
|
||||
const gamificationService = new GamificationService(storage);
|
||||
|
||||
import { setupAuth, hashPassword, comparePassword } from "./auth.js";
|
||||
import { apiKeyAuth } from "./api-key-auth.js";
|
||||
|
||||
function isAdmin(req: any, res: any, next: any) {
|
||||
if (req.isAuthenticated() && req.user.role === 'admin') {
|
||||
@@ -27,6 +28,10 @@ function isAdmin(req: any, res: any, next: any) {
|
||||
export async function registerRoutes(app: Express): Promise<Server> {
|
||||
setupAuth(app);
|
||||
|
||||
// API Key auth middleware - must be after session setup
|
||||
// Allows external tools to authenticate via X-API-Key header
|
||||
app.use("/api", apiKeyAuth());
|
||||
|
||||
// Update user schedule
|
||||
app.patch("/api/user/schedule", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
@@ -838,6 +843,62 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
|
||||
}
|
||||
});
|
||||
|
||||
// --- API Key Management Routes ---
|
||||
|
||||
// Get current API key (masked)
|
||||
app.get("/api/user/api-key", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const user = req.user as User;
|
||||
if (user.apiKey) {
|
||||
// Show first 8 and last 4 chars
|
||||
const masked = user.apiKey.substring(0, 8) + "..." + user.apiKey.substring(user.apiKey.length - 4);
|
||||
res.json({ hasKey: true, maskedKey: masked });
|
||||
} else {
|
||||
res.json({ hasKey: false, maskedKey: null });
|
||||
}
|
||||
});
|
||||
|
||||
// Generate new API key
|
||||
app.post("/api/user/api-key", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const user = req.user as User;
|
||||
const crypto = await import("crypto");
|
||||
const newKey = "tf_" + crypto.randomBytes(32).toString("hex");
|
||||
|
||||
await storage.updateUserApiKey(user.id, newKey);
|
||||
|
||||
await storage.createAuditLog({
|
||||
userId: user.id,
|
||||
action: "CREATE",
|
||||
entityType: "API_KEY",
|
||||
entityId: user.id,
|
||||
details: { message: "API key generated" },
|
||||
source: "USER"
|
||||
});
|
||||
|
||||
// Return full key ONCE (user must save it)
|
||||
res.json({ apiKey: newKey, message: "Save this key - it won't be shown again in full." });
|
||||
});
|
||||
|
||||
// Revoke API key
|
||||
app.delete("/api/user/api-key", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const user = req.user as User;
|
||||
|
||||
await storage.updateUserApiKey(user.id, null);
|
||||
|
||||
await storage.createAuditLog({
|
||||
userId: user.id,
|
||||
action: "DELETE",
|
||||
entityType: "API_KEY",
|
||||
entityId: user.id,
|
||||
details: { message: "API key revoked" },
|
||||
source: "USER"
|
||||
});
|
||||
|
||||
res.json({ message: "API key revoked" });
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get("/api/health", (req, res) => {
|
||||
res.status(200).json({ status: "ok", timestamp: new Date().toISOString() });
|
||||
|
||||
Reference in New Issue
Block a user