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,181 @@
|
||||
# TaskFlow API Reference
|
||||
|
||||
**Base URL:** `https://task.nothaft.cloud`
|
||||
**Stand:** 2026-02-03
|
||||
|
||||
---
|
||||
|
||||
## Authentifizierung
|
||||
|
||||
Zwei Methoden:
|
||||
|
||||
### 1. Session Cookie (Browser)
|
||||
Standard Login über `/api/login` mit Username/Password.
|
||||
|
||||
### 2. API Key (Externe Tools / Bots)
|
||||
Für automatisierte Zugriffe. Key wird über die GUI oder API generiert.
|
||||
|
||||
**Übergabe:**
|
||||
```
|
||||
X-API-Key: tf_abc123...
|
||||
# oder
|
||||
Authorization: Bearer tf_abc123...
|
||||
# oder
|
||||
?apiKey=tf_abc123...
|
||||
```
|
||||
|
||||
### API Key generieren
|
||||
```bash
|
||||
# Generieren (eingeloggt via Session)
|
||||
POST /api/user/api-key
|
||||
→ { "apiKey": "tf_...", "message": "Save this key..." }
|
||||
|
||||
# Status prüfen
|
||||
GET /api/user/api-key
|
||||
→ { "hasKey": true, "maskedKey": "tf_abc12...xyz9" }
|
||||
|
||||
# Widerrufen
|
||||
DELETE /api/user/api-key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## REST Endpoints
|
||||
|
||||
### Health
|
||||
```
|
||||
GET /api/health
|
||||
→ { "status": "ok", "timestamp": "..." }
|
||||
```
|
||||
|
||||
### Tasks
|
||||
|
||||
```bash
|
||||
# Alle Tasks
|
||||
GET /api/tasks
|
||||
→ [{ id, title, status, priority, dueDate, ... }]
|
||||
|
||||
# Einzelner Task
|
||||
GET /api/tasks/:id
|
||||
|
||||
# Task erstellen
|
||||
POST /api/tasks
|
||||
{ "title": "...", "priority": "high", "dueDate": "2026-02-05", "labelId": "..." }
|
||||
|
||||
# Task updaten
|
||||
PATCH /api/tasks/:id
|
||||
{ "status": "done", "priority": "low" }
|
||||
|
||||
# Task löschen
|
||||
DELETE /api/tasks/:id
|
||||
```
|
||||
|
||||
### Labels
|
||||
|
||||
```bash
|
||||
GET /api/labels
|
||||
POST /api/labels { "name": "Work", "color": "#ff5733" }
|
||||
PATCH /api/labels/:id
|
||||
DELETE /api/labels/:id
|
||||
```
|
||||
|
||||
### User
|
||||
|
||||
```bash
|
||||
GET /api/user # Eigenes Profil + Stats
|
||||
GET /api/user/api-key # API Key Status
|
||||
POST /api/user/api-key # API Key generieren
|
||||
DELETE /api/user/api-key # API Key widerrufen
|
||||
PATCH /api/user/profile # E-Mail ändern
|
||||
PATCH /api/user/password # Passwort ändern
|
||||
PATCH /api/user/schedule # Arbeitszeiten
|
||||
PATCH /api/user/privacy # Privatsphäre-Einstellungen
|
||||
```
|
||||
|
||||
### Gamification
|
||||
|
||||
```bash
|
||||
GET /api/user/history # XP-Verlauf
|
||||
GET /api/user/inventory # Erworbene Rewards
|
||||
GET /api/leaderboard # Bestenliste
|
||||
GET /api/rewards # Verfügbare Rewards
|
||||
POST /api/rewards/purchase # Reward kaufen
|
||||
```
|
||||
|
||||
### Analytics
|
||||
|
||||
```bash
|
||||
GET /api/analytics/weekly
|
||||
GET /api/analytics/monthly
|
||||
GET /api/analytics/yearly
|
||||
GET /api/analytics/time-distribution?period=week
|
||||
```
|
||||
|
||||
### AI Chat
|
||||
|
||||
```bash
|
||||
GET /api/ai/status # AI konfiguriert?
|
||||
GET /api/ai/conversations # Alle Gespräche
|
||||
POST /api/ai/conversations # Neues Gespräch
|
||||
POST /api/ai/chat # Nachricht senden
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MCP (Model Context Protocol)
|
||||
|
||||
Für AI-Tool-Integration (Claude, etc.)
|
||||
|
||||
```
|
||||
GET /api/mcp/sse # SSE Stream (mit API Key)
|
||||
POST /api/mcp/messages # JSON-RPC Nachrichten
|
||||
```
|
||||
|
||||
### Verfügbare MCP Tools
|
||||
|
||||
| Tool | Beschreibung |
|
||||
|---|---|
|
||||
| `list_tasks` | Tasks auflisten (Filter: status, priority, labelId) |
|
||||
| `get_task` | Einzelnen Task laden |
|
||||
| `search_tasks` | Tasks durchsuchen |
|
||||
| `create_task` | Task erstellen |
|
||||
| `update_task` | Task aktualisieren |
|
||||
| `complete_task` | Task als erledigt markieren |
|
||||
| `delete_task` | Task löschen |
|
||||
| `list_labels` | Labels auflisten |
|
||||
| `create_label` | Label erstellen |
|
||||
| `get_user_stats` | User-Statistiken |
|
||||
| `get_dashboard` | Dashboard-Übersicht |
|
||||
| `bulk_create_tasks` | Mehrere Tasks auf einmal erstellen |
|
||||
| `bulk_update_tasks` | Mehrere Tasks auf einmal updaten |
|
||||
|
||||
### Beispiel: Task per API Key erstellen
|
||||
|
||||
```bash
|
||||
curl -X POST https://task.nothaft.cloud/api/tasks \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: tf_your_key_here" \
|
||||
-d '{"title": "Report schreiben", "priority": "high", "dueDate": "2026-02-10"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NotiBot Integration
|
||||
|
||||
NotiBot nutzt die REST API mit API Key um Tasks für Paul zu verwalten.
|
||||
|
||||
### Konfiguration
|
||||
- API Key in NotiBot Memory gespeichert
|
||||
- Zugriff via `web_fetch` auf `https://task.nothaft.cloud/api/*`
|
||||
- Audit-Logs zeigen `source: "API"` für Bot-Aktionen
|
||||
|
||||
### Was NotiBot kann
|
||||
- Tasks erstellen, updaten, abschließen, löschen
|
||||
- Labels verwalten
|
||||
- Dashboard/Stats abrufen
|
||||
- Bulk-Operationen
|
||||
- Erinnerungen basierend auf Due-Dates
|
||||
|
||||
---
|
||||
|
||||
*Letzte Aktualisierung: 2026-02-03*
|
||||
@@ -0,0 +1,158 @@
|
||||
# TaskFlow Architektur
|
||||
|
||||
**Stand:** 2026-02-03
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| Schicht | Technologie |
|
||||
|---|---|
|
||||
| **Frontend** | React 18, TypeScript, Tailwind CSS, shadcn/ui, Vite |
|
||||
| **Backend** | Express.js, TypeScript, Node.js |
|
||||
| **Datenbank** | PostgreSQL 15, Drizzle ORM |
|
||||
| **State** | TanStack Query (Client), Express Session (Server) |
|
||||
| **Auth** | Passport.js (Local Strategy), API Key, 2FA (Email OTP) |
|
||||
| **Build** | Vite (Frontend), ESBuild (Server Bundle) |
|
||||
| **Deployment** | Docker, Docker Compose |
|
||||
|
||||
---
|
||||
|
||||
## Verzeichnisstruktur
|
||||
|
||||
```
|
||||
/opt/task-manager/
|
||||
├── client/ # React Frontend
|
||||
│ └── src/
|
||||
│ ├── components/ # UI-Komponenten
|
||||
│ │ ├── adhd/ # ADHD-Mode Features
|
||||
│ │ ├── admin/ # Admin-Panel
|
||||
│ │ └── gamification/ # Gamification UI
|
||||
│ ├── pages/ # Seitenkomponenten
|
||||
│ ├── hooks/ # Custom React Hooks
|
||||
│ └── lib/ # Utilities, API Client
|
||||
├── server/ # Express Backend
|
||||
│ ├── index.ts # Server Entry Point
|
||||
│ ├── routes.ts # API Routes (~2400 Zeilen)
|
||||
│ ├── storage.ts # Storage Interface + Implementierungen
|
||||
│ ├── auth.ts # Passport Auth + Session
|
||||
│ ├── api-key-auth.ts # API Key Middleware [NEU]
|
||||
│ ├── mcp.ts # MCP Server (JSON-RPC)
|
||||
│ ├── ai.ts # AI Service (Chat, Scheduling)
|
||||
│ ├── gamification.ts # XP, Levels, Streaks
|
||||
│ ├── email.ts # SMTP Email Service
|
||||
│ ├── push.ts # Web Push Notifications
|
||||
│ ├── db.ts # Database Connection
|
||||
│ └── services/
|
||||
│ └── recurrence.ts # Recurring Tasks
|
||||
├── shared/ # Shared Types
|
||||
│ ├── schema.ts # Drizzle DB Schema + Zod
|
||||
│ └── gamification.ts # XP/Level Constants
|
||||
├── docs/ # Dokumentation
|
||||
│ ├── ARCHITECTURE.md # Diese Datei
|
||||
│ ├── API-REFERENCE.md # API Dokumentation
|
||||
│ └── REFACTORING-REVIEW.md # Code Review
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
└── package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Core
|
||||
- Task CRUD mit Labels, Prioritäten, Due-Dates
|
||||
- Kalender-Ansicht (Drag & Drop)
|
||||
- Kanban Board (Traditional, Weekly, Monthly)
|
||||
- Subtasks / Parent-Child Beziehungen
|
||||
- Time Tracking mit Timer
|
||||
- Task Sharing (User-to-User, Label Sharing)
|
||||
|
||||
### Gamification
|
||||
- XP-System mit Levels
|
||||
- Tägliche Streaks
|
||||
- Rewards Shop
|
||||
- Leaderboard
|
||||
- Daily Challenges
|
||||
|
||||
### ADHD Mode
|
||||
- Quick Win List (kurze Tasks zuerst)
|
||||
- Single Task View (Fokus-Modus)
|
||||
- Energy Check-In (Low/Medium/High)
|
||||
- Break Reminders
|
||||
- Hyperfocus Guard
|
||||
- Visual Timer
|
||||
- Body Doubling Sessions
|
||||
|
||||
### AI Integration
|
||||
- Chat mit Kontext (Tasks, User-Profil)
|
||||
- Auto-Scheduling (AI plant Task-Termine)
|
||||
- Task Completion Analysis
|
||||
- Task Breakdown (AI teilt große Tasks auf)
|
||||
- Configurable Provider (OpenAI, Ollama, etc.)
|
||||
|
||||
### Communication
|
||||
- Email (Passwort-Reset, 2FA, Routinen)
|
||||
- Web Push Notifications
|
||||
- MCP Server (für AI-Tools)
|
||||
|
||||
### Admin
|
||||
- User Management (erstellen, deaktivieren, löschen)
|
||||
- System Settings (SMTP, AI, Registration)
|
||||
- Audit Logs
|
||||
- XP Reset
|
||||
|
||||
---
|
||||
|
||||
## Datenmodell (Wichtigste Tabellen)
|
||||
|
||||
```
|
||||
users ──┬── tasks ──── labels
|
||||
│ └── sharedTasks
|
||||
├── conversations ── messages
|
||||
├── xpEvents
|
||||
├── userRewards ── rewards
|
||||
├── focusSessions
|
||||
├── dailyChallenges
|
||||
├── breakLogs
|
||||
├── energyLogs
|
||||
└── auditLogs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Auth-Flow
|
||||
|
||||
```
|
||||
Browser Request
|
||||
│
|
||||
├── Hat Session Cookie? → Passport Session Auth → req.user
|
||||
│
|
||||
├── Hat X-API-Key / Bearer? → API Key Middleware → req.user
|
||||
│
|
||||
└── Keins von beiden → 401 Unauthorized
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### Lokal (Development)
|
||||
```bash
|
||||
cd /opt/task-manager
|
||||
npm run dev # Vite + Express auf Port 5001
|
||||
```
|
||||
|
||||
### Docker (Production)
|
||||
```bash
|
||||
docker-compose up -d # PostgreSQL + App auf Port 5000
|
||||
```
|
||||
|
||||
### Live
|
||||
- **URL:** https://task.nothaft.cloud
|
||||
- Reverse Proxy davor (vermutlich Caddy/Nginx)
|
||||
|
||||
---
|
||||
|
||||
*Letzte Aktualisierung: 2026-02-03*
|
||||
@@ -0,0 +1,678 @@
|
||||
# TaskFlow Refactoring Review
|
||||
|
||||
**Datum:** 2026-02-03
|
||||
**Reviewer:** AI Code Review (Senior Level)
|
||||
**Scope:** Full-Stack Analyse – Server, Shared, Client
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
TaskFlow ist eine funktional umfangreiche Task-Management-App mit Gamification, ADHD-Features, AI-Integration und Social-Features. Der Code funktioniert, hat aber **erhebliche architektonische und sicherheitstechnische Probleme**, die vor einem Production-Release adressiert werden müssen.
|
||||
|
||||
**Kernprobleme:**
|
||||
1. **`routes.ts` ist ein 2345-Zeilen-Monolith** – alle 96 Endpoints in einer Datei, keine klare Trennung
|
||||
2. **Kritische Security-Issues** – Debug-Endpoints ohne Auth, 2FA-Codes im Response, hardcoded Session Secret
|
||||
3. **Doppelte Route-Registrierungen** – `DELETE /api/tasks/:id` ist zweimal definiert (Zeile 889 + 1277)
|
||||
4. **Doppelter Label-Delete** – `storage.deleteLabel()` wird zweimal aufgerufen (Zeile 970 + 974)
|
||||
5. **Kein Rate Limiting, kein CORS, kein Helmet** – grundlegende Security-Middleware fehlt komplett
|
||||
6. **Massive `any`-Nutzung** – 20+ `any`-Types allein in routes.ts
|
||||
7. **N+1 Query-Probleme** in `getTasksForUser()` und Label-Share-Views
|
||||
|
||||
**Gesamtbewertung:** ⚠️ **Funktional, aber nicht produktionsreif.** Geschätzter Refactoring-Aufwand: 3-4 Wochen für eine Person.
|
||||
|
||||
---
|
||||
|
||||
## Kritische Issues
|
||||
|
||||
### 🔴 SEC-1: Debug-Endpoints ohne Authentifizierung (KRITISCH)
|
||||
|
||||
**Datei:** `server/routes.ts`, Zeilen 93-113
|
||||
|
||||
```typescript
|
||||
app.post("/api/debug/fix-settings", async (req, res) => {
|
||||
await storage.setSystemSettings("registration_enabled", "true");
|
||||
// ... resets SMTP, enables registration
|
||||
});
|
||||
|
||||
app.post("/api/debug/force-enable-registration", async (req, res) => {
|
||||
await storage.setSystemSettings("registration_enabled", "true");
|
||||
});
|
||||
```
|
||||
|
||||
**Problem:** Jeder kann ohne Authentifizierung die Registrierung aktivieren und SMTP-Einstellungen überschreiben. Ein Angreifer kann sich dadurch selbst registrieren und das System kompromittieren.
|
||||
|
||||
**Fix:** Sofort entfernen oder hinter `isAdmin`-Middleware + Environment-Check (`NODE_ENV !== 'production'`) schützen.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 SEC-2: 2FA-Code im API-Response exponiert (KRITISCH)
|
||||
|
||||
**Datei:** `server/auth.ts`, Zeilen 182 und 262
|
||||
|
||||
```typescript
|
||||
return res.status(200).json({
|
||||
message: "2fa_required",
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
debugCode: code // ← 2FA-Code im Klartext!
|
||||
});
|
||||
```
|
||||
|
||||
**Problem:** Der 2FA-Code wird direkt im Login-Response zurückgegeben. Das macht 2FA komplett wirkungslos – jeder Angreifer, der das Login-Request sieht, hat den Code.
|
||||
|
||||
**Fix:** `debugCode` entfernen. Nur in `NODE_ENV === 'development'` als Console-Log.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 SEC-3: Hardcoded Session Secret (KRITISCH)
|
||||
|
||||
**Datei:** `server/auth.ts`, Zeile 27
|
||||
|
||||
```typescript
|
||||
secret: process.env.SESSION_SECRET || "s3cr3t_m3ss4g3",
|
||||
```
|
||||
|
||||
**Problem:** Wenn `SESSION_SECRET` nicht gesetzt ist, wird ein bekanntes Secret verwendet. Session-Cookies können damit gefälscht werden.
|
||||
|
||||
**Fix:** Bei fehlendem `SESSION_SECRET` in Production den Server nicht starten lassen:
|
||||
```typescript
|
||||
if (!process.env.SESSION_SECRET && process.env.NODE_ENV === 'production') {
|
||||
throw new Error("SESSION_SECRET must be set in production!");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔴 SEC-4: 2FA Fail-Open – Immer bypass (KRITISCH)
|
||||
|
||||
**Datei:** `server/auth.ts`, Zeile 170
|
||||
|
||||
```typescript
|
||||
if (true) { // ← IMMER true!
|
||||
return res.status(200).json({
|
||||
message: "2fa_required",
|
||||
...
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Die Bedingung `if (true)` macht den Code-Pfad, der prüft ob die E-Mail tatsächlich gesendet wurde, irrelevant. Die Variable `sent` wird nie ausgewertet. Der Kommentar sagt "Fail-open for testing" – das muss in Production gefixt werden.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 SEC-5: Fehlende Auth-Checks auf mehreren Endpoints
|
||||
|
||||
| Endpoint | Problem |
|
||||
|---|---|
|
||||
| `GET /api/labels/:id` | Kein `isAuthenticated()` Check |
|
||||
| `POST /api/labels` | Kein expliziter Auth-Check (nur `req.user` wird genutzt) |
|
||||
| `PATCH /api/labels/:id` | Kein Auth-Check |
|
||||
| `DELETE /api/labels/:id` | Kein Auth-Check, kein Ownership-Check |
|
||||
| `GET /api/notes` | Kein Auth-Check |
|
||||
| `POST /api/notes` | Kein Auth-Check |
|
||||
| `GET /api/goals` | Kein Auth-Check |
|
||||
| `POST /api/goals` | Kein Auth-Check |
|
||||
| `GET /api/rewards` | Kein Auth-Check |
|
||||
| `GET /api/leaderboard` | Kein Auth-Check (gewollt?) |
|
||||
|
||||
---
|
||||
|
||||
### 🔴 SEC-6: Fehlende Security-Middleware
|
||||
|
||||
Nicht vorhanden:
|
||||
- **Rate Limiting** – Brute-Force auf Login/2FA möglich
|
||||
- **Helmet.js** – Keine Security-Header (CSP, X-Frame-Options, etc.)
|
||||
- **CORS** – Keine Origin-Einschränkung
|
||||
- **CSRF Protection** – Keine CSRF-Tokens
|
||||
- **Input Sanitization** – Kein XSS-Schutz auf user-generated Content
|
||||
|
||||
---
|
||||
|
||||
### 🟡 SEC-7: Gemini API-Key in URL exponiert
|
||||
|
||||
**Datei:** `server/ai.ts`
|
||||
|
||||
```typescript
|
||||
const url = `...?key=${apiKey}`;
|
||||
```
|
||||
|
||||
Google Gemini API-Key wird als Query-Parameter gesendet und landet damit in Server-Logs und Browser-History.
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Empfehlungen
|
||||
|
||||
### ARCH-1: routes.ts aufteilen (Höchste Priorität)
|
||||
|
||||
**Aktuell:** 1 Datei, 2345 Zeilen, 96 Endpoints, 0 Separation of Concerns.
|
||||
|
||||
**Vorschlag: Router-Module**
|
||||
|
||||
```
|
||||
server/
|
||||
├── routes/
|
||||
│ ├── index.ts # Router-Registry
|
||||
│ ├── auth.routes.ts # Login, Register, 2FA, Password Reset
|
||||
│ ├── admin.routes.ts # User-Mgmt, Settings, Audit Logs
|
||||
│ ├── task.routes.ts # CRUD Tasks, Sharing, Subtasks
|
||||
│ ├── label.routes.ts # CRUD Labels, Sharing
|
||||
│ ├── ai.routes.ts # Chat, Analyze, Schedule, Breakdown
|
||||
│ ├── social.routes.ts # Leaderboard, Search, Share All
|
||||
│ ├── gamification.routes.ts # Rewards, XP, Goals, Challenges
|
||||
│ ├── adhd.routes.ts # Break Logs, Energy, Focus, Body Doubling
|
||||
│ ├── push.routes.ts # Push Notifications
|
||||
│ └── mcp.routes.ts # MCP Server
|
||||
├── middleware/
|
||||
│ ├── auth.middleware.ts
|
||||
│ ├── rate-limit.middleware.ts
|
||||
│ └── validation.middleware.ts
|
||||
```
|
||||
|
||||
**Geschätzter Aufwand:** ~2 Tage, reines Code-Moving ohne Logikänderung.
|
||||
|
||||
---
|
||||
|
||||
### ARCH-2: Storage-Interface ist zu breit
|
||||
|
||||
`IStorage` hat **80+ Methoden** – das ist kein Interface mehr, das ist ein God Object.
|
||||
|
||||
**Vorschlag: Repository Pattern**
|
||||
|
||||
```typescript
|
||||
interface IUserRepository { ... }
|
||||
interface ITaskRepository { ... }
|
||||
interface ILabelRepository { ... }
|
||||
interface IGamificationRepository { ... }
|
||||
// etc.
|
||||
```
|
||||
|
||||
Jedes Repository wird separat implementiert (Mem + DB). Die Service-Layer-Klassen (`GamificationService`, `AiService`) erhalten nur die Repositories, die sie brauchen.
|
||||
|
||||
---
|
||||
|
||||
### ARCH-3: MemStorage als separate Implementierung beibehalten, aber isoliert
|
||||
|
||||
`MemStorage` hat viele Stubs (`throw new Error("Not implemented")`). Das ist fragil. Entweder:
|
||||
- **Option A:** MemStorage komplett pflegen (Test-Support)
|
||||
- **Option B:** MemStorage entfernen und nur mit DB + Testcontainer arbeiten
|
||||
|
||||
---
|
||||
|
||||
### ARCH-4: Service Layer konsistent nutzen
|
||||
|
||||
**Aktuell:** Services existieren (`GamificationService`, `AiService`, `RecurrenceService`, `EmailService`), aber die Logik ist trotzdem großteils in `routes.ts` verstreut.
|
||||
|
||||
Beispiel: `calculateXP()` (Zeile 1393) ist eine lose Funktion in routes.ts, obwohl `GamificationService.getXPForSource()` existiert. `calculateXP` wird nie aufgerufen → **Dead Code**.
|
||||
|
||||
---
|
||||
|
||||
### ARCH-5: DB Migration ist fragil
|
||||
|
||||
**Datei:** `server/db.ts`, Zeilen 56-85
|
||||
|
||||
`runMigrations()` erstellt nur 3 Tabellen (`users`, `labels`, `tasks`) mit minimalem Schema. Das Schema in `shared/schema.ts` hat **20+ Tabellen**. Die Funktion ist veraltet und gefährlich, weil sie existierende Tabellen nicht aktualisiert.
|
||||
|
||||
**Fix:** Drizzle Kit Migrations (`drizzle-kit generate` + `drizzle-kit migrate`) verwenden statt manueller SQL.
|
||||
|
||||
---
|
||||
|
||||
## Code-Qualität
|
||||
|
||||
### DUP-1: Doppelte DELETE Route für Tasks
|
||||
|
||||
**Datei:** `server/routes.ts`
|
||||
|
||||
```
|
||||
Zeile 889: app.delete("/api/tasks/:id", ...) ← mit Auth + Ownership-Check
|
||||
Zeile 1277: app.delete("/api/tasks/:id", ...) ← OHNE Auth-Check!
|
||||
```
|
||||
|
||||
Express registriert beide, aber nur die **erste** wird ausgeführt (Zeile 889). Die zweite (1277) ist Dead Code, aber ihre Existenz zeigt Copy-Paste-Probleme. **Die zweite hat keinen Auth-Check** – wäre sie die erste, hätte jeder Tasks löschen können.
|
||||
|
||||
---
|
||||
|
||||
### DUP-2: Doppelter deleteLabel()-Aufruf
|
||||
|
||||
**Datei:** `server/routes.ts`, Zeilen 970-974
|
||||
|
||||
```typescript
|
||||
const success = await storage.deleteLabel(req.params.id);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: "Label not found" });
|
||||
}
|
||||
await storage.deleteLabel(req.params.id); // ← Zweiter Delete-Aufruf!
|
||||
```
|
||||
|
||||
Das Label wird zweimal gelöscht. Der zweite Call ist ein No-Op (already deleted), aber es ist ein Bug.
|
||||
|
||||
---
|
||||
|
||||
### DUP-3: Doppelte createReward()-Deklaration im Interface
|
||||
|
||||
**Datei:** `server/storage.ts`, Zeilen 79-80
|
||||
|
||||
```typescript
|
||||
createReward(reward: InsertReward): Promise<Reward>;
|
||||
createReward(reward: InsertReward): Promise<Reward>; // Duplikat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DUP-4: AI Context-Building ist copy-pasted
|
||||
|
||||
Der User-Context-Block (`User Context: - User ID: ...`) wird in mindestens 3 Stellen fast identisch gebaut:
|
||||
- `POST /api/ai/chat` (Zeile ~680)
|
||||
- `PUT /api/ai/chat/:messageId` (Zeile ~800)
|
||||
- `POST /api/ai/analyze-completion` (Zeile ~740)
|
||||
|
||||
**Fix:** `buildUserContext(user: User, tasks: Task[]): string` Utility-Funktion extrahieren.
|
||||
|
||||
---
|
||||
|
||||
### DUP-5: Auth-Check Pattern wiederholt sich 50+ mal
|
||||
|
||||
```typescript
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const userId = (req.user as User).id;
|
||||
```
|
||||
|
||||
**Fix:** Express-Middleware:
|
||||
```typescript
|
||||
function requireAuth(req, res, next) {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
next();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DUP-6: AuditLog-Erstellung ist boilerplate-intensiv
|
||||
|
||||
Fast jeder Endpoint hat:
|
||||
```typescript
|
||||
await storage.createAuditLog({
|
||||
userId: (req.user as User).id,
|
||||
action: "...",
|
||||
entityType: "...",
|
||||
entityId: ...,
|
||||
details: ...,
|
||||
source: "USER"
|
||||
});
|
||||
```
|
||||
|
||||
**Fix:** Audit-Middleware oder Decorator-Pattern.
|
||||
|
||||
---
|
||||
|
||||
### TYPE-1: any-Types (20+ Vorkommen)
|
||||
|
||||
| Datei | Zeile | Problem |
|
||||
|---|---|---|
|
||||
| `routes.ts` | 21 | `isAdmin(req: any, res: any, next: any)` |
|
||||
| `routes.ts` | 366 | `const settings: any = {}` |
|
||||
| `routes.ts` | 1393 | `calculateXP(task: any)` |
|
||||
| `routes.ts` | 1547 | `const updates: any = {}` |
|
||||
| `routes.ts` | 1835 | `const exportData: any = {}` |
|
||||
| `routes.ts` | 2036 | `(user.adhdSettings as any)` |
|
||||
| `storage.ts` | 80+ | ADHD methods: `any` params und returns |
|
||||
| `recurrence.ts` | 39 | `const newTask: any = { ... }` |
|
||||
| `gamification.ts` | Div. | `awardXP` overloads mit inkonsistenter Signatur |
|
||||
|
||||
**Fix:** Proper TypeScript Interfaces für alle. Express `Request`/`Response` Types mit `req.user` über Declaration Merging definieren.
|
||||
|
||||
---
|
||||
|
||||
### TYPE-2: Inkonsistente GamificationService.awardXP Signatur
|
||||
|
||||
In `gamification.ts`:
|
||||
```typescript
|
||||
async awardXP(userId: string, source: string, amount?: number, details?: any)
|
||||
```
|
||||
|
||||
In `routes.ts` Zeile 1925 (log-break):
|
||||
```typescript
|
||||
await gamificationService.awardXP(userId, 10, 'break_taken', undefined, { breakType });
|
||||
```
|
||||
|
||||
Hier wird `10` als `source` und `'break_taken'` als `amount` übergeben – die Parameter sind vertauscht! Das führt zu falschen XP-Berechnungen.
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
### PERF-1: N+1 Queries in getTasksForUser() (DbStorage)
|
||||
|
||||
**Datei:** `server/storage.ts`, Zeilen ~940-1000
|
||||
|
||||
```typescript
|
||||
async getTasksForUser(userId: string): Promise<Task[]> {
|
||||
// Query 1: My tasks (with LEFT JOIN!)
|
||||
const resultRaw = await this.db.select(...)...
|
||||
// Query 2: Shared tasks links
|
||||
const sharedLinks = await this.db.select()...
|
||||
// Query 3: Shared tasks by IDs
|
||||
sharedTasks = await this.db.select()...
|
||||
// Query 4: Global access grants
|
||||
const accessGrants = await this.db.select()...
|
||||
// Query 5: Tasks from global access
|
||||
globalTasks = await this.db.select()...
|
||||
// Query 6: Shared labels
|
||||
const sharedLabels = await this.db.select()...
|
||||
// Query 7: Tasks from shared labels
|
||||
tasksFromSharedLabels = await this.db.select()...
|
||||
}
|
||||
```
|
||||
|
||||
**7 separate Queries** für einen einzigen API-Call (`GET /api/tasks`). Das wird bei jedem Seitenaufruf ausgeführt.
|
||||
|
||||
**Fix:** Eine einzige SQL-Query mit UNIONs:
|
||||
```sql
|
||||
SELECT t.* FROM tasks t WHERE t.user_id = $1
|
||||
UNION
|
||||
SELECT t.* FROM tasks t JOIN shared_tasks st ON t.id = st.task_id WHERE st.shared_with_user_id = $1
|
||||
UNION
|
||||
-- etc.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PERF-2: searchTasks() holt ALLE Tasks und filtert im JS
|
||||
|
||||
**Datei:** `server/storage.ts` (DbStorage):
|
||||
|
||||
```typescript
|
||||
async searchTasks(query: string, userId: string): Promise<Task[]> {
|
||||
const allTasks = await this.getTasksForUser(userId); // 7 Queries!
|
||||
if (!query) return allTasks;
|
||||
return allTasks.filter(t => t.title.toLowerCase().includes(lowerQuery)...);
|
||||
}
|
||||
```
|
||||
|
||||
**Fix:** SQL `ILIKE` direkt in der Query nutzen. Für Volltext: PostgreSQL Full-Text Search oder `pg_trgm`.
|
||||
|
||||
---
|
||||
|
||||
### PERF-3: Admin Settings laden jede Einstellung einzeln
|
||||
|
||||
**Datei:** `server/routes.ts`, Zeile 359-380
|
||||
|
||||
```typescript
|
||||
for (const key of keys) {
|
||||
const val = await storage.getSystemSettings(key); // 1 Query pro Key!
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
12 separate DB-Queries für ein Admin-Panel-Load.
|
||||
|
||||
**Fix:** `getSystemSettingsBulk(keys: string[]): Promise<Record<string, string>>` mit `WHERE key IN (...)`.
|
||||
|
||||
---
|
||||
|
||||
### PERF-4: AI Chat holt ALLE Tasks bei jedem Message
|
||||
|
||||
Jede Chat-Message triggert:
|
||||
```typescript
|
||||
const tasks = await storage.getTasksForUser(user.id); // 7 Queries
|
||||
const activeTasks = tasks.filter(t => t.status !== 'done');
|
||||
```
|
||||
|
||||
**Fix:** Caching (Redis/in-memory mit TTL) oder Summary-Table.
|
||||
|
||||
---
|
||||
|
||||
### PERF-5: Fehlende DB-Indizes
|
||||
|
||||
Schema definiert keine expliziten Indizes. Mindestens benötigt:
|
||||
- `tasks.user_id` (jeder Task-Fetch)
|
||||
- `tasks.label_id` (Label-basierte Queries)
|
||||
- `tasks.status` (Filter nach Status)
|
||||
- `tasks.parent_task_id` (Subtask-Lookups)
|
||||
- `xp_events.user_id` + `created_at` (Analytics)
|
||||
- `shared_tasks.shared_with_user_id` (Sharing-Lookups)
|
||||
- `audit_logs.created_at` (Log-Sortierung)
|
||||
|
||||
---
|
||||
|
||||
### PERF-6: Label-Share-View hat O(n²) Problem
|
||||
|
||||
**Datei:** `routes.ts`, Zeile ~1000
|
||||
|
||||
```typescript
|
||||
const sharesWithDetails = await Promise.all(users.map(async u => {
|
||||
const shareInfos = await storage.getLabelShares(req.params.id); // ← SAME query in loop!
|
||||
const specificShare = shareInfos.find(s => s.sharedWithUserId === u.id);
|
||||
...
|
||||
}));
|
||||
```
|
||||
|
||||
`getLabelShares()` wird **einmal pro User** aufgerufen, obwohl es immer dasselbe Ergebnis liefert.
|
||||
|
||||
**Fix:** `getLabelShares()` einmal vor der Loop aufrufen.
|
||||
|
||||
---
|
||||
|
||||
## Dead Code & Cleanup
|
||||
|
||||
### DEAD-1: Unused calculateXP() function
|
||||
|
||||
**Datei:** `routes.ts`, Zeile 1393-1399
|
||||
|
||||
```typescript
|
||||
const calculateXP = (task: any) => {
|
||||
let baseXP = 10;
|
||||
if (task.priority === 'high') baseXP += 20;
|
||||
...
|
||||
};
|
||||
```
|
||||
|
||||
Wird nirgendwo aufgerufen. `GamificationService.getXPForSource()` hat diese Logik.
|
||||
|
||||
---
|
||||
|
||||
### DEAD-2: Notes-Endpoints sind Placeholder
|
||||
|
||||
**Datei:** `routes.ts`, Zeilen 1285-1300
|
||||
|
||||
```typescript
|
||||
app.get("/api/notes", async (req, res) => {
|
||||
res.json([]); // Placeholder until storage implementation
|
||||
});
|
||||
|
||||
app.post("/api/notes", async (req, res) => {
|
||||
res.status(201).json({ ...result.data, id: "placeholder" });
|
||||
});
|
||||
```
|
||||
|
||||
Notes-Schema existiert in `schema.ts`, aber die Storage-Implementation fehlt. Entweder implementieren oder entfernen.
|
||||
|
||||
---
|
||||
|
||||
### DEAD-3: Duplizierte DELETE /api/tasks/:id Route
|
||||
|
||||
Zeile 1277 ist eine Dead-Route (Express nimmt die erste Registrierung).
|
||||
|
||||
---
|
||||
|
||||
### DEAD-4: Goals-Endpoint nutzt updateTask statt updateGoal
|
||||
|
||||
**Datei:** `routes.ts`, Zeile 1759
|
||||
|
||||
```typescript
|
||||
app.patch("/api/goals/:id", async (req, res) => {
|
||||
const updated = await storage.updateTask(req.params.id, req.body); // ← WRONG!
|
||||
```
|
||||
|
||||
`updateTask` statt `updateGoal` – das ist ein Bug, kein Dead Code. Goals werden als Tasks aktualisiert!
|
||||
|
||||
---
|
||||
|
||||
### DEAD-5: DEFAULT_SYSTEM_PROMPT ist doppelt definiert
|
||||
|
||||
- In `server/ai.ts` (Zeile 11)
|
||||
- In `server/index.ts` (Zeile 87) – nochmal als inline String, aber abweichend
|
||||
|
||||
---
|
||||
|
||||
### DEAD-6: Leaderboard Route ist doppelt registriert
|
||||
|
||||
**Datei:** `client/src/App.tsx`
|
||||
|
||||
```tsx
|
||||
<Route path="/leaderboard"><LeaderboardPage /></Route>
|
||||
<Route path="/leaderboard"><LeaderboardPage /></Route> // Duplikat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEAD-7: Auskommentierter AiChat Import
|
||||
|
||||
**Datei:** `client/src/App.tsx`
|
||||
|
||||
```typescript
|
||||
// import { AiChat } from "@/components/AiChat";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Weitere Issues
|
||||
|
||||
### MISC-1: Fire-and-Forget AI Processing
|
||||
|
||||
**Datei:** `routes.ts`, Zeile ~680
|
||||
|
||||
```typescript
|
||||
res.json(userMessage); // Response sofort gesendet
|
||||
|
||||
(async () => {
|
||||
// AI Processing in Background
|
||||
const responseContent = await aiService.chat(history, user, context);
|
||||
await storage.addMessage({ ... });
|
||||
})(); // ← Kein Error-Handling für Client!
|
||||
```
|
||||
|
||||
Der Client bekommt nie die AI-Antwort direkt. Er muss pollen. Das ist fragil. **Besser: Server-Sent Events (SSE) oder WebSocket für AI-Responses.**
|
||||
|
||||
---
|
||||
|
||||
### MISC-2: PATCH /api/goals/:id hat keinen Auth-Check
|
||||
|
||||
Zeile 1759 – kein `isAuthenticated()` Check, kein Ownership-Check. Jeder kann jeden Goal ändern.
|
||||
|
||||
(Hinweis: Wird aktuell fälschlicherweise über `updateTask` geroutet – siehe DEAD-4.)
|
||||
|
||||
---
|
||||
|
||||
### MISC-3: XP Double-Counting Risk
|
||||
|
||||
**Datei:** `server/storage.ts` (DbStorage), `logXpEvent()`:
|
||||
|
||||
```typescript
|
||||
async logXpEvent(event: InsertXpEvent): Promise<XpEvent> {
|
||||
const result = await this.db.insert(schema.xpEvents).values(event).returning();
|
||||
if (event.userId) {
|
||||
await this.updateUserXP(event.userId, event.amount); // ← Adds XP again!
|
||||
}
|
||||
return result[0];
|
||||
}
|
||||
```
|
||||
|
||||
`GamificationService.awardXP()` ruft zuerst `storage.updateUserXP(userId, xpAmount)` auf, dann `storage.logXpEvent()`. In `logXpEvent()` wird `updateUserXP()` nochmal aufgerufen → **XP wird doppelt vergeben!**
|
||||
|
||||
---
|
||||
|
||||
### MISC-4: Fehlende Pagination
|
||||
|
||||
Keine einzige Liste hat Pagination:
|
||||
- `GET /api/tasks` – alle Tasks auf einmal
|
||||
- `GET /api/admin/users` – alle User
|
||||
- `GET /api/admin/audit-logs` – max 100 (hardcoded)
|
||||
- `GET /api/ai/conversations` – alle Conversations
|
||||
- `GET /api/user/history` – alle XP Events
|
||||
|
||||
---
|
||||
|
||||
## Priorisierte Roadmap
|
||||
|
||||
### Phase 1: Security Fixes (1-2 Tage) 🔴
|
||||
| # | Task | Impact |
|
||||
|---|---|---|
|
||||
| 1 | Debug-Endpoints entfernen/schützen (SEC-1) | Kritisch |
|
||||
| 2 | `debugCode` aus API-Responses entfernen (SEC-2) | Kritisch |
|
||||
| 3 | Session Secret Validierung (SEC-3) | Kritisch |
|
||||
| 4 | `if (true)` in 2FA-Flow fixen (SEC-4) | Kritisch |
|
||||
| 5 | Fehlende Auth-Checks hinzufügen (SEC-5) | Kritisch |
|
||||
| 6 | Rate Limiting installieren (`express-rate-limit`) | Hoch |
|
||||
| 7 | Helmet.js einbinden | Hoch |
|
||||
| 8 | CORS konfigurieren | Hoch |
|
||||
|
||||
### Phase 2: Bug Fixes (1 Tag) 🟡
|
||||
| # | Task | Impact |
|
||||
|---|---|---|
|
||||
| 1 | Doppelte `DELETE /api/tasks/:id` Route entfernen (DUP-1) | Bug |
|
||||
| 2 | Doppelten `deleteLabel()` Call fixen (DUP-2) | Bug |
|
||||
| 3 | `PATCH /api/goals/:id` → `updateGoal` statt `updateTask` (DEAD-4) | Bug |
|
||||
| 4 | XP Double-Counting fixen (MISC-3) | Bug |
|
||||
| 5 | `gamificationService.awardXP()` Parameter-Reihenfolge fixen (TYPE-2) | Bug |
|
||||
|
||||
### Phase 3: Architecture – routes.ts aufteilen (2-3 Tage)
|
||||
| # | Task |
|
||||
|---|---|
|
||||
| 1 | Router-Module erstellen (10 Dateien) |
|
||||
| 2 | Auth-Middleware extrahieren (`requireAuth`, `requireAdmin`) |
|
||||
| 3 | Validation-Middleware mit Zod |
|
||||
| 4 | Audit-Log-Helper/Middleware |
|
||||
| 5 | User-Context-Builder extrahieren |
|
||||
|
||||
### Phase 4: Performance (2-3 Tage)
|
||||
| # | Task |
|
||||
|---|---|
|
||||
| 1 | `getTasksForUser()` zu einer UNION-Query refactoren |
|
||||
| 2 | `searchTasks()` mit SQL ILIKE |
|
||||
| 3 | Bulk `getSystemSettings()` |
|
||||
| 4 | DB-Indizes hinzufügen |
|
||||
| 5 | Pagination für alle Listen |
|
||||
|
||||
### Phase 5: TypeScript & Code Quality (2-3 Tage)
|
||||
| # | Task |
|
||||
|---|---|
|
||||
| 1 | `any` Types durch proper Interfaces ersetzen |
|
||||
| 2 | Express Request Type-Augmentation für `req.user` |
|
||||
| 3 | Dead Code entfernen (Notes-Placeholder, calculateXP, doppelte Routes) |
|
||||
| 4 | Storage Interface aufteilen (Repository Pattern) |
|
||||
| 5 | Consistent Error-Handling mit Custom Error Classes |
|
||||
|
||||
### Phase 6: Nice-to-Have (ongoing)
|
||||
| # | Task |
|
||||
|---|---|
|
||||
| 1 | SSE/WebSocket für AI-Chat statt Fire-and-Forget |
|
||||
| 2 | Drizzle Kit Migrations statt manueller SQL |
|
||||
| 3 | MemStorage entweder voll implementieren oder entfernen |
|
||||
| 4 | OpenAPI/Swagger Spec generieren |
|
||||
| 5 | Integration Tests |
|
||||
| 6 | Client-seitige Validierung mit shared Zod Schemas |
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung nach Datei
|
||||
|
||||
| Datei | LOC | Zustand | Hauptprobleme |
|
||||
|---|---|---|---|
|
||||
| `server/routes.ts` | 2345 | 🔴 Kritisch | Monolith, Duplikate, fehlende Auth, Dead Code |
|
||||
| `server/storage.ts` | 1634 | 🟡 Problematisch | God-Interface (80+ Methods), any-Types, N+1 |
|
||||
| `shared/schema.ts` | 551 | 🟢 OK | Gut strukturiert, fehlende Indizes |
|
||||
| `server/auth.ts` | 270 | 🔴 Kritisch | debugCode, hardcoded secret, if(true) |
|
||||
| `server/ai.ts` | 550 | 🟡 Problematisch | Sehr lang, aber gut strukturiert. Gemini-Key in URL |
|
||||
| `server/gamification.ts` | 160 | 🟢 OK | Inkonsistente awardXP-Signatur |
|
||||
| `server/email.ts` | 130 | 🟢 OK | Solide, i18n-Support |
|
||||
| `server/push.ts` | 220 | 🟢 OK | Gute Fehlerbehandlung |
|
||||
| `server/services/recurrence.ts` | 100 | 🟢 OK | `any`-Cast, sonst sauber |
|
||||
| `server/db.ts` | 100 | 🟡 Problematisch | Veraltete Migration-Logik |
|
||||
| `server/index.ts` | 140 | 🟡 Problematisch | Doppelter System-Prompt, aber sonst OK |
|
||||
| `server/mcp.ts` | 200 | 🟢 OK | Saubere MCP-Implementation |
|
||||
| `client/src/App.tsx` | 340 | 🟡 Problematisch | Zu viel Logik, doppelte Route, aber funktional |
|
||||
|
||||
---
|
||||
|
||||
*Dieser Report wurde automatisch generiert. Alle Zeilenangaben beziehen sich auf den Stand vom 2026-02-03.*
|
||||
Generated
+1
-30
@@ -923,7 +923,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
|
||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
@@ -2379,7 +2378,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -6625,7 +6623,6 @@
|
||||
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.20.7",
|
||||
"@babel/types": "^7.20.7",
|
||||
@@ -6916,7 +6913,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz",
|
||||
"integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
@@ -6948,7 +6944,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -6960,7 +6955,6 @@
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
@@ -7090,7 +7084,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -7305,7 +7298,6 @@
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
@@ -7499,7 +7491,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -7533,7 +7524,6 @@
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build": "^4.3.0"
|
||||
},
|
||||
@@ -8123,7 +8113,6 @@
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
@@ -8748,7 +8737,6 @@
|
||||
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.39.3.tgz",
|
||||
"integrity": "sha512-EZ8ZpYvDIvKU9C56JYLOmUskazhad+uXZCTCRN4OnRMsL+xAJ05dv1eCpAG5xzhsm1hqiuC5kAZUCS924u2DTw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@aws-sdk/client-rds-data": ">=3",
|
||||
"@cloudflare/workers-types": ">=4",
|
||||
@@ -8931,8 +8919,7 @@
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
|
||||
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/embla-carousel-react": {
|
||||
"version": "8.6.0",
|
||||
@@ -9135,7 +9122,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -10129,7 +10115,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
@@ -12537,7 +12522,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.17.1.tgz",
|
||||
"integrity": "sha512-EIR+jXdYNSMOrpRp7g6WgQr7SaZNZfS7IzZIO0oTNEeibq956JxeD15t3Jk3zZH0KH8DmOIx38qJfQenoE8bXQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.10.0",
|
||||
"pg-pool": "^3.11.0",
|
||||
@@ -12727,7 +12711,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -13079,7 +13062,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -13106,7 +13088,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -13120,7 +13101,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
|
||||
"integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -13598,7 +13578,6 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.2.tgz",
|
||||
"integrity": "sha512-PggGy4dhwx5qaW+CKBilA/98Ql9keyfnb7lh4SR6shQ91QQQi1ORJ1v4UinkdP2i87OBs9AQFooQylcrrRfIcg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -14368,7 +14347,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -14562,7 +14540,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -14614,7 +14591,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
|
||||
"integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
@@ -14669,7 +14645,6 @@
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -15297,7 +15272,6 @@
|
||||
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -15683,7 +15657,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -16492,7 +16465,6 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
|
||||
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
@@ -16786,7 +16758,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -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