9 Commits

Author SHA1 Message Date
simon.franken
a91a8e9dfa adds timer break feature requirement 2026-03-26 09:51:55 +01:00
simon.franken
fb94745588 adds instructions for feature development 2026-03-26 09:26:52 +01:00
simon.franken
8eddbed00b update docs 2026-03-26 09:17:41 +01:00
simon.franken
e83d247cf9 documents ios-authentication 2026-03-25 10:50:09 +01:00
simon.franken
88866f73e6 moves helm chart 2026-03-25 10:25:29 +01:00
ca521000bf Merge pull request 'feature/mcp-and-api-keys' (#11) from feature/mcp-and-api-keys into main
Reviewed-on: #11
2026-03-16 16:46:26 +00:00
simon.franken
a7ab55932f 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
2026-03-16 15:35:06 +01:00
simon.franken
64211e6a49 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
2026-03-16 15:26:09 +01:00
cd03d8751e fix: timer widget blocks dialogs 2026-03-13 17:08:21 +00:00
37 changed files with 2272 additions and 294 deletions

View File

@@ -0,0 +1,251 @@
# Feature Planning Skill
This skill provides a structured workflow for implementing new features in the TimeTracker project. Use this skill when the user requests a new feature or significant functionality change.
## Workflow Overview
```
1. Requirements Discovery (iterative)
└── Clarify edge cases, acceptance criteria, constraints
2. Feature Plan Creation
└── docs/features/{feature-name}.md
3. Implementation
└── Use plan as single source of truth
```
## Phase 1: Requirements Discovery
**Goal:** Understand exactly what needs to be built before writing any code.
### Questions to Ask
Ask targeted questions to clarify:
#### Core Functionality
- What is the primary purpose of this feature?
- What user problem does it solve?
- How should users interact with this feature?
#### Data & API
- What new data needs to be stored?
- What existing data structures are affected?
- What API endpoints are needed (if any)?
#### User Interface
- Where in the UI should this feature appear?
- What views or components are needed?
- What user interactions are required?
#### Edge Cases
- What happens when inputs are invalid?
- How should errors be handled?
- What are the boundary conditions?
- Are there any race conditions to consider?
#### Constraints
- Are there performance requirements?
- Any security considerations?
- Browser/device compatibility?
- Integration with existing features?
### Iteration Process
1. **Initial Questions** — Ask 3-5 focused questions based on the feature description
2. **Wait for Answers** — Let the programmer respond
3. **Follow-up Questions** — Dig deeper based on responses
4. **Confirm Understanding** — Summarize your understanding and ask for confirmation
5. **Iterate** — Repeat until requirements are clear
### Example Question Flow
```
User: "Add the ability to export time entries to CSV"
Agent: "Before I start planning, I have a few questions:
1. Should the export include all entries or only filtered entries (if filters are active)?
2. What columns should be included in the CSV? (date, duration, project, client, description?)
3. Should the CSV include break minutes and net duration, or just total time?
4. Is there a date range limit, or can users export all historical data?
5. Should the export be triggered from the Time Entries page, or from a separate Export page?"
```
## Phase 2: Feature Plan
**Goal:** Create a comprehensive plan document before implementation.
### Plan Location
Create the plan at: `docs/features/{feature-name}.md`
Use kebab-case for the filename (e.g., `csv-export.md`, `dark-mode.md`).
### Plan Template
```markdown
# Feature: {Feature Name}
## Overview
Brief description of what this feature does and why it's needed.
## Requirements
### Functional Requirements
- Requirement 1
- Requirement 2
- Requirement 3
### Non-Functional Requirements
- Performance: ...
- Security: ...
- Usability: ...
### Constraints
- Constraint 1
- Constraint 2
## Technical Approach
### Architecture Decisions
- Decision 1 and rationale
- Decision 2 and rationale
### Database Changes
- New tables/columns
- Migrations needed
- Data migration strategy (if any)
### API Changes
- New endpoints
- Modified endpoints
- Request/response formats
### Frontend Changes
- New components
- Modified components
- State management approach
## Implementation Steps
1. **Step 1: Backend - Database**
- Create migration
- Update Prisma schema
- Regenerate client
2. **Step 2: Backend - Service**
- Add service methods
- Add validation schemas
3. **Step 3: Backend - Routes**
- Create route handlers
- Add middleware
4. **Step 4: Frontend - API Client**
- Add API functions
5. **Step 5: Frontend - Components**
- Create/update components
- Add to routes if needed
6. **Step 6: Testing**
- Manual testing steps
- Edge case verification
## File Changes
### New Files
- `backend/src/services/export.service.ts`
- `frontend/src/hooks/useExport.ts`
### Modified Files
- `backend/src/routes/timeEntry.routes.ts` — Add export endpoint
- `frontend/src/pages/TimeEntriesPage.tsx` — Add export button
- `frontend/src/api/timeEntries.ts` — Add export function
### Database
- No changes required (or specify migration)
## Edge Cases
| Case | Handling |
|------|----------|
| No entries match filter | Show empty state, export empty CSV with headers |
| Very large export (>10k entries) | Stream response, show progress indicator |
| User cancels export mid-stream | Gracefully close connection |
| Invalid date range | Return 400 error with clear message |
## Testing Strategy
### Manual Testing
1. Navigate to Time Entries page
2. Apply date filter
3. Click Export button
4. Verify CSV downloads with correct data
5. Open CSV and verify format
### Edge Case Testing
1. Export with no entries
2. Export with 1000+ entries
3. Export with special characters in descriptions
4. Export while timer is running
## Open Questions
- [ ] Question 1 (to be resolved during implementation)
- [ ] Question 2
```
### Plan Review
After creating the plan:
1. Present the plan to the programmer
2. Ask for feedback and approval
3. Make requested changes
4. Get final approval before proceeding to implementation
## Phase 3: Implementation
**Goal:** Implement the feature exactly as planned.
### Rules
1. **Read the plan first** — Start by reading the full plan file
2. **Follow the plan** — Implement step by step as outlined
3. **Update if needed** — If implementation differs from plan, update the plan file
4. **Document changes** — After completion, update relevant documentation
### Implementation Checklist
- [ ] Read `docs/features/{feature-name}.md`
- [ ] Implement database changes (if any)
- [ ] Implement backend service logic
- [ ] Implement backend routes
- [ ] Implement frontend API client
- [ ] Implement frontend components
- [ ] Run linting: `npm run lint`
- [ ] Manual testing
- [ ] Update plan if implementation differs
- [ ] Update `project.md` if requirements changed
- [ ] Update `README.md` if API changed
- [ ] Update `AGENTS.md` if patterns changed
## Quick Reference
### Commands
- Frontend lint: `npm run lint` (in `frontend/`)
- Backend build: `npm run build` (in `backend/`)
- DB migration: `npm run db:migrate` (in `backend/`)
- DB generate: `npm run db:generate` (in `backend/`)
### File Locations
- Backend routes: `backend/src/routes/`
- Backend services: `backend/src/services/`
- Backend schemas: `backend/src/schemas/`
- Frontend pages: `frontend/src/pages/`
- Frontend hooks: `frontend/src/hooks/`
- Frontend API: `frontend/src/api/`
- Feature plans: `docs/features/`

View File

@@ -1,6 +1,6 @@
# AGENTS.md — Codebase Guide for AI Coding Agents # AGENTS.md — Codebase Guide for AI Coding Agents
This document describes the structure, conventions, and commands for the `vibe_coding_timetracker` monorepo. Read it in full before making changes. This document describes the structure, conventions, and commands for the `vibe_coding_timetracker` monorepo. **Read it in full before making changes.**
## Repository Structure ## Repository Structure
@@ -18,17 +18,103 @@ This document describes the structure, conventions, and commands for the `vibe_c
├── backend/ # Express REST API (TypeScript + Prisma + PostgreSQL) ├── backend/ # Express REST API (TypeScript + Prisma + PostgreSQL)
│ └── src/ │ └── src/
│ ├── auth/ # OIDC + JWT logic │ ├── auth/ # OIDC + JWT logic
│ ├── config/ # Configuration constants
│ ├── errors/ # AppError subclasses │ ├── errors/ # AppError subclasses
│ ├── middleware/# Express middlewares │ ├── middleware/# Express middlewares
│ ├── prisma/ # Prisma client singleton │ ├── prisma/ # Prisma client singleton
│ ├── routes/ # Express routers (xxx.routes.ts) │ ├── routes/ # Express routers (xxx.routes.ts)
│ ├── schemas/ # Zod validation schemas │ ├── schemas/ # Zod validation schemas
── services/ # Business logic classes (xxx.service.ts) ── services/ # Business logic classes (xxx.service.ts)
│ ├── types/ # TypeScript interfaces
│ └── utils/ # Utility functions
├── ios/ # Native iOS app (Swift/Xcode) ├── ios/ # Native iOS app (Swift/Xcode)
├── timetracker-chart/ # Helm chart for Kubernetes deployment ├── helm/ # Helm chart for Kubernetes deployment
└── docker-compose.yml └── docker-compose.yml
``` ```
## AI Agent Workflow
### Before Making Changes
1. Read this file completely
2. Read `project.md` for feature requirements
3. Read `README.md` for setup instructions
4. Understand the specific task or feature request
### During Development
1. Follow all code conventions in this document
2. Write clean, maintainable code
3. Add inline comments only when necessary for clarity
4. Run linting before completing: `npm run lint`
### After Making Changes
**Always update documentation.** See [Documentation Maintenance](#documentation-maintenance).
## Feature Development Workflow
**For new features, AI agents MUST follow this process before writing any code.**
### Phase 1: Requirements Discovery
1. Ask clarifying questions about the feature request
2. Identify edge cases, constraints, and acceptance criteria
3. Confirm understanding with the programmer
4. Iterate until requirements are clear
### Phase 2: Feature Plan
1. Create `docs/features/{feature-name}.md` with the feature plan
2. Include: overview, requirements, technical approach, file changes, edge cases, testing
3. Present plan for review
4. Iterate until approved by the programmer
### Phase 3: Implementation
1. Use the approved plan as the single source of truth
2. Implement step by step following the plan
3. Update the plan if implementation differs
4. Update documentation after completion
**See the `feature-planning` skill for detailed workflow and templates.**
## Documentation Maintenance
**Every code change requires a documentation review.** When you modify the codebase, check whether documentation needs updating.
### Documentation Files and Their Purposes
| File | Purpose | Update When |
|------|---------|-------------|
| `AGENTS.md` | Code conventions, commands, architecture patterns | Changing conventions, adding new patterns, modifying architecture |
| `README.md` | Setup instructions, API reference, features list | Adding endpoints, changing environment variables, adding features |
| `project.md` | Requirements, data model, functional specifications | Modifying business logic, adding entities, changing validation rules |
### Update Rules
#### Update `AGENTS.md` When:
- Adding a new coding pattern or convention
- Changing the project structure (new directories, reorganization)
- Adding or modifying build/lint/test commands
- Introducing a new architectural pattern
- Changing state management or error handling approaches
#### Update `README.md` When:
- Adding, removing, or modifying API endpoints
- Changing environment variables or configuration
- Adding new features visible to users
- Modifying setup or installation steps
- Changing the technology stack
#### Update `project.md` When:
- Adding or modifying business requirements
- Changing the data model or relationships
- Adding new validation rules
- Modifying functional specifications
- Updating security or non-functional requirements
### Documentation Format Rules
- Use Markdown formatting
- Keep entries concise and actionable
- Match the existing tone and style
- Use code blocks for commands and code examples
- Maintain alphabetical or logical ordering in lists
## Build, Lint, and Dev Commands ## Build, Lint, and Dev Commands
### Frontend (`frontend/`) ### Frontend (`frontend/`)

39
DOCS.md Normal file
View File

@@ -0,0 +1,39 @@
# Documentation Guide
## Documentation Files
| File | Purpose | When to Update |
|------|---------|----------------|
| `AGENTS.md` | Code conventions, commands, architecture, AI agent workflow | Adding patterns, changing conventions, modifying structure, updating agent workflow |
| `README.md` | Setup instructions, API reference, features list | New endpoints, config changes, new features, technology stack changes |
| `project.md` | Requirements, data model, functional specifications | Business logic changes, new entities, validation rules, UI requirements |
| `DOCS.md` | Documentation standards and index | Documentation process changes, new documentation files |
| `docs/features/*.md` | Feature implementation plans | Created during feature development, updated if implementation differs |
## AI Agent Skills
| Skill | Purpose | When to Use |
|-------|---------|-------------|
| `feature-planning` | Structured workflow for new features | When implementing new features or significant functionality changes |
## Documentation Standards
- Use Markdown formatting
- Keep entries concise and actionable
- Use code blocks for commands and examples
- Match existing tone and style
- Maintain logical ordering in lists
## Maintenance Rules
### AI Agents Must Update Documentation When:
1. Adding new code patterns or conventions
2. Modifying API endpoints or configuration
3. Changing business logic or data models
4. Adding new features or entities
### Review Checklist
- [ ] Documentation reflects code changes
- [ ] Examples are accurate and tested
- [ ] Formatting is consistent
- [ ] No outdated information remains

View File

@@ -10,6 +10,10 @@ A multi-user web application for tracking time spent working on projects. Users
- **Time Tracking** - Start/stop timer with live elapsed time display - **Time Tracking** - Start/stop timer with live elapsed time display
- **Manual Entry** - Add time entries manually for past work - **Manual Entry** - Add time entries manually for past work
- **Validation** - Overlap prevention and end-time validation - **Validation** - Overlap prevention and end-time validation
- **Statistics** - View aggregated time tracking data by project and client
- **Client Targets** - Set hourly targets per client with weekly/monthly periods
- **API Keys** - Generate API keys for external tools and AI agents
- **MCP Integration** - Model Context Protocol endpoint for AI agent access
- **Responsive UI** - Works on desktop and mobile - **Responsive UI** - Works on desktop and mobile
## Architecture ## Architecture
@@ -125,6 +129,27 @@ APP_URL="http://localhost:5173"
- `POST /api/timer/start` - Start timer - `POST /api/timer/start` - Start timer
- `PUT /api/timer` - Update timer (set project) - `PUT /api/timer` - Update timer (set project)
- `POST /api/timer/stop` - Stop timer (creates entry) - `POST /api/timer/stop` - Stop timer (creates entry)
- `POST /api/timer/cancel` - Cancel timer without saving
### Client Targets
- `GET /api/client-targets` - List targets with balance
- `POST /api/client-targets` - Create target
- `PUT /api/client-targets/:id` - Update target
- `DELETE /api/client-targets/:id` - Delete target
- `POST /api/client-targets/:id/corrections` - Add correction
- `DELETE /api/client-targets/:id/corrections/:correctionId` - Delete correction
### API Keys
- `GET /api/api-keys` - List API keys
- `POST /api/api-keys` - Create API key
- `DELETE /api/api-keys/:id` - Revoke API key
### MCP (Model Context Protocol)
- `GET /mcp` - SSE stream for server-initiated messages
- `POST /mcp` - JSON-RPC requests (tool invocations)
## Data Model ## Data Model

View File

@@ -8,6 +8,7 @@
"name": "timetracker-backend", "name": "timetracker-backend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@quixo3/prisma-session-store": "^3.1.19", "@quixo3/prisma-session-store": "^3.1.19",
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -471,6 +472,367 @@
"node": ">=18" "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": { "node_modules/@noble/hashes": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@@ -771,6 +1133,39 @@
"node": ">= 0.6" "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": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -983,6 +1378,20 @@
"url": "https://opencollective.com/express" "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": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -1193,6 +1602,27 @@
"node": ">= 0.6" "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": { "node_modules/express": {
"version": "4.22.1", "version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
@@ -1239,6 +1669,24 @@
"url": "https://opencollective.com/express" "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": { "node_modules/express-session": {
"version": "1.19.0", "version": "1.19.0",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
@@ -1292,6 +1740,28 @@
"node": ">=8.0.0" "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": { "node_modules/finalhandler": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -1456,6 +1926,15 @@
"node": ">= 0.4" "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": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1494,6 +1973,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1503,6 +1991,18 @@
"node": ">= 0.10" "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": { "node_modules/jiti": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -1522,6 +2022,18 @@
"url": "https://github.com/sponsors/panva" "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": { "node_modules/jsonwebtoken": {
"version": "9.0.3", "version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -1808,6 +2320,15 @@
"node": ">= 0.8" "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": { "node_modules/openid-client": {
"version": "5.7.1", "version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
@@ -1832,6 +2353,15 @@
"node": ">= 0.8" "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": { "node_modules/path-to-regexp": {
"version": "0.1.12", "version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
@@ -1852,6 +2382,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/pkg-types": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
@@ -1993,6 +2532,15 @@
"url": "https://paulmillr.com/funding/" "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": { "node_modules/resolve-pkg-maps": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "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" "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": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -2092,6 +2689,27 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "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": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -2321,6 +2939,27 @@
"node": ">= 0.8" "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": { "node_modules/yallist": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
@@ -2335,6 +2974,15 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "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"
}
} }
} }
} }

View File

@@ -10,6 +10,7 @@
"db:seed": "tsx prisma/seed.ts" "db:seed": "tsx prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@quixo3/prisma-session-store": "^3.1.19", "@quixo3/prisma-session-store": "^3.1.19",
"cors": "^2.8.5", "cors": "^2.8.5",

View File

@@ -0,0 +1,21 @@
-- 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;

View File

@@ -20,6 +20,7 @@ model User {
timeEntries TimeEntry[] timeEntries TimeEntry[]
ongoingTimer OngoingTimer? ongoingTimer OngoingTimer?
clientTargets ClientTarget[] clientTargets ClientTarget[]
apiKeys ApiKey[]
@@map("users") @@map("users")
} }
@@ -151,3 +152,18 @@ model Session {
@@map("sessions") @@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")
}

View File

@@ -13,6 +13,8 @@ import projectRoutes from "./routes/project.routes";
import timeEntryRoutes from "./routes/timeEntry.routes"; import timeEntryRoutes from "./routes/timeEntry.routes";
import timerRoutes from "./routes/timer.routes"; import timerRoutes from "./routes/timer.routes";
import clientTargetRoutes from "./routes/clientTarget.routes"; import clientTargetRoutes from "./routes/clientTarget.routes";
import apiKeyRoutes from "./routes/apiKey.routes";
import mcpRoutes from "./routes/mcp.routes";
async function main() { async function main() {
// Validate configuration // Validate configuration
@@ -70,6 +72,8 @@ async function main() {
app.use("/time-entries", timeEntryRoutes); app.use("/time-entries", timeEntryRoutes);
app.use("/timer", timerRoutes); app.use("/timer", timerRoutes);
app.use("/client-targets", clientTargetRoutes); app.use("/client-targets", clientTargetRoutes);
app.use("/api-keys", apiKeyRoutes);
app.use("/mcp", mcpRoutes);
// Error handling // Error handling
app.use(notFoundHandler); app.use(notFoundHandler);

View File

@@ -2,6 +2,9 @@ import { Request, Response, NextFunction } from 'express';
import { prisma } from '../prisma/client'; import { prisma } from '../prisma/client';
import type { AuthenticatedRequest, AuthenticatedUser } from '../types'; import type { AuthenticatedRequest, AuthenticatedUser } from '../types';
import { verifyBackendJwt } from '../auth/jwt'; import { verifyBackendJwt } from '../auth/jwt';
import { ApiKeyService } from '../services/apiKey.service';
const apiKeyService = new ApiKeyService();
export async function requireAuth( export async function requireAuth(
req: AuthenticatedRequest, req: AuthenticatedRequest,
@@ -17,11 +20,33 @@ export async function requireAuth(
return next(); return next();
} }
// 2. Bearer JWT auth (iOS / native clients) // 2. Bearer token auth (JWT or API key)
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) { if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7); const token = authHeader.slice(7);
console.log(`${tag} -> Bearer token present (first 20 chars: ${token.slice(0, 20)}…)`); 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 { try {
req.user = verifyBackendJwt(token); req.user = verifyBackendJwt(token);
console.log(`${tag} -> JWT auth OK (user: ${req.user.id})`); 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,455 @@
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 ({ 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) }] };
}
);
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), hours: z.number().min(-1000).max(1000),
description: z.string().max(255).optional(), 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,
};
}
}

View File

@@ -31,7 +31,7 @@ export interface CreateProjectInput {
export interface UpdateProjectInput { export interface UpdateProjectInput {
name?: string; name?: string;
description?: string; description?: string;
color?: string; color?: string | null;
clientId?: string; clientId?: string;
} }

View File

@@ -1,285 +0,0 @@
# Client Targets v2 — Feature Requirements
## Overview
This document defines the requirements for the second iteration of the Client Targets feature. The main additions are:
- Targets can be set on a **weekly or monthly** period.
- Each target defines a **fixed weekly working-day pattern** (e.g. Mon + Wed).
- The balance for the **current period** is calculated proportionally based on elapsed working days, so the user can see at any point in time whether they are ahead or behind.
- The **start date** can be any calendar day (no longer restricted to Mondays).
- Manual **balance corrections** are preserved and continue to work as before.
---
## 1. Target Configuration
| Field | Type | Constraints |
|---|---|---|
| `periodType` | `WEEKLY \| MONTHLY` | Required |
| `weeklyOrMonthlyHours` | positive float, ≤ 168 | Required; represents hours per week or per month |
| `workingDays` | array of day names | At least one of `MON TUE WED THU FRI SAT SUN`; fixed repeating pattern |
| `startDate` | `YYYY-MM-DD` | Any calendar day; no longer restricted to Mondays |
| `clientId` | UUID | Must belong to the authenticated user |
**One active target per client** — the unique `(userId, clientId)` constraint is preserved. To change period type, hours, or working days the user creates a new target with a new `startDate`; the old target is soft-deleted. History from the old target is retained as-is and is no longer recalculated.
---
## 2. Period Definitions
| `periodType` | Period start | Period end |
|---|---|---|
| `WEEKLY` | Monday 00:00 of the calendar week | Sunday 23:59 of that same calendar week |
| `MONTHLY` | 1st of the calendar month 00:00 | Last day of the calendar month 23:59 |
---
## 3. Balance Calculation — Overview
The total balance is the **sum of individual period balances** from the period containing `startDate` up to and including the **current period** (the period that contains today).
Each period is classified as either **completed** or **ongoing**.
```
total_balance_seconds = SUM( balance_seconds ) over all periods
```
Positive = overtime. Negative = undertime.
---
## 4. Completed Period Balance
A period is **completed** when its end date is strictly before today.
```
balance = tracked_hours + correction_hours - period_target_hours
```
- `period_target_hours` — see §5 (pro-ration) for the first period; full `weeklyOrMonthlyHours` for all subsequent periods.
- `tracked_hours` — sum of all time entries for this client whose date falls within `[period_start, period_end]`.
- `correction_hours` — sum of manual corrections whose `date` falls within `[period_start, period_end]`.
No working-day logic is applied to completed periods. The target is simply the (optionally pro-rated) hours for that period.
---
## 5. First Period Pro-ration
If `startDate` does not fall on the natural first day of a period (Monday for weekly, 1st for monthly), the target hours for that first period are pro-rated by calendar days.
### Monthly
```
full_period_days = total calendar days in that month
remaining_days = (last day of month) startDate + 1 // inclusive
period_target_hours = (remaining_days / full_period_days) × weeklyOrMonthlyHours
```
**Example:** startDate = Jan 25, target = 40 h/month, January has 31 days.
`remaining_days = 7`, `period_target_hours = (7 / 31) × 40 = 9.032 h`
### Weekly
```
full_period_days = 7
remaining_days = Sunday of that calendar week startDate + 1 // inclusive
period_target_hours = (remaining_days / 7) × weeklyOrMonthlyHours
```
**Example:** startDate = Wednesday, target = 40 h/week.
`remaining_days = 5 (WedSun)`, `period_target_hours = (5 / 7) × 40 = 28.571 h`
All periods after the first use the full `weeklyOrMonthlyHours`.
---
## 6. Ongoing Period Balance (Current Period)
The current period is **ongoing** when today falls within it. The balance reflects how the user is doing *so far* — future working days within the current period are not considered.
### Step 1 — Period target hours
Apply §5 if this is the first period; otherwise use full `weeklyOrMonthlyHours`.
### Step 2 — Daily rate
```
working_days_in_period = COUNT of days in [period_start, period_end]
that match the working day pattern
daily_rate_hours = period_target_hours / working_days_in_period
```
The rate is fixed at the start of the period and does not change as time passes.
### Step 3 — Elapsed working days
```
elapsed_working_days = COUNT of days in [period_start, TODAY] (both inclusive)
that match the working day pattern
```
- If today matches the working day pattern, it is counted as a **full** elapsed working day.
- If today does not match the working day pattern, it is not counted.
### Step 4 — Expected hours so far
```
expected_hours = elapsed_working_days × daily_rate_hours
```
### Step 5 — Balance
```
tracked_hours = SUM of time entries for this client in [period_start, today]
correction_hours = SUM of manual corrections whose date ∈ [period_start, today]
balance = tracked_hours + correction_hours expected_hours
```
### Worked example
> Target: 40 h/month. Working days: Mon + Wed.
> Current month has 4 Mondays and 4 Wednesdays → `working_days_in_period = 8`.
> `daily_rate_hours = 40 / 8 = 5 h`.
> 3 working days have elapsed → `expected_hours = 15 h`.
> Tracked so far: 13 h, no corrections.
> `balance = 13 15 = 2 h` (2 hours behind).
---
## 7. Manual Balance Corrections
| Field | Type | Constraints |
|---|---|---|
| `date` | `YYYY-MM-DD` | Must be ≥ `startDate`; not more than one period in the future |
| `hours` | signed float | Positive = extra credit (reduces deficit). Negative = reduces tracked credit |
| `description` | string | Optional, max 255 chars |
- The system automatically assigns a correction to the period that contains its `date`.
- Corrections in **completed periods** are included in the completed period formula (§4).
- Corrections in the **ongoing period** are included in the ongoing balance formula (§6).
- Corrections in a **future period** (not yet started) are stored and will be applied when that period becomes active.
- A correction whose `date` is before `startDate` is rejected with a validation error.
---
## 8. Edge Cases
| Scenario | Behaviour |
|---|---|
| `startDate` = 1st of month / Monday | No pro-ration; `period_target_hours = weeklyOrMonthlyHours` |
| `startDate` = last day of period | `remaining_days = 1`; target is heavily reduced (e.g. 1/31 × hours) |
| Working pattern has no matches in the partial first period | `elapsed_working_days = 0`; `expected_hours = 0`; balance = `tracked + corrections` |
| Current period has zero elapsed working days | `expected_hours = 0`; balance = `tracked + corrections` (cannot divide by zero — guard required) |
| `working_days_in_period = 0` | Impossible by validation (at least one day required), but system must guard: treat as `daily_rate_hours = 0` |
| Today is not a working day | `elapsed_working_days` does not include today |
| Correction date before `startDate` | Rejected with a validation error |
| Correction date in future period | Accepted and stored; applied when that period is ongoing or completed |
| User changes working days or period type | Must create a new target with a new `startDate`; old target history is frozen |
| Two periods with the same client exist (old soft-deleted, new active) | Only the active target's periods contribute to the displayed balance |
| A month with only partial working day coverage (e.g. all Mondays are public holidays) | No automatic holiday handling; user adds manual corrections to compensate |
---
## 9. Data Model Changes
### `ClientTarget` table — additions / changes
| Column | Change | Notes |
|---|---|---|
| `period_type` | **Add** | Enum: `WEEKLY`, `MONTHLY` |
| `working_days` | **Add** | Array/bitmask of day names: `MON TUE WED THU FRI SAT SUN` |
| `start_date` | **Modify** | Remove "must be Monday" validation constraint |
| `weekly_hours` | **Rename** | → `target_hours` (represents hours per week or per month depending on `period_type`) |
### `BalanceCorrection` table — no structural changes
Date-to-period assignment is computed at query time, not stored.
---
## 10. API Changes
### `ClientTargetWithBalance` response shape
```typescript
interface ClientTargetWithBalance {
id: string
clientId: string
clientName: string
userId: string
periodType: "weekly" | "monthly"
targetHours: number // renamed from weeklyHours
workingDays: string[] // e.g. ["MON", "WED"]
startDate: string // YYYY-MM-DD
createdAt: string
updatedAt: string
corrections: BalanceCorrection[]
totalBalanceSeconds: number // running total across all periods
currentPeriodTrackedSeconds: number // replaces currentWeekTrackedSeconds
currentPeriodTargetSeconds: number // replaces currentWeekTargetSeconds
periods: PeriodBalance[] // replaces weeks[]
}
interface PeriodBalance {
periodStart: string // YYYY-MM-DD (Monday or 1st of month)
periodEnd: string // YYYY-MM-DD (Sunday or last of month)
targetHours: number // pro-rated for first period
trackedSeconds: number
correctionHours: number
balanceSeconds: number
isOngoing: boolean
// only present when isOngoing = true
dailyRateHours?: number
workingDaysInPeriod?: number
elapsedWorkingDays?: number
expectedHours?: number
}
```
### Endpoint changes
| Method | Path | Change |
|---|---|---|
| `POST /client-targets` | Create | Accepts `periodType`, `workingDays`, `targetHours`; `startDate` unconstrained |
| `PUT /client-targets/:id` | Update | Accepts same new fields |
| `GET /client-targets` | List | Returns updated `ClientTargetWithBalance` shape |
| `POST /client-targets/:id/corrections` | Add correction | No change to signature |
| `DELETE /client-targets/:id/corrections/:corrId` | Delete correction | No change |
### Zod schema changes
- `CreateClientTargetSchema` / `UpdateClientTargetSchema`:
- Add `periodType: z.enum(["weekly", "monthly"])`
- Add `workingDays: z.array(z.enum(["MON","TUE","WED","THU","FRI","SAT","SUN"])).min(1)`
- Rename `weeklyHours``targetHours`
- Remove Monday-only regex constraint from `startDate`
---
## 11. Frontend Changes
### Types (`frontend/src/types/index.ts`)
- `ClientTargetWithBalance` — add `periodType`, `workingDays`, `targetHours`; replace `weeks``periods: PeriodBalance[]`; replace `currentWeek*``currentPeriod*`
- Add `PeriodBalance` interface
- `CreateClientTargetInput` / `UpdateClientTargetInput` — same field additions
### Hook (`frontend/src/hooks/useClientTargets.ts`)
- No structural changes; mutations pass through new fields
### API client (`frontend/src/api/clientTargets.ts`)
- No structural changes; payload shapes updated
### `ClientsPage` — `ClientTargetPanel`
- Working day selector (checkboxes: MonSun, at least one required)
- Period type selector (Weekly / Monthly)
- Label for hours input updates dynamically: "Hours/week" or "Hours/month"
- Start date picker: free date input (no week-picker)
- Balance display: label changes from "this week" to "this week" or "this month" based on `periodType`
- Expanded period list replaces the expanded week list
### `DashboardPage`
- "Weekly Targets" widget renamed to "Targets"
- "This week" label becomes "This week" / "This month" dynamically
- `currentWeek*` fields replaced with `currentPeriod*`

0
docs/features/.gitkeep Normal file
View File

View File

@@ -0,0 +1,131 @@
# Feature: Timer Breaks (Pause During Work)
## Overview
Allow users to take breaks while a timer is running. When on break, elapsed time is frozen and break time is tracked. When resumed, break time accumulates and is subtracted from the displayed work time.
## User Experience
### Timer States
1. **Running** — normal state, elapsed time ticking
2. **On Break** — elapsed time frozen, break time ticking, Stop/Cancel buttons disabled
3. **Stopped** — no timer active
### UI Changes (TimerWidget)
- Add a **"Break"** button (amber, `Pause` icon) next to Stop when timer is running
- When on break:
- Change pulsing dot color from red to amber
- Elapsed time frozen at net work time
- Show break time below: `Break: Xm XXs` (live-ticking)
- Replace "Break" button with **"Resume"** button (green, `Play` icon)
- **Disable** Stop and Cancel buttons (tooltip: "Resume before stopping")
### Duration Calculations
- **Work time (displayed):** `now - startTime - totalBreakSeconds`
- Where `totalBreakSeconds = (breakMinutes * 60) + (now - breakStart if on break)`
- When on break: frozen at `(breakStart - startTime - breakMinutes * 60)`
- **Break time (displayed):** `breakMinutes * 60 + (now - breakStart if on break)`
## Implementation
### 1. Database Schema (`backend/prisma/schema.prisma`)
Add two fields to `OngoingTimer`:
```prisma
model OngoingTimer {
// ... existing fields ...
breakMinutes Int @default(0) @map("break_minutes")
breakStart DateTime? @map("break_start") @db.Timestamptz()
}
```
Run: `npx prisma migrate dev --name add_timer_break_fields`
### 2. Backend Service (`backend/src/services/timer.service.ts`)
**New method `startBreak(userId)`:**
- Get ongoing timer, throw `NotFoundError` if none
- Check `timer.breakStart` is null (not already on break), throw `BadRequestError` if on break
- Update: `breakStart = new Date()`
- Return updated timer
**New method `endBreak(userId)`:**
- Get ongoing timer, throw `NotFoundError` if none
- Check `timer.breakStart` is not null, throw `BadRequestError` if not on break
- Calculate additional break minutes: `Math.floor((now - breakStart) / 60000)`
- Update: `breakMinutes += additionalMinutes`, `breakStart = null`
- Return updated timer
**Modify `stop(userId)`:**
- Before creating time entry, check `timer.breakStart` is null — throw `BadRequestError("Cannot stop timer while on break")` if break is active
- When creating `TimeEntry`, set `breakMinutes: timer.breakMinutes`
**Modify `cancel(userId)`:**
- Check `timer.breakStart` is null — throw `BadRequestError("Cannot cancel timer while on break")` if break is active
### 3. Backend Routes (`backend/src/routes/timer.routes.ts`)
Add two new routes (both require auth, no body validation):
```
POST /api/timer/break → timerService.startBreak(userId)
POST /api/timer/resume → timerService.endBreak(userId)
```
### 4. MCP Tools (`backend/src/routes/mcp.routes.ts`)
Add two MCP tools: `pause_timer` and `resume_timer`.
### 5. Frontend Types (`frontend/src/types/index.ts`)
Update `OngoingTimer` interface:
```typescript
export interface OngoingTimer {
// ... existing fields ...
breakMinutes: number;
breakStart: string | null;
}
```
### 6. Frontend API (`frontend/src/api/timer.ts`)
Add two methods:
```typescript
startBreak: async (): Promise<OngoingTimer> => { ... }
endBreak: async (): Promise<OngoingTimer> => { ... }
```
### 7. Frontend TimerContext (`frontend/src/contexts/TimerContext.tsx`)
- Add `breakSeconds` state (live-updating, similar to `elapsedSeconds`)
- Expose `isOnBreak` derived boolean (`ongoingTimer?.breakStart !== null`)
- Update elapsed time calculation:
- Running: `(now - startTime) - (breakMinutes * 60) - (now - breakStart if on break)`
- On break: `(breakStart - startTime) - (breakMinutes * 60)` (frozen)
- Break seconds: `(breakMinutes * 60) + (now - breakStart if on break)`
- Add `startBreak()` and `endBreak()` callbacks
- Expose `breakSeconds` and `isOnBreak` in context value
### 8. Frontend TimerWidget (`frontend/src/components/TimerWidget.tsx`)
- Import `Pause` icon from lucide-react
- Add Break/Resume button between project selector and Stop button
- Show break time display when `breakSeconds > 0` or `isOnBreak`
- Change dot color to amber when on break
- Disable Stop/Cancel when on break with tooltip
## Files to Modify (in order)
| # | File | Change |
|---|------|--------|
| 1 | `backend/prisma/schema.prisma` | Add `breakMinutes`, `breakStart` to `OngoingTimer` |
| 2 | `backend/src/services/timer.service.ts` | Add `startBreak()`, `endBreak()`, modify `stop()` and `cancel()` |
| 3 | `backend/src/routes/timer.routes.ts` | Add `/break` and `/resume` routes |
| 4 | `backend/src/routes/mcp.routes.ts` | Add `pause_timer` and `resume_timer` MCP tools |
| 5 | `frontend/src/types/index.ts` | Add `breakMinutes`, `breakStart` to `OngoingTimer` |
| 6 | `frontend/src/api/timer.ts` | Add `startBreak()`, `endBreak()` API methods |
| 7 | `frontend/src/contexts/TimerContext.tsx` | Add break state, `breakSeconds`, `isOnBreak`, break methods |
| 8 | `frontend/src/components/TimerWidget.tsx` | Add break UI (button, display, disabled states) |
## Edge Cases
- Break start must be after timer start (always true since break is clicked after start)
- Break duration naturally cannot exceed work duration (breakStart > startTime)
- On stop: reject if break is active (user must resume first)
- On cancel: reject if break is active (user must resume first)
- Break minutes accumulate across multiple break/resume cycles
- Timer refetch (every 60s) will sync break state from server
## Verification
- Run `npm run lint` in both `frontend/` and `backend/`
- Run `npm run build` in both `frontend/` and `backend/`
- Manual testing: start timer → break → verify elapsed frozen, break ticking → resume → verify break added to total → stop → verify time entry has correct breakMinutes

View File

@@ -10,6 +10,7 @@ import { TimeEntriesPage } from "./pages/TimeEntriesPage";
import { ClientsPage } from "./pages/ClientsPage"; import { ClientsPage } from "./pages/ClientsPage";
import { ProjectsPage } from "./pages/ProjectsPage"; import { ProjectsPage } from "./pages/ProjectsPage";
import { StatisticsPage } from "./pages/StatisticsPage"; import { StatisticsPage } from "./pages/StatisticsPage";
import { ApiKeysPage } from "./pages/ApiKeysPage";
function App() { function App() {
return ( return (
@@ -33,6 +34,7 @@ function App() {
<Route path="clients" element={<ClientsPage />} /> <Route path="clients" element={<ClientsPage />} />
<Route path="projects" element={<ProjectsPage />} /> <Route path="projects" element={<ProjectsPage />} />
<Route path="statistics" element={<StatisticsPage />} /> <Route path="statistics" element={<StatisticsPage />} />
<Route path="api-keys" element={<ApiKeysPage />} />
</Route> </Route>
</Routes> </Routes>
</AuthProvider> </AuthProvider>

View File

@@ -0,0 +1,18 @@
import apiClient from './client';
import type { ApiKey, CreatedApiKey, CreateApiKeyInput } from '@/types';
export const apiKeysApi = {
getAll: async (): Promise<ApiKey[]> => {
const { data } = await apiClient.get<ApiKey[]>('/api-keys');
return data;
},
create: async (input: CreateApiKeyInput): Promise<CreatedApiKey> => {
const { data } = await apiClient.post<CreatedApiKey>('/api-keys', input);
return data;
},
delete: async (id: string): Promise<void> => {
await apiClient.delete(`/api-keys/${id}`);
},
};

View File

@@ -8,6 +8,7 @@ import {
LogOut, LogOut,
ChevronDown, ChevronDown,
Settings, Settings,
Key,
} from "lucide-react"; } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
@@ -40,6 +41,7 @@ export function Navbar() {
const managementItems = [ const managementItems = [
{ to: "/clients", label: "Clients", icon: Briefcase }, { to: "/clients", label: "Clients", icon: Briefcase },
{ to: "/projects", label: "Projects", icon: FolderOpen }, { to: "/projects", label: "Projects", icon: FolderOpen },
{ to: "/api-keys", label: "API Keys", icon: Key },
]; ];
return ( return (

View File

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

View File

@@ -0,0 +1,243 @@
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<string | null>(null);
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null);
const [copiedKey, setCopiedKey] = useState(false);
const [revokeConfirmId, setRevokeConfirmId] = useState<string | null>(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 rendered below the table row via deleteApiKey.error
}
}
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Key className="h-6 w-6 text-gray-600" />
<h1 className="text-2xl font-bold text-gray-900">API Keys</h1>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 text-white text-sm font-medium rounded-lg hover:bg-primary-700 transition-colors"
>
<Plus className="h-4 w-4" />
Create API Key
</button>
</div>
<p className="text-sm text-gray-500 mb-6">
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.
</p>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error instanceof Error ? error.message : "Failed to load API keys"}
</div>
)}
{deleteApiKey.isError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{deleteApiKey.error instanceof Error
? deleteApiKey.error.message
: "Failed to revoke API key"}
</div>
)}
{isLoading ? (
<div className="text-center py-12 text-gray-400 text-sm">Loading...</div>
) : !apiKeys || apiKeys.length === 0 ? (
<div className="text-center py-12 border border-dashed border-gray-300 rounded-lg">
<Key className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 text-sm">No API keys yet. Create one to get started.</p>
</div>
) : (
<div className="border border-gray-200 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-600">Name</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Prefix</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Created</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Last Used</th>
<th className="px-4 py-3 text-right font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{apiKeys.map((key) => (
<tr key={key.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{key.name}</td>
<td className="px-4 py-3">
<code className="text-xs bg-gray-100 px-2 py-1 rounded font-mono text-gray-700">
{key.prefix}
</code>
</td>
<td className="px-4 py-3 text-gray-500">{formatDate(key.createdAt)}</td>
<td className="px-4 py-3 text-gray-500">{formatDate(key.lastUsedAt)}</td>
<td className="px-4 py-3 text-right">
{revokeConfirmId === key.id ? (
<div className="inline-flex items-center gap-2">
<span className="text-xs text-red-600">Revoke?</span>
<button
onClick={() => handleRevoke(key.id)}
disabled={deleteApiKey.isPending}
className="text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50"
>
Yes
</button>
<button
onClick={() => setRevokeConfirmId(null)}
className="text-xs px-2 py-1 border border-gray-300 rounded hover:bg-gray-50 transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setRevokeConfirmId(key.id)}
className="inline-flex items-center gap-1 px-2 py-1 text-red-600 hover:text-red-800 hover:bg-red-50 rounded transition-colors"
title="Revoke key"
>
<Trash2 className="h-4 w-4" />
<span>Revoke</span>
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Create API Key Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Create API Key</h2>
</div>
<div className="px-6 py-5">
{createdKey ? (
/* One-time key reveal */
<div className="space-y-4">
<div className="flex items-start gap-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-800">
Copy this key now. <strong>It will not be shown again.</strong>
</p>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Your new API key</label>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-gray-100 border border-gray-200 rounded-lg px-3 py-2 font-mono text-gray-900 break-all">
{createdKey.rawKey}
</code>
<button
onClick={handleCopyKey}
className="flex-shrink-0 p-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
title="Copy to clipboard"
>
{copiedKey ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4 text-gray-500" />
)}
</button>
</div>
</div>
</div>
) : (
/* Name input form */
<div className="space-y-4">
<div>
<label htmlFor="key-name" className="block text-sm font-medium text-gray-700 mb-1">
Key name
</label>
<input
id="key-name"
type="text"
value={newKeyName}
onChange={(e) => 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
/>
</div>
{createError && (
<p className="text-red-600 text-sm">{createError}</p>
)}
</div>
)}
</div>
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
<button
onClick={handleCloseCreateModal}
className="px-4 py-2 text-sm font-medium border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
{createdKey ? "Done" : "Cancel"}
</button>
{!createdKey && (
<button
onClick={handleCreate}
disabled={!newKeyName.trim() || createApiKey.isPending}
className="px-4 py-2 text-sm font-medium bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{createApiKey.isPending ? "Creating..." : "Create"}
</button>
)}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -210,3 +210,19 @@ export interface CreateCorrectionInput {
hours: number; hours: number;
description?: string; 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;
}

View File

@@ -40,16 +40,22 @@ A multi-user web application for tracking time spent working on projects. Users
| **Project** | A project belonging to a client | User, belongs to one Client | | **Project** | A project belonging to a client | User, belongs to one Client |
| **TimeEntry** | A completed time tracking record | User (explicit), belongs to one Project | | **TimeEntry** | A completed time tracking record | User (explicit), belongs to one Project |
| **OngoingTimer** | An active timer while tracking is in progress | User (explicit), belongs to one Project (optional) | | **OngoingTimer** | An active timer while tracking is in progress | User (explicit), belongs to one Project (optional) |
| **ClientTarget** | Hourly target for a client per period | User, belongs to one Client |
| **BalanceCorrection** | Manual hour adjustment for a target | Belongs to one ClientTarget |
| **ApiKey** | API key for external tool access | User |
### Relationships ### Relationships
``` ```
User User
├── Client (one-to-many) ├── Client (one-to-many)
── Project (one-to-many) ── Project (one-to-many)
└── TimeEntry (one-to-many, explicit user reference) └── TimeEntry (one-to-many, explicit user reference)
│ └── ClientTarget (one-to-one per client)
│ └── BalanceCorrection (one-to-many)
── OngoingTimer (zero-or-one, explicit user reference) ── OngoingTimer (zero-or-one, explicit user reference)
└── ApiKey (one-to-many)
``` ```
**Important**: Both `TimeEntry` and `OngoingTimer` have explicit references to the user who created them. This is distinct from the project's ownership and is required for future extensibility (see Future Extensibility section). **Important**: Both `TimeEntry` and `OngoingTimer` have explicit references to the user who created them. This is distinct from the project's ownership and is required for future extensibility (see Future Extensibility section).
@@ -127,10 +133,72 @@ User
- Start time - Start time
- End time - End time
- Project - Project
- Optional fields:
- Break minutes (deducted from total duration)
- Description (notes about the work)
- The entry is validated against overlap rules before saving - The entry is validated against overlap rules before saving
--- ---
### 6. Statistics
- User can view aggregated time tracking statistics
- Filters available:
- Date range (start/end)
- Client
- Project
- Statistics display:
- Total working time
- Entry count
- Breakdown by project (with color indicators)
- Breakdown by client
---
### 7. Client Targets
- User can set hourly targets per client
- Target configuration:
- Target hours per period
- Period type (weekly or monthly)
- Working days (e.g., MON-FRI)
- Start date
- Balance tracking:
- Shows current balance vs target
- Supports manual corrections (e.g., holidays, overtime carry-over)
- Only one target per client allowed
---
### 8. API Keys
- User can generate API keys for external tool access
- API key properties:
- Name (for identification)
- Prefix (first characters shown for identification)
- Last used timestamp
- Security:
- Raw key shown only once at creation
- Key is hashed (SHA-256) before storage
- Keys can be revoked (deleted)
---
### 9. MCP Integration
- Model Context Protocol endpoint for AI agent access
- Stateless operation (no session persistence)
- Tools exposed:
- Client CRUD operations
- Project CRUD operations
- Time entry CRUD operations
- Timer start/stop/cancel
- Client target management
- Statistics queries
- Authentication via API keys
---
## API Endpoints (Suggested) ## API Endpoints (Suggested)
### Authentication ### Authentication
@@ -165,8 +233,29 @@ User
- `POST /api/timer/start` — Start timer (creates OngoingTimer) - `POST /api/timer/start` — Start timer (creates OngoingTimer)
- `PUT /api/timer` — Update ongoing timer (e.g., set project) - `PUT /api/timer` — Update ongoing timer (e.g., set project)
- `POST /api/timer/stop` — Stop timer (converts to TimeEntry) - `POST /api/timer/stop` — Stop timer (converts to TimeEntry)
- `POST /api/timer/cancel` — Cancel timer without saving
- `GET /api/timer` — Get current ongoing timer (if any) - `GET /api/timer` — Get current ongoing timer (if any)
### Client Targets
- `GET /api/client-targets` — List targets with computed balance
- `POST /api/client-targets` — Create a target
- `PUT /api/client-targets/{id}` — Update a target
- `DELETE /api/client-targets/{id}` — Delete a target
- `POST /api/client-targets/{id}/corrections` — Add a correction
- `DELETE /api/client-targets/{id}/corrections/{correctionId}` — Delete a correction
### API Keys
- `GET /api/api-keys` — List user's API keys
- `POST /api/api-keys` — Create a new API key
- `DELETE /api/api-keys/{id}` — Revoke an API key
### MCP (Model Context Protocol)
- `GET /mcp` — SSE stream for server-initiated messages
- `POST /mcp` — JSON-RPC requests (tool invocations)
--- ---
## UI Requirements ## UI Requirements
@@ -183,6 +272,9 @@ User
- **Dashboard**: Overview with active timer widget and recent entries - **Dashboard**: Overview with active timer widget and recent entries
- **Time Entries**: List/calendar view of all entries with filters (date range, client, project) - **Time Entries**: List/calendar view of all entries with filters (date range, client, project)
- **Clients & Projects**: Management interface for clients and projects - **Clients & Projects**: Management interface for clients and projects
- **Statistics**: Aggregated time data with filters and breakdowns
- **API Keys**: Create and manage API keys for external access
- **Client Targets**: Set and monitor hourly targets per client
--- ---

View File

@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
REGISTRY="git.simon-franken.de" REGISTRY="git.simon-franken.de"
CHART_DIR="timetracker-chart" CHART_DIR="helm"
# Load .env file if present (values do not override existing env variables) # Load .env file if present (values do not override existing env variables)
if [[ -f ".env" ]]; then if [[ -f ".env" ]]; then