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
This commit is contained in:
simon.franken
2026-03-16 15:26:09 +01:00
parent cd03d8751e
commit 64211e6a49
16 changed files with 1642 additions and 1 deletions

View File

@@ -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);

View File

@@ -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})`);

View File

@@ -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;

View File

@@ -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<void> {
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;

View File

@@ -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),
});

View File

@@ -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<CreatedApiKey> {
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<ApiKeyListItem[]> {
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<void> {
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<AuthenticatedUser | null> {
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,
};
}
}