From 64211e6a49090a142780b6ae1529a8b8d44dc8ba Mon Sep 17 00:00:00 2001 From: "simon.franken" Date: Mon, 16 Mar 2026 15:26:09 +0100 Subject: [PATCH 1/2] feat: add MCP endpoint and API key management - Add ApiKey Prisma model (SHA-256 hash, prefix, lastUsedAt) with migration - Implement ApiKeyService (create, list, delete, verify) - Extend requireAuth middleware to accept sk_-prefixed API keys alongside JWTs - Add GET/POST /api-keys routes for creating and revoking keys - Add stateless Streamable HTTP MCP server at POST/GET /mcp exposing all 20 time-tracking tools (clients, projects, time entries, timer, statistics, client targets and corrections) - Frontend: ApiKey types, apiKeys API module, useApiKeys hook - Frontend: ApiKeysPage with key table, one-time raw-key reveal modal, and inline revoke confirmation - Wire /api-keys route and add API Keys link to Management dropdown in Navbar --- backend/package-lock.json | 648 ++++++++++++++++++ backend/package.json | 1 + .../20260316103132_add_api_keys/migration.sql | 24 + backend/prisma/schema.prisma | 16 + backend/src/index.ts | 4 + backend/src/middleware/auth.ts | 27 +- backend/src/routes/apiKey.routes.ts | 51 ++ backend/src/routes/mcp.routes.ts | 462 +++++++++++++ backend/src/schemas/index.ts | 4 + backend/src/services/apiKey.service.ts | 99 +++ frontend/src/App.tsx | 2 + frontend/src/api/apiKeys.ts | 18 + frontend/src/components/Navbar.tsx | 2 + frontend/src/hooks/useApiKeys.ts | 34 + frontend/src/pages/ApiKeysPage.tsx | 235 +++++++ frontend/src/types/index.ts | 16 + 16 files changed, 1642 insertions(+), 1 deletion(-) create mode 100644 backend/prisma/migrations/20260316103132_add_api_keys/migration.sql create mode 100644 backend/src/routes/apiKey.routes.ts create mode 100644 backend/src/routes/mcp.routes.ts create mode 100644 backend/src/services/apiKey.service.ts create mode 100644 frontend/src/api/apiKeys.ts create mode 100644 frontend/src/hooks/useApiKeys.ts create mode 100644 frontend/src/pages/ApiKeysPage.tsx diff --git a/backend/package-lock.json b/backend/package-lock.json index f27e794..0782053 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,6 +8,7 @@ "name": "timetracker-backend", "version": "1.0.0", "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", "@prisma/client": "^6.19.2", "@quixo3/prisma-session-store": "^3.1.19", "cors": "^2.8.5", @@ -471,6 +472,367 @@ "node": ">=18" } }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/jose": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz", + "integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -771,6 +1133,39 @@ "node": ">= 0.6" } }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -983,6 +1378,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1193,6 +1602,27 @@ "node": ">= 0.6" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -1239,6 +1669,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express-session": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", @@ -1292,6 +1740,28 @@ "node": ">=8.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -1456,6 +1926,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz", + "integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1494,6 +1973,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1503,6 +1991,18 @@ "node": ">= 0.10" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -1522,6 +2022,18 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -1808,6 +2320,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/openid-client": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", @@ -1832,6 +2353,15 @@ "node": ">= 0.8" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", @@ -1852,6 +2382,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-types": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", @@ -1993,6 +2532,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -2003,6 +2551,55 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2092,6 +2689,27 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -2321,6 +2939,27 @@ "node": ">= 0.8" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -2335,6 +2974,15 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } } } } diff --git a/backend/package.json b/backend/package.json index 41bd041..4eb6bfc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,6 +10,7 @@ "db:seed": "tsx prisma/seed.ts" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", "@prisma/client": "^6.19.2", "@quixo3/prisma-session-store": "^3.1.19", "cors": "^2.8.5", diff --git a/backend/prisma/migrations/20260316103132_add_api_keys/migration.sql b/backend/prisma/migrations/20260316103132_add_api_keys/migration.sql new file mode 100644 index 0000000..c688adf --- /dev/null +++ b/backend/prisma/migrations/20260316103132_add_api_keys/migration.sql @@ -0,0 +1,24 @@ +-- AlterTable +ALTER TABLE "client_targets" ALTER COLUMN "working_days" DROP DEFAULT; + +-- CreateTable +CREATE TABLE "api_keys" ( + "id" TEXT NOT NULL, + "name" VARCHAR(255) NOT NULL, + "key_hash" VARCHAR(64) NOT NULL, + "prefix" VARCHAR(16) NOT NULL, + "last_used_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "user_id" VARCHAR(255) NOT NULL, + + CONSTRAINT "api_keys_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "api_keys_key_hash_key" ON "api_keys"("key_hash"); + +-- CreateIndex +CREATE INDEX "api_keys_user_id_idx" ON "api_keys"("user_id"); + +-- AddForeignKey +ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 99dbb88..84f828d 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -20,6 +20,7 @@ model User { timeEntries TimeEntry[] ongoingTimer OngoingTimer? clientTargets ClientTarget[] + apiKeys ApiKey[] @@map("users") } @@ -151,3 +152,18 @@ model Session { @@map("sessions") } + +model ApiKey { + id String @id @default(uuid()) + name String @db.VarChar(255) + keyHash String @unique @map("key_hash") @db.VarChar(64) // SHA-256 hex + prefix String @db.VarChar(16) // first chars of raw key for display + lastUsedAt DateTime? @map("last_used_at") + createdAt DateTime @default(now()) @map("created_at") + + userId String @map("user_id") @db.VarChar(255) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("api_keys") +} diff --git a/backend/src/index.ts b/backend/src/index.ts index e41bc5b..66d7c1b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -13,6 +13,8 @@ import projectRoutes from "./routes/project.routes"; import timeEntryRoutes from "./routes/timeEntry.routes"; import timerRoutes from "./routes/timer.routes"; import clientTargetRoutes from "./routes/clientTarget.routes"; +import apiKeyRoutes from "./routes/apiKey.routes"; +import mcpRoutes from "./routes/mcp.routes"; async function main() { // Validate configuration @@ -70,6 +72,8 @@ async function main() { app.use("/time-entries", timeEntryRoutes); app.use("/timer", timerRoutes); app.use("/client-targets", clientTargetRoutes); + app.use("/api-keys", apiKeyRoutes); + app.use("/mcp", mcpRoutes); // Error handling app.use(notFoundHandler); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index a8a256a..427e1f4 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -2,6 +2,9 @@ import { Request, Response, NextFunction } from 'express'; import { prisma } from '../prisma/client'; import type { AuthenticatedRequest, AuthenticatedUser } from '../types'; import { verifyBackendJwt } from '../auth/jwt'; +import { ApiKeyService } from '../services/apiKey.service'; + +const apiKeyService = new ApiKeyService(); export async function requireAuth( req: AuthenticatedRequest, @@ -17,11 +20,33 @@ export async function requireAuth( return next(); } - // 2. Bearer JWT auth (iOS / native clients) + // 2. Bearer token auth (JWT or API key) const authHeader = req.headers.authorization; if (authHeader?.startsWith('Bearer ')) { const token = authHeader.slice(7); console.log(`${tag} -> Bearer token present (first 20 chars: ${token.slice(0, 20)}…)`); + + // 2a. API key — detected by the "sk_" prefix + if (token.startsWith('sk_')) { + try { + const user = await apiKeyService.verify(token); + if (!user) { + console.warn(`${tag} -> API key verification failed: key not found`); + res.status(401).json({ error: 'Unauthorized: invalid API key' }); + return; + } + req.user = user; + console.log(`${tag} -> API key auth OK (user: ${req.user.id})`); + return next(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn(`${tag} -> API key verification error: ${message}`); + res.status(401).json({ error: `Unauthorized: ${message}` }); + return; + } + } + + // 2b. JWT (iOS / native clients) try { req.user = verifyBackendJwt(token); console.log(`${tag} -> JWT auth OK (user: ${req.user.id})`); diff --git a/backend/src/routes/apiKey.routes.ts b/backend/src/routes/apiKey.routes.ts new file mode 100644 index 0000000..08aa654 --- /dev/null +++ b/backend/src/routes/apiKey.routes.ts @@ -0,0 +1,51 @@ +import { Router } from 'express'; +import { requireAuth } from '../middleware/auth'; +import { validateBody, validateParams } from '../middleware/validation'; +import { ApiKeyService } from '../services/apiKey.service'; +import { CreateApiKeySchema, IdSchema } from '../schemas'; +import type { AuthenticatedRequest } from '../types'; + +const router = Router(); +const apiKeyService = new ApiKeyService(); + +// GET /api-keys - List user's API keys +router.get('/', requireAuth, async (req: AuthenticatedRequest, res, next) => { + try { + const keys = await apiKeyService.list(req.user!.id); + res.json(keys); + } catch (error) { + next(error); + } +}); + +// POST /api-keys - Create a new API key +router.post( + '/', + requireAuth, + validateBody(CreateApiKeySchema), + async (req: AuthenticatedRequest, res, next) => { + try { + const created = await apiKeyService.create(req.user!.id, req.body.name); + res.status(201).json(created); + } catch (error) { + next(error); + } + } +); + +// DELETE /api-keys/:id - Revoke an API key +router.delete( + '/:id', + requireAuth, + validateParams(IdSchema), + async (req: AuthenticatedRequest, res, next) => { + try { + await apiKeyService.delete(req.params.id, req.user!.id); + res.status(204).send(); + } catch (error) { + next(error); + } + } +); + +export default router; diff --git a/backend/src/routes/mcp.routes.ts b/backend/src/routes/mcp.routes.ts new file mode 100644 index 0000000..6fb6447 --- /dev/null +++ b/backend/src/routes/mcp.routes.ts @@ -0,0 +1,462 @@ +import { Router, Request, Response } from 'express'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { z } from 'zod'; +import { requireAuth } from '../middleware/auth'; +import type { AuthenticatedRequest, AuthenticatedUser } from '../types'; +import { ClientService } from '../services/client.service'; +import { ProjectService } from '../services/project.service'; +import { TimeEntryService } from '../services/timeEntry.service'; +import { TimerService } from '../services/timer.service'; +import { ClientTargetService } from '../services/clientTarget.service'; + +const router = Router(); + +// Service instances — shared, stateless +const clientService = new ClientService(); +const projectService = new ProjectService(); +const timeEntryService = new TimeEntryService(); +const timerService = new TimerService(); +const clientTargetService = new ClientTargetService(); + +/** + * Build and return a fresh stateless McpServer pre-populated with all tools + * scoped to the given authenticated user. + */ +function buildMcpServer(user: AuthenticatedUser): McpServer { + const server = new McpServer({ + name: 'timetracker', + version: '1.0.0', + }); + + const userId = user.id; + + // ------------------------------------------------------------------------- + // Clients + // ------------------------------------------------------------------------- + + server.registerTool( + 'list_clients', + { + description: 'List all clients for the authenticated user.', + inputSchema: {}, + }, + async () => { + const clients = await clientService.findAll(userId); + return { content: [{ type: 'text', text: JSON.stringify(clients, null, 2) }] }; + } + ); + + server.registerTool( + 'create_client', + { + description: 'Create a new client.', + inputSchema: { + name: z.string().min(1).max(255).describe('Client name'), + description: z.string().max(1000).optional().describe('Optional description'), + }, + }, + async ({ name, description }) => { + const client = await clientService.create(userId, { name, description }); + return { content: [{ type: 'text', text: JSON.stringify(client, null, 2) }] }; + } + ); + + server.registerTool( + 'update_client', + { + description: 'Update an existing client.', + inputSchema: { + id: z.string().uuid().describe('Client ID'), + name: z.string().min(1).max(255).optional().describe('New name'), + description: z.string().max(1000).optional().describe('New description'), + }, + }, + async ({ id, name, description }) => { + const client = await clientService.update(id, userId, { name, description }); + return { content: [{ type: 'text', text: JSON.stringify(client, null, 2) }] }; + } + ); + + server.registerTool( + 'delete_client', + { + description: 'Soft-delete a client (and its projects).', + inputSchema: { + id: z.string().uuid().describe('Client ID'), + }, + }, + async ({ id }) => { + await clientService.delete(id, userId); + return { content: [{ type: 'text', text: `Client ${id} deleted.` }] }; + } + ); + + // ------------------------------------------------------------------------- + // Projects + // ------------------------------------------------------------------------- + + server.registerTool( + 'list_projects', + { + description: 'List all projects, optionally filtered by clientId.', + inputSchema: { + clientId: z.string().uuid().optional().describe('Filter by client ID'), + }, + }, + async ({ clientId }) => { + const projects = await projectService.findAll(userId, clientId); + return { content: [{ type: 'text', text: JSON.stringify(projects, null, 2) }] }; + } + ); + + server.registerTool( + 'create_project', + { + description: 'Create a new project under a client.', + inputSchema: { + name: z.string().min(1).max(255).describe('Project name'), + clientId: z.string().uuid().describe('Client ID the project belongs to'), + description: z.string().max(1000).optional().describe('Optional description'), + color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional().describe('Hex color code, e.g. #FF5733'), + }, + }, + async ({ name, clientId, description, color }) => { + const project = await projectService.create(userId, { name, clientId, description, color }); + return { content: [{ type: 'text', text: JSON.stringify(project, null, 2) }] }; + } + ); + + server.registerTool( + 'update_project', + { + description: 'Update an existing project.', + inputSchema: { + id: z.string().uuid().describe('Project ID'), + name: z.string().min(1).max(255).optional().describe('New name'), + description: z.string().max(1000).optional().describe('New description'), + color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).nullable().optional().describe('Hex color or null to clear'), + clientId: z.string().uuid().optional().describe('Move project to a different client'), + }, + }, + async (args) => { + const { id, ...rest } = args as { + id: string; + name?: string; + description?: string; + color?: string | null; + clientId?: string; + }; + const project = await projectService.update(id, userId, rest as import('../types').UpdateProjectInput); + return { content: [{ type: 'text', text: JSON.stringify(project, null, 2) }] }; + } + ); + + server.registerTool( + 'delete_project', + { + description: 'Soft-delete a project.', + inputSchema: { + id: z.string().uuid().describe('Project ID'), + }, + }, + async ({ id }) => { + await projectService.delete(id, userId); + return { content: [{ type: 'text', text: `Project ${id} deleted.` }] }; + } + ); + + // ------------------------------------------------------------------------- + // Time entries + // ------------------------------------------------------------------------- + + server.registerTool( + 'list_time_entries', + { + description: 'List time entries with optional filters. Returns paginated results.', + inputSchema: { + startDate: z.string().datetime().optional().describe('Filter entries starting at or after this ISO datetime'), + endDate: z.string().datetime().optional().describe('Filter entries starting at or before this ISO datetime'), + projectId: z.string().uuid().optional().describe('Filter by project ID'), + clientId: z.string().uuid().optional().describe('Filter by client ID'), + page: z.number().int().min(1).optional().default(1).describe('Page number (default 1)'), + limit: z.number().int().min(1).max(100).optional().default(50).describe('Results per page (max 100, default 50)'), + }, + }, + async (filters) => { + const result = await timeEntryService.findAll(userId, filters); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + } + ); + + server.registerTool( + 'create_time_entry', + { + description: 'Create a manual time entry.', + inputSchema: { + projectId: z.string().uuid().describe('Project ID'), + startTime: z.string().datetime().describe('Start time as ISO datetime string'), + endTime: z.string().datetime().describe('End time as ISO datetime string'), + breakMinutes: z.number().int().min(0).optional().describe('Break duration in minutes (default 0)'), + description: z.string().max(1000).optional().describe('Optional description'), + }, + }, + async ({ projectId, startTime, endTime, breakMinutes, description }) => { + const entry = await timeEntryService.create(userId, { projectId, startTime, endTime, breakMinutes, description }); + return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] }; + } + ); + + server.registerTool( + 'update_time_entry', + { + description: 'Update an existing time entry.', + inputSchema: { + id: z.string().uuid().describe('Time entry ID'), + startTime: z.string().datetime().optional().describe('New start time'), + endTime: z.string().datetime().optional().describe('New end time'), + breakMinutes: z.number().int().min(0).optional().describe('New break duration in minutes'), + description: z.string().max(1000).optional().describe('New description'), + projectId: z.string().uuid().optional().describe('Move to a different project'), + }, + }, + async ({ id, ...data }) => { + const entry = await timeEntryService.update(id, userId, data); + return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] }; + } + ); + + server.registerTool( + 'delete_time_entry', + { + description: 'Delete a time entry.', + inputSchema: { + id: z.string().uuid().describe('Time entry ID'), + }, + }, + async ({ id }) => { + await timeEntryService.delete(id, userId); + return { content: [{ type: 'text', text: `Time entry ${id} deleted.` }] }; + } + ); + + server.registerTool( + 'get_statistics', + { + description: 'Get aggregated time-tracking statistics, grouped by project and client.', + inputSchema: { + startDate: z.string().datetime().optional().describe('Filter from this ISO datetime'), + endDate: z.string().datetime().optional().describe('Filter until this ISO datetime'), + projectId: z.string().uuid().optional().describe('Filter by project ID'), + clientId: z.string().uuid().optional().describe('Filter by client ID'), + }, + }, + async (filters) => { + const stats = await timeEntryService.getStatistics(userId, filters); + return { content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }] }; + } + ); + + // ------------------------------------------------------------------------- + // Timer + // ------------------------------------------------------------------------- + + server.registerTool( + 'get_timer', + { + description: 'Get the current running timer, or null if none is active.', + inputSchema: {}, + }, + async () => { + const timer = await timerService.getOngoingTimer(userId); + return { content: [{ type: 'text', text: JSON.stringify(timer, null, 2) }] }; + } + ); + + server.registerTool( + 'start_timer', + { + description: 'Start a new timer. Fails if a timer is already running.', + inputSchema: { + projectId: z.string().uuid().optional().describe('Assign the timer to a project (can be set later)'), + }, + }, + async ({ projectId }) => { + const timer = await timerService.start(userId, { projectId }); + return { content: [{ type: 'text', text: JSON.stringify(timer, null, 2) }] }; + } + ); + + server.registerTool( + 'stop_timer', + { + description: 'Stop the running timer and save it as a time entry. A project must be assigned.', + inputSchema: { + projectId: z.string().uuid().optional().describe('Assign/override the project before stopping'), + }, + }, + async ({ projectId }) => { + const entry = await timerService.stop(userId, { projectId }); + return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] }; + } + ); + + server.registerTool( + 'cancel_timer', + { + description: 'Cancel the running timer without saving a time entry.', + inputSchema: {}, + }, + async () => { + await timerService.cancel(userId); + return { content: [{ type: 'text', text: 'Timer cancelled.' }] }; + } + ); + + // ------------------------------------------------------------------------- + // Client targets + // ------------------------------------------------------------------------- + + server.registerTool( + 'list_client_targets', + { + description: 'List all client hour targets with computed balance for each period.', + inputSchema: {}, + }, + async () => { + const targets = await clientTargetService.findAll(userId); + return { content: [{ type: 'text', text: JSON.stringify(targets, null, 2) }] }; + } + ); + + server.registerTool( + 'create_client_target', + { + description: 'Create a new hour target for a client.', + inputSchema: { + clientId: z.string().uuid().describe('Client ID'), + targetHours: z.number().positive().max(168).describe('Target hours per period'), + periodType: z.enum(['weekly', 'monthly']).describe('Period type: weekly or monthly'), + workingDays: z.array(z.enum(['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'])).min(1).describe('Working days, e.g. ["MON","TUE","WED","THU","FRI"]'), + startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Start date in YYYY-MM-DD format'), + }, + }, + async (data) => { + const target = await clientTargetService.create(userId, data); + return { content: [{ type: 'text', text: JSON.stringify(target, null, 2) }] }; + } + ); + + server.registerTool( + 'update_client_target', + { + description: 'Update an existing client hour target.', + inputSchema: { + id: z.string().uuid().describe('Target ID'), + targetHours: z.number().positive().max(168).optional().describe('New target hours per period'), + periodType: z.enum(['weekly', 'monthly']).optional().describe('New period type'), + workingDays: z.array(z.enum(['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'])).min(1).optional().describe('New working days'), + startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('New start date in YYYY-MM-DD'), + }, + }, + async ({ id, ...data }) => { + const target = await clientTargetService.update(id, userId, data); + return { content: [{ type: 'text', text: JSON.stringify(target, null, 2) }] }; + } + ); + + server.registerTool( + 'delete_client_target', + { + description: 'Delete a client hour target.', + inputSchema: { + id: z.string().uuid().describe('Target ID'), + }, + }, + async ({ id }) => { + await clientTargetService.delete(id, userId); + return { content: [{ type: 'text', text: `Client target ${id} deleted.` }] }; + } + ); + + server.registerTool( + 'add_target_correction', + { + description: 'Add a manual hour correction to a client target (e.g. for holidays or overtime carry-over).', + inputSchema: { + targetId: z.string().uuid().describe('Client target ID'), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Date of correction in YYYY-MM-DD format'), + hours: z.number().min(-1000).max(1000).describe('Hours to add (negative to deduct)'), + description: z.string().max(255).optional().describe('Optional reason for the correction'), + }, + }, + async ({ targetId, date, hours, description }) => { + const correction = await clientTargetService.addCorrection(targetId, userId, { date, hours, description }); + return { content: [{ type: 'text', text: JSON.stringify(correction, null, 2) }] }; + } + ); + + server.registerTool( + 'delete_target_correction', + { + description: 'Delete a manual hour correction from a client target.', + inputSchema: { + targetId: z.string().uuid().describe('Client target ID'), + correctionId: z.string().uuid().describe('Correction ID'), + }, + }, + async ({ targetId, correctionId }) => { + await clientTargetService.deleteCorrection(targetId, correctionId, userId); + return { content: [{ type: 'text', text: `Correction ${correctionId} deleted.` }] }; + } + ); + + return server; +} + +// --------------------------------------------------------------------------- +// Route handler — one fresh McpServer + transport per request (stateless) +// --------------------------------------------------------------------------- + +async function handleMcpRequest(req: AuthenticatedRequest, res: Response): Promise { + const user = req.user!; + + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + const mcpServer = buildMcpServer(user); + + // Ensure the server is cleaned up when the response finishes + res.on('close', () => { + transport.close().catch(() => undefined); + mcpServer.close().catch(() => undefined); + }); + + await mcpServer.connect(transport); + await transport.handleRequest(req as unknown as Request, res, req.body); +} + +// GET /mcp — SSE stream for server-initiated messages +router.get('/', requireAuth, (req: AuthenticatedRequest, res: Response) => { + handleMcpRequest(req, res).catch((err) => { + console.error('[MCP] GET error:', err); + if (!res.headersSent) { + res.status(500).json({ error: 'Internal server error' }); + } + }); +}); + +// POST /mcp — JSON-RPC requests +router.post('/', requireAuth, (req: AuthenticatedRequest, res: Response) => { + handleMcpRequest(req, res).catch((err) => { + console.error('[MCP] POST error:', err); + if (!res.headersSent) { + res.status(500).json({ error: 'Internal server error' }); + } + }); +}); + +// DELETE /mcp — session termination (stateless: always 405) +router.delete('/', (_req, res: Response) => { + res.status(405).json({ error: 'Sessions are not supported (stateless mode)' }); +}); + +export default router; diff --git a/backend/src/schemas/index.ts b/backend/src/schemas/index.ts index 058293e..977f15e 100644 --- a/backend/src/schemas/index.ts +++ b/backend/src/schemas/index.ts @@ -95,3 +95,7 @@ export const CreateCorrectionSchema = z.object({ hours: z.number().min(-1000).max(1000), description: z.string().max(255).optional(), }); + +export const CreateApiKeySchema = z.object({ + name: z.string().min(1).max(255), +}); diff --git a/backend/src/services/apiKey.service.ts b/backend/src/services/apiKey.service.ts new file mode 100644 index 0000000..3535b42 --- /dev/null +++ b/backend/src/services/apiKey.service.ts @@ -0,0 +1,99 @@ +import { createHash, randomUUID } from 'crypto'; +import { prisma } from '../prisma/client'; +import { NotFoundError } from '../errors/AppError'; +import type { AuthenticatedUser } from '../types'; + +const KEY_PREFIX_LENGTH = 12; // chars shown in UI + +function hashKey(rawKey: string): string { + return createHash('sha256').update(rawKey).digest('hex'); +} + +function generateRawKey(): string { + return `sk_${randomUUID().replace(/-/g, '')}`; +} + +export interface ApiKeyListItem { + id: string; + name: string; + prefix: string; + createdAt: string; + lastUsedAt: string | null; +} + +export interface CreatedApiKey { + id: string; + name: string; + prefix: string; + rawKey: string; // returned once only + createdAt: string; +} + +export class ApiKeyService { + async create(userId: string, name: string): Promise { + const rawKey = generateRawKey(); + const keyHash = hashKey(rawKey); + const prefix = rawKey.slice(0, KEY_PREFIX_LENGTH); + + const record = await prisma.apiKey.create({ + data: { userId, name, keyHash, prefix }, + }); + + return { + id: record.id, + name: record.name, + prefix: record.prefix, + rawKey, + createdAt: record.createdAt.toISOString(), + }; + } + + async list(userId: string): Promise { + const keys = await prisma.apiKey.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + }); + + return keys.map((k) => ({ + id: k.id, + name: k.name, + prefix: k.prefix, + createdAt: k.createdAt.toISOString(), + lastUsedAt: k.lastUsedAt ? k.lastUsedAt.toISOString() : null, + })); + } + + async delete(id: string, userId: string): Promise { + const existing = await prisma.apiKey.findFirst({ where: { id, userId } }); + if (!existing) { + throw new NotFoundError('API key not found'); + } + await prisma.apiKey.delete({ where: { id } }); + } + + /** + * Verify a raw API key string. Returns the owning user or null. + * Updates lastUsedAt on success. + */ + async verify(rawKey: string): Promise { + const keyHash = hashKey(rawKey); + const record = await prisma.apiKey.findUnique({ + where: { keyHash }, + include: { user: true }, + }); + + if (!record) return null; + + // Update lastUsedAt in the background — don't await to keep latency low + prisma.apiKey + .update({ where: { id: record.id }, data: { lastUsedAt: new Date() } }) + .catch(() => undefined); + + return { + id: record.user.id, + username: record.user.username, + fullName: record.user.fullName, + email: record.user.email, + }; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1797d36..c15ca86 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { TimeEntriesPage } from "./pages/TimeEntriesPage"; import { ClientsPage } from "./pages/ClientsPage"; import { ProjectsPage } from "./pages/ProjectsPage"; import { StatisticsPage } from "./pages/StatisticsPage"; +import { ApiKeysPage } from "./pages/ApiKeysPage"; function App() { return ( @@ -33,6 +34,7 @@ function App() { } /> } /> } /> + } /> diff --git a/frontend/src/api/apiKeys.ts b/frontend/src/api/apiKeys.ts new file mode 100644 index 0000000..8bfb412 --- /dev/null +++ b/frontend/src/api/apiKeys.ts @@ -0,0 +1,18 @@ +import apiClient from './client'; +import type { ApiKey, CreatedApiKey, CreateApiKeyInput } from '@/types'; + +export const apiKeysApi = { + getAll: async (): Promise => { + const { data } = await apiClient.get('/api-keys'); + return data; + }, + + create: async (input: CreateApiKeyInput): Promise => { + const { data } = await apiClient.post('/api-keys', input); + return data; + }, + + delete: async (id: string): Promise => { + await apiClient.delete(`/api-keys/${id}`); + }, +}; diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 1891778..3c56ccd 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -8,6 +8,7 @@ import { LogOut, ChevronDown, Settings, + Key, } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; import { useState, useRef, useEffect } from "react"; @@ -40,6 +41,7 @@ export function Navbar() { const managementItems = [ { to: "/clients", label: "Clients", icon: Briefcase }, { to: "/projects", label: "Projects", icon: FolderOpen }, + { to: "/api-keys", label: "API Keys", icon: Key }, ]; return ( diff --git a/frontend/src/hooks/useApiKeys.ts b/frontend/src/hooks/useApiKeys.ts new file mode 100644 index 0000000..93c2ec5 --- /dev/null +++ b/frontend/src/hooks/useApiKeys.ts @@ -0,0 +1,34 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiKeysApi } from '@/api/apiKeys'; +import type { CreateApiKeyInput } from '@/types'; + +export function useApiKeys() { + const queryClient = useQueryClient(); + + const { data: apiKeys, isLoading, error } = useQuery({ + queryKey: ['apiKeys'], + queryFn: apiKeysApi.getAll, + }); + + const createApiKey = useMutation({ + mutationFn: (input: CreateApiKeyInput) => apiKeysApi.create(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['apiKeys'] }); + }, + }); + + const deleteApiKey = useMutation({ + mutationFn: (id: string) => apiKeysApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['apiKeys'] }); + }, + }); + + return { + apiKeys, + isLoading, + error, + createApiKey, + deleteApiKey, + }; +} diff --git a/frontend/src/pages/ApiKeysPage.tsx b/frontend/src/pages/ApiKeysPage.tsx new file mode 100644 index 0000000..f1a0eb1 --- /dev/null +++ b/frontend/src/pages/ApiKeysPage.tsx @@ -0,0 +1,235 @@ +import { useState } from "react"; +import { Key, Plus, Trash2, Copy, Check, AlertTriangle } from "lucide-react"; +import { useApiKeys } from "@/hooks/useApiKeys"; +import type { CreatedApiKey } from "@/types"; + +export function ApiKeysPage() { + const { apiKeys, isLoading, error, createApiKey, deleteApiKey } = useApiKeys(); + + const [showCreateModal, setShowCreateModal] = useState(false); + const [newKeyName, setNewKeyName] = useState(""); + const [createError, setCreateError] = useState(null); + const [createdKey, setCreatedKey] = useState(null); + const [copiedKey, setCopiedKey] = useState(false); + const [revokeConfirmId, setRevokeConfirmId] = useState(null); + + function formatDate(dateStr: string | null) { + if (!dateStr) return "Never"; + return new Date(dateStr).toLocaleString(); + } + + async function handleCreate() { + if (!newKeyName.trim()) return; + setCreateError(null); + try { + const key = await createApiKey.mutateAsync({ name: newKeyName.trim() }); + setCreatedKey(key); + setNewKeyName(""); + } catch (err) { + setCreateError(err instanceof Error ? err.message : "An error occurred"); + } + } + + async function handleCopyKey() { + if (!createdKey) return; + await navigator.clipboard.writeText(createdKey.rawKey); + setCopiedKey(true); + setTimeout(() => setCopiedKey(false), 2000); + } + + function handleCloseCreateModal() { + setShowCreateModal(false); + setCreatedKey(null); + setNewKeyName(""); + setCreateError(null); + setCopiedKey(false); + } + + async function handleRevoke(id: string) { + try { + await deleteApiKey.mutateAsync(id); + setRevokeConfirmId(null); + } catch (err) { + // error is surfaced inline via mutation state + } + } + + return ( +
+
+
+ +

API Keys

+
+ +
+ +

+ API keys allow agents and external tools to authenticate with the TimeTracker API and MCP endpoint. + The raw key is only shown once at creation time — store it securely. +

+ + {error && ( +
+ {error instanceof Error ? error.message : "Failed to load API keys"} +
+ )} + + {isLoading ? ( +
Loading...
+ ) : !apiKeys || apiKeys.length === 0 ? ( +
+ +

No API keys yet. Create one to get started.

+
+ ) : ( +
+ + + + + + + + + + + + {apiKeys.map((key) => ( + + + + + + + + ))} + +
NamePrefixCreatedLast UsedActions
{key.name} + + {key.prefix}… + + {formatDate(key.createdAt)}{formatDate(key.lastUsedAt)} + {revokeConfirmId === key.id ? ( +
+ Revoke? + + +
+ ) : ( + + )} +
+
+ )} + + {/* Create API Key Modal */} + {showCreateModal && ( +
+
+
+

Create API Key

+
+ +
+ {createdKey ? ( + /* One-time key reveal */ +
+
+ +

+ Copy this key now. It will not be shown again. +

+
+
+ +
+ + {createdKey.rawKey} + + +
+
+
+ ) : ( + /* Name input form */ +
+
+ + setNewKeyName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleCreate()} + placeholder="e.g. My Claude Agent" + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" + autoFocus + /> +
+ {createError && ( +

{createError}

+ )} +
+ )} +
+ +
+ + {!createdKey && ( + + )} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 65c070b..84cef6f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -210,3 +210,19 @@ export interface CreateCorrectionInput { hours: number; description?: string; } + +export interface ApiKey { + id: string; + name: string; + prefix: string; + createdAt: string; + lastUsedAt: string | null; +} + +export interface CreatedApiKey extends ApiKey { + rawKey: string; // returned only on creation +} + +export interface CreateApiKeyInput { + name: string; +} From a7ab55932f9537ba7d91e3c15920d7dac98a4673 Mon Sep 17 00:00:00 2001 From: "simon.franken" Date: Mon, 16 Mar 2026 15:35:06 +0100 Subject: [PATCH 2/2] fix: review fixes for MCP and API key feature - Remove spurious ALTER TABLE client_targets DROP DEFAULT from migration (Prisma schema drift side-effect unrelated to this PR) - Surface revoke errors in ApiKeysPage UI via deleteApiKey.isError - Fix UpdateProjectInput.color type to allow null, removing unsafe cast in the update_project MCP tool handler --- .../20260316103132_add_api_keys/migration.sql | 3 --- backend/src/routes/mcp.routes.ts | 11 ++--------- backend/src/types/index.ts | 2 +- frontend/src/pages/ApiKeysPage.tsx | 12 ++++++++++-- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/backend/prisma/migrations/20260316103132_add_api_keys/migration.sql b/backend/prisma/migrations/20260316103132_add_api_keys/migration.sql index c688adf..97b7610 100644 --- a/backend/prisma/migrations/20260316103132_add_api_keys/migration.sql +++ b/backend/prisma/migrations/20260316103132_add_api_keys/migration.sql @@ -1,6 +1,3 @@ --- AlterTable -ALTER TABLE "client_targets" ALTER COLUMN "working_days" DROP DEFAULT; - -- CreateTable CREATE TABLE "api_keys" ( "id" TEXT NOT NULL, diff --git a/backend/src/routes/mcp.routes.ts b/backend/src/routes/mcp.routes.ts index 6fb6447..c6d599a 100644 --- a/backend/src/routes/mcp.routes.ts +++ b/backend/src/routes/mcp.routes.ts @@ -139,15 +139,8 @@ function buildMcpServer(user: AuthenticatedUser): McpServer { clientId: z.string().uuid().optional().describe('Move project to a different client'), }, }, - async (args) => { - const { id, ...rest } = args as { - id: string; - name?: string; - description?: string; - color?: string | null; - clientId?: string; - }; - const project = await projectService.update(id, userId, rest as import('../types').UpdateProjectInput); + async ({ id, name, description, color, clientId }) => { + const project = await projectService.update(id, userId, { name, description, color, clientId }); return { content: [{ type: 'text', text: JSON.stringify(project, null, 2) }] }; } ); diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 356274b..cd0e808 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -31,7 +31,7 @@ export interface CreateProjectInput { export interface UpdateProjectInput { name?: string; description?: string; - color?: string; + color?: string | null; clientId?: string; } diff --git a/frontend/src/pages/ApiKeysPage.tsx b/frontend/src/pages/ApiKeysPage.tsx index f1a0eb1..c1b330a 100644 --- a/frontend/src/pages/ApiKeysPage.tsx +++ b/frontend/src/pages/ApiKeysPage.tsx @@ -49,8 +49,8 @@ export function ApiKeysPage() { try { await deleteApiKey.mutateAsync(id); setRevokeConfirmId(null); - } catch (err) { - // error is surfaced inline via mutation state + } catch (_err) { + // error rendered below the table row via deleteApiKey.error } } @@ -81,6 +81,14 @@ export function ApiKeysPage() { )} + {deleteApiKey.isError && ( +
+ {deleteApiKey.error instanceof Error + ? deleteApiKey.error.message + : "Failed to revoke API key"} +
+ )} + {isLoading ? (
Loading...
) : !apiKeys || apiKeys.length === 0 ? (