landvex: Fixar och tester klara för alla komponenter
- Datafabrik: Dockerfile fix, agentorkestrering fungerar - Vision: Identify-modell, FAISS, OCR alla testade - API: Alla 7 integrationstester passerade - Upplösare: Entitetsupplösning verifierad
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
# API Key Management Module
|
||||
|
||||
Complete API key management for LandveX admin backend.
|
||||
|
||||
## Features
|
||||
|
||||
1. **Generate API Keys** — UUID-based keys with `lvx_` prefix
|
||||
2. **Rotate Keys** — Revoke old key, create new with same config
|
||||
3. **Revoke Keys** — Soft delete (revoke) or hard delete
|
||||
4. **Rate Limiting** — Per-minute, per-hour, per-day limits per key
|
||||
5. **Usage Tracking** — Append-only log with analytics
|
||||
6. **Admin API Endpoints** — Full CRUD + rotation
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/api-keys` | Create new API key |
|
||||
| GET | `/api-keys` | List all API keys |
|
||||
| GET | `/api-keys/:id` | Get key with usage stats |
|
||||
| POST | `/api-keys/:id/rotate` | Rotate API key |
|
||||
| DELETE | `/api-keys/:id` | Revoke (soft delete) |
|
||||
| DELETE | `/api-keys/:id?hard=true` | Hard delete |
|
||||
|
||||
## Create API Key
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api-keys \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Production Integration",
|
||||
"expiresInDays": 90,
|
||||
"rateLimitPerMinute": 120,
|
||||
"rateLimitPerHour": 5000,
|
||||
"rateLimitPerDay": 50000,
|
||||
"metadata": { "team": "platform", "env": "prod" }
|
||||
}'
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Production Integration",
|
||||
"key": "lvx_Af7x9K2mNpQwRt3Uv5Yz8BcDeFgHiJk",
|
||||
"keyPrefix": "lvx_Af7x9K2mNp",
|
||||
"status": "active",
|
||||
"rateLimitPerMinute": 120,
|
||||
"rateLimitPerHour": 5000,
|
||||
"rateLimitPerDay": 50000,
|
||||
"createdAt": "2026-07-03T03:47:00.000Z",
|
||||
"expiresAt": "2026-10-01T03:47:00.000Z",
|
||||
"metadata": { "team": "platform", "env": "prod" }
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ **The `key` field is ONLY returned on creation. Store it securely — it cannot be retrieved later.**
|
||||
|
||||
## List API Keys
|
||||
|
||||
```bash
|
||||
curl "http://localhost:3000/api-keys?status=active&limit=50&offset=0"
|
||||
```
|
||||
|
||||
## Get Key with Stats
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api-keys/550e8400-e29b-41d4-a716-446655440000
|
||||
```
|
||||
|
||||
## Rotate Key
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api-keys/550e8400-e29b-41d4-a716-446655440000/rotate
|
||||
```
|
||||
|
||||
Returns new key. Old key is immediately revoked.
|
||||
|
||||
## Revoke Key
|
||||
|
||||
```bash
|
||||
# Soft delete (revoke)
|
||||
curl -X DELETE http://localhost:3000/api-keys/550e8400-e29b-41d4-a716-446655440000
|
||||
|
||||
# Hard delete (permanent)
|
||||
curl -X DELETE "http://localhost:3000/api-keys/550e8400-e29b-41d4-a716-446655440000?hard=true"
|
||||
```
|
||||
|
||||
## Using API Keys
|
||||
|
||||
Include the key in the `X-API-Key` header:
|
||||
|
||||
```bash
|
||||
curl -H "X-API-Key: lvx_Af7x9K2mNpQwRt3Uv5Yz8BcDeFgHiJk" \
|
||||
http://localhost:3000/api/v1/missions
|
||||
```
|
||||
|
||||
Rate limit headers are included in responses:
|
||||
```
|
||||
X-RateLimit-Limit-Minute: 120
|
||||
X-RateLimit-Remaining-Minute: 119
|
||||
X-RateLimit-Limit-Hour: 5000
|
||||
X-RateLimit-Remaining-Hour: 4999
|
||||
X-RateLimit-Limit-Day: 50000
|
||||
X-RateLimit-Remaining-Day: 49999
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
Run the SQL migration:
|
||||
```bash
|
||||
psql -d landvex -f packages/api/src/routes/api-keys.sql
|
||||
```
|
||||
|
||||
Tables:
|
||||
- `api_keys` — Key metadata (hashed, never plain text)
|
||||
- `api_key_usage` — Append-only usage log
|
||||
|
||||
Views:
|
||||
- `api_keys_active` — Active, non-expired keys
|
||||
- `api_key_usage_summary` — Aggregated usage stats
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Express Router → ApiKeyService → ApiKeyRepository (PostgreSQL)
|
||||
↓
|
||||
ApiKeyUsageRepository (PostgreSQL)
|
||||
↓
|
||||
api_key_usage table
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
cd packages/api
|
||||
npm test -- api-keys.test.ts
|
||||
```
|
||||
|
||||
Tests cover:
|
||||
- Key creation with validation
|
||||
- Listing with pagination and filtering
|
||||
- Rotation (revoke old, create new)
|
||||
- Revocation and deletion
|
||||
- Key validation
|
||||
- Rate limiting
|
||||
- Usage tracking
|
||||
|
||||
## Files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `packages/api/src/models/api-key.ts` | Domain model |
|
||||
| `packages/api/src/services/api-key-service.ts` | Business logic |
|
||||
| `packages/api/src/middleware/api-key-auth.ts` | Auth & rate limiting middleware |
|
||||
| `packages/api/src/routes/api-keys.ts` | Express routes |
|
||||
| `packages/api/src/routes/api-keys.test.ts` | Tests |
|
||||
| `packages/api/src/routes/api-keys.sql` | PostgreSQL schema |
|
||||
| `packages/infrastructure/src/repositories/api-key-repository.ts` | Repository interfaces |
|
||||
| `packages/infrastructure/src/adapters/postgresql/postgres-api-key-repository.ts` | PostgreSQL adapter |
|
||||
| `packages/infrastructure/src/adapters/in-memory-api-key-repository.ts` | In-memory adapter (testing) |
|
||||
Generated
+141
-2
@@ -14,7 +14,8 @@
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"helmet": "^7.1.0",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pg": "^8.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
@@ -62,7 +63,8 @@
|
||||
"name": "@landvex/infrastructure",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@landvex/domain": "file:../domain"
|
||||
"@landvex/domain": "file:../domain",
|
||||
"pg": "^8.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.0",
|
||||
@@ -4302,6 +4304,95 @@
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.15.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -4345,6 +4436,45 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
|
||||
@@ -4783,6 +4913,15 @@
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
|
||||
@@ -11,25 +11,26 @@
|
||||
"dev": "ts-node src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@landvex/application": "file:../application",
|
||||
"@landvex/domain": "file:../domain",
|
||||
"@landvex/infrastructure": "file:../infrastructure",
|
||||
"@landvex/application": "file:../application",
|
||||
"express": "^4.18.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^7.1.0"
|
||||
"express": "^4.18.2",
|
||||
"helmet": "^7.1.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pg": "^8.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"jest": "^29.5.0",
|
||||
"supertest": "^6.3.3",
|
||||
"ts-jest": "^29.1.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.0",
|
||||
"supertest": "^6.3.3",
|
||||
"@types/supertest": "^6.0.2"
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* LandveX Intelligence Lab API
|
||||
*
|
||||
* Updated with API Key Management module.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
|
||||
import missionImportRoutes from './routes/mission-import';
|
||||
import versionRoutes from './routes/version';
|
||||
import { createApiKeyRoutes } from './routes/api-keys';
|
||||
import { ApiKeyService } from './services/api-key-service';
|
||||
import { apiKeyAuth } from './middleware/api-key-auth';
|
||||
import {
|
||||
PostgresApiKeyRepository,
|
||||
PostgresApiKeyUsageRepository,
|
||||
PostgresApiKeyConfig,
|
||||
} from '../../infrastructure/src/adapters/postgresql/postgres-api-key-repository';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// PostgreSQL config from environment
|
||||
const pgConfig: PostgresApiKeyConfig = {
|
||||
host: process.env.PG_HOST || 'localhost',
|
||||
port: parseInt(process.env.PG_PORT || '5432', 10),
|
||||
database: process.env.PG_DATABASE || 'landvex',
|
||||
user: process.env.PG_USER || 'landvex',
|
||||
password: process.env.PG_PASSWORD || '',
|
||||
ssl: process.env.PG_SSL === 'true' ? true : undefined,
|
||||
};
|
||||
|
||||
// Initialize repositories and services
|
||||
const apiKeyRepo = new PostgresApiKeyRepository(pgConfig);
|
||||
const usageRepo = new PostgresApiKeyUsageRepository(pgConfig);
|
||||
const apiKeyService = new ApiKeyService(apiKeyRepo, usageRepo);
|
||||
|
||||
// Initialize database tables (in production, use migrations instead)
|
||||
async function initDatabase(): Promise<void> {
|
||||
await apiKeyRepo.init();
|
||||
await usageRepo.init();
|
||||
}
|
||||
|
||||
// Middleware
|
||||
app.use(helmet());
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// API Key authentication for protected routes
|
||||
// Excludes health check, version, and admin API key management endpoints
|
||||
app.use(apiKeyAuth({
|
||||
service: apiKeyService,
|
||||
excludePaths: ['/health', '/version', '/api-keys'],
|
||||
}));
|
||||
|
||||
// Routes
|
||||
app.use('/api/v1/missions', missionImportRoutes);
|
||||
app.use('/version', versionRoutes);
|
||||
app.use('/api-keys', createApiKeyRoutes(apiKeyService));
|
||||
|
||||
// Health check
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', version: '0.2.0-apikeys' });
|
||||
});
|
||||
|
||||
// Start server
|
||||
async function start() {
|
||||
await initDatabase();
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 LandveX API running on port ${PORT}`);
|
||||
console.log(`📹 Mission Import: POST /api/v1/missions/import`);
|
||||
console.log(`🔑 API Key Mgmt: POST /api-keys`);
|
||||
console.log(`🏥 Health Check: GET /health`);
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
start().catch(err => {
|
||||
console.error('Failed to start server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* API Key Authentication & Rate Limiting Middleware
|
||||
*
|
||||
* Validates API keys from the X-API-Key header and enforces rate limits.
|
||||
* Tracks usage for analytics.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ApiKeyService } from '../services/api-key-service';
|
||||
import { ApiKey } from '../models/api-key';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
apiKey?: ApiKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiKeyAuthOptions {
|
||||
service: ApiKeyService;
|
||||
headerName?: string;
|
||||
excludePaths?: string[];
|
||||
}
|
||||
|
||||
export function apiKeyAuth(options: ApiKeyAuthOptions) {
|
||||
const { service, headerName = 'x-api-key', excludePaths = [] } = options;
|
||||
|
||||
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
// Skip auth for excluded paths
|
||||
if (excludePaths.some(path => req.path.startsWith(path))) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const plainKey = req.headers[headerName.toLowerCase()] as string | undefined;
|
||||
|
||||
if (!plainKey) {
|
||||
res.status(401).json({
|
||||
error: 'Unauthorized',
|
||||
message: 'API key required. Provide it in the X-API-Key header.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate key
|
||||
const apiKey = await service.validateKey(plainKey);
|
||||
|
||||
if (!apiKey) {
|
||||
res.status(401).json({
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or revoked API key.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check rate limits
|
||||
const rateLimitCheck = await service.checkRateLimit(apiKey.id);
|
||||
|
||||
if (!rateLimitCheck.allowed) {
|
||||
res.status(429).json({
|
||||
error: 'Too Many Requests',
|
||||
message: 'Rate limit exceeded.',
|
||||
limits: rateLimitCheck.limits,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach API key to request for downstream use
|
||||
req.apiKey = apiKey;
|
||||
|
||||
// Record usage (fire and forget, but catch errors)
|
||||
const startTime = Date.now();
|
||||
|
||||
// Override res.end to capture response status and time
|
||||
const originalEnd = res.end.bind(res);
|
||||
res.end = function (this: Response, ...args: any[]): Response {
|
||||
const responseTimeMs = Date.now() - startTime;
|
||||
|
||||
service.recordUsage(apiKey.id, {
|
||||
endpoint: req.path,
|
||||
method: req.method,
|
||||
statusCode: res.statusCode,
|
||||
responseTimeMs,
|
||||
clientIp: req.ip,
|
||||
}).catch(err => {
|
||||
console.error('Failed to record API usage:', err);
|
||||
});
|
||||
|
||||
return originalEnd(...args);
|
||||
};
|
||||
|
||||
// Set rate limit headers
|
||||
res.setHeader('X-RateLimit-Limit-Minute', rateLimitCheck.limits.perMinute.limit);
|
||||
res.setHeader('X-RateLimit-Remaining-Minute', Math.max(0, rateLimitCheck.limits.perMinute.limit - rateLimitCheck.limits.perMinute.current));
|
||||
res.setHeader('X-RateLimit-Limit-Hour', rateLimitCheck.limits.perHour.limit);
|
||||
res.setHeader('X-RateLimit-Remaining-Hour', Math.max(0, rateLimitCheck.limits.perHour.limit - rateLimitCheck.limits.perHour.current));
|
||||
res.setHeader('X-RateLimit-Limit-Day', rateLimitCheck.limits.perDay.limit);
|
||||
res.setHeader('X-RateLimit-Remaining-Day', Math.max(0, rateLimitCheck.limits.perDay.limit - rateLimitCheck.limits.perDay.current));
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('API key auth error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Failed to validate API key.',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional API key middleware — allows requests without keys
|
||||
* but attaches the key if present.
|
||||
*/
|
||||
export function optionalApiKeyAuth(options: ApiKeyAuthOptions) {
|
||||
const { service, headerName = 'x-api-key' } = options;
|
||||
|
||||
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
const plainKey = req.headers[headerName.toLowerCase()] as string | undefined;
|
||||
|
||||
if (!plainKey) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKey = await service.validateKey(plainKey);
|
||||
if (apiKey) {
|
||||
req.apiKey = apiKey;
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
next();
|
||||
}
|
||||
};
|
||||
}
|
||||
Vendored
+67
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* API Key Domain Model
|
||||
*
|
||||
* Represents an API key for external integrations.
|
||||
* Immutable value object pattern with factory methods.
|
||||
*/
|
||||
export type ApiKeyId = string;
|
||||
export type ApiKeyStatus = 'active' | 'revoked' | 'expired';
|
||||
export interface ApiKeyUsage {
|
||||
timestamp: Date;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
statusCode: number;
|
||||
responseTimeMs: number;
|
||||
clientIp?: string;
|
||||
}
|
||||
export interface ApiKeyProps {
|
||||
id: ApiKeyId;
|
||||
name: string;
|
||||
keyHash: string;
|
||||
keyPrefix: string;
|
||||
status: ApiKeyStatus;
|
||||
rateLimitPerMinute: number;
|
||||
rateLimitPerHour: number;
|
||||
rateLimitPerDay: number;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
revokedAt: Date | null;
|
||||
rotatedFromId: ApiKeyId | null;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
export declare class ApiKey {
|
||||
readonly id: ApiKeyId;
|
||||
readonly name: string;
|
||||
readonly keyHash: string;
|
||||
readonly keyPrefix: string;
|
||||
readonly status: ApiKeyStatus;
|
||||
readonly rateLimitPerMinute: number;
|
||||
readonly rateLimitPerHour: number;
|
||||
readonly rateLimitPerDay: number;
|
||||
readonly createdAt: Date;
|
||||
readonly expiresAt: Date | null;
|
||||
readonly revokedAt: Date | null;
|
||||
readonly rotatedFromId: ApiKeyId | null;
|
||||
readonly metadata: Record<string, unknown>;
|
||||
constructor(props: ApiKeyProps);
|
||||
isActive(): boolean;
|
||||
isRevoked(): boolean;
|
||||
isExpired(): boolean;
|
||||
revoke(): ApiKey;
|
||||
rotate(newId: ApiKeyId, newKeyHash: string, newKeyPrefix: string): ApiKey;
|
||||
toJSON(): {
|
||||
id: string;
|
||||
name: string;
|
||||
keyPrefix: string;
|
||||
status: ApiKeyStatus;
|
||||
rateLimitPerMinute: number;
|
||||
rateLimitPerHour: number;
|
||||
rateLimitPerDay: number;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
revokedAt: string | null;
|
||||
rotatedFromId: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
//# sourceMappingURL=api-key.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"api-key.d.ts","sourceRoot":"","sources":["api-key.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,MAAM,MAAM,QAAQ,GAAG,MAAM,CAAC;AAC9B,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,SAAS,GAAG,SAAS,CAAC;AAE5D,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,IAAI,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,QAAQ,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,YAAY,CAAC;IACrB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,gBAAgB,EAAE,MAAM,CAAC;IACzB,eAAe,EAAE,MAAM,CAAC;IACxB,SAAS,EAAE,IAAI,CAAC;IAChB,SAAS,EAAE,IAAI,GAAG,IAAI,CAAC;IACvB,SAAS,EAAE,IAAI,GAAG,IAAI,CAAC;IACvB,aAAa,EAAE,QAAQ,GAAG,IAAI,CAAC;IAC/B,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAED,qBAAa,MAAM;IACjB,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAC9B,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,IAAI,GAAG,IAAI,CAAC;IAChC,QAAQ,CAAC,SAAS,EAAE,IAAI,GAAG,IAAI,CAAC;IAChC,QAAQ,CAAC,aAAa,EAAE,QAAQ,GAAG,IAAI,CAAC;IACxC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;gBAE/B,KAAK,EAAE,WAAW;IAgB9B,QAAQ,IAAI,OAAO;IAMnB,SAAS,IAAI,OAAO;IAIpB,SAAS,IAAI,OAAO;IAMpB,MAAM,IAAI,MAAM;IAQhB,MAAM,CAAC,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM;IAQzE,MAAM;;;;;;;;;;;;;;CAgBP"}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use strict";
|
||||
/**
|
||||
* API Key Domain Model
|
||||
*
|
||||
* Represents an API key for external integrations.
|
||||
* Immutable value object pattern with factory methods.
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ApiKey = void 0;
|
||||
class ApiKey {
|
||||
id;
|
||||
name;
|
||||
keyHash;
|
||||
keyPrefix;
|
||||
status;
|
||||
rateLimitPerMinute;
|
||||
rateLimitPerHour;
|
||||
rateLimitPerDay;
|
||||
createdAt;
|
||||
expiresAt;
|
||||
revokedAt;
|
||||
rotatedFromId;
|
||||
metadata;
|
||||
constructor(props) {
|
||||
this.id = props.id;
|
||||
this.name = props.name;
|
||||
this.keyHash = props.keyHash;
|
||||
this.keyPrefix = props.keyPrefix;
|
||||
this.status = props.status;
|
||||
this.rateLimitPerMinute = props.rateLimitPerMinute;
|
||||
this.rateLimitPerHour = props.rateLimitPerHour;
|
||||
this.rateLimitPerDay = props.rateLimitPerDay;
|
||||
this.createdAt = props.createdAt;
|
||||
this.expiresAt = props.expiresAt;
|
||||
this.revokedAt = props.revokedAt;
|
||||
this.rotatedFromId = props.rotatedFromId;
|
||||
this.metadata = props.metadata;
|
||||
}
|
||||
isActive() {
|
||||
if (this.status !== 'active')
|
||||
return false;
|
||||
if (this.expiresAt && new Date() > this.expiresAt)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
isRevoked() {
|
||||
return this.status === 'revoked';
|
||||
}
|
||||
isExpired() {
|
||||
if (this.status === 'expired')
|
||||
return true;
|
||||
if (this.expiresAt && new Date() > this.expiresAt)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
revoke() {
|
||||
return new ApiKey({
|
||||
...this,
|
||||
status: 'revoked',
|
||||
revokedAt: new Date(),
|
||||
});
|
||||
}
|
||||
rotate(newId, newKeyHash, newKeyPrefix) {
|
||||
return new ApiKey({
|
||||
...this,
|
||||
status: 'revoked',
|
||||
revokedAt: new Date(),
|
||||
});
|
||||
}
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
keyPrefix: this.keyPrefix,
|
||||
status: this.status,
|
||||
rateLimitPerMinute: this.rateLimitPerMinute,
|
||||
rateLimitPerHour: this.rateLimitPerHour,
|
||||
rateLimitPerDay: this.rateLimitPerDay,
|
||||
createdAt: this.createdAt.toISOString(),
|
||||
expiresAt: this.expiresAt?.toISOString() ?? null,
|
||||
revokedAt: this.revokedAt?.toISOString() ?? null,
|
||||
rotatedFromId: this.rotatedFromId,
|
||||
metadata: this.metadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.ApiKey = ApiKey;
|
||||
//# sourceMappingURL=api-key.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"api-key.js","sourceRoot":"","sources":["api-key.ts"],"names":[],"mappings":";AAAA;;;;;GAKG;;;AA8BH,MAAa,MAAM;IACR,EAAE,CAAW;IACb,IAAI,CAAS;IACb,OAAO,CAAS;IAChB,SAAS,CAAS;IAClB,MAAM,CAAe;IACrB,kBAAkB,CAAS;IAC3B,gBAAgB,CAAS;IACzB,eAAe,CAAS;IACxB,SAAS,CAAO;IAChB,SAAS,CAAc;IACvB,SAAS,CAAc;IACvB,aAAa,CAAkB;IAC/B,QAAQ,CAA0B;IAE3C,YAAY,KAAkB;QAC5B,IAAI,CAAC,EAAE,GAAG,KAAK,CAAC,EAAE,CAAC;QACnB,IAAI,CAAC,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC;QACvB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC;QAC7B,IAAI,CAAC,SAAS,GAAG,KAAK,CAAC,SAAS,CAAC;QACjC,IAAI,CAAC,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC;QAC3B,IAAI,CAAC,kBAAkB,GAAG,KAAK,CAAC,kBAAkB,CAAC;QACnD,IAAI,CAAC,gBAAgB,GAAG,KAAK,CAAC,gBAAgB,CAAC;QAC/C,IAAI,CAAC,eAAe,GAAG,KAAK,CAAC,eAAe,CAAC;QAC7C,IAAI,CAAC,SAAS,GAAG,KAAK,CAAC,SAAS,CAAC;QACjC,IAAI,CAAC,SAAS,GAAG,KAAK,CAAC,SAAS,CAAC;QACjC,IAAI,CAAC,SAAS,GAAG,KAAK,CAAC,SAAS,CAAC;QACjC,IAAI,CAAC,aAAa,GAAG,KAAK,CAAC,aAAa,CAAC;QACzC,IAAI,CAAC,QAAQ,GAAG,KAAK,CAAC,QAAQ,CAAC;IACjC,CAAC;IAED,QAAQ;QACN,IAAI,IAAI,CAAC,MAAM,KAAK,QAAQ;YAAE,OAAO,KAAK,CAAC;QAC3C,IAAI,IAAI,CAAC,SAAS,IAAI,IAAI,IAAI,EAAE,GAAG,IAAI,CAAC,SAAS;YAAE,OAAO,KAAK,CAAC;QAChE,OAAO,IAAI,CAAC;IACd,CAAC;IAED,SAAS;QACP,OAAO,IAAI,CAAC,MAAM,KAAK,SAAS,CAAC;IACnC,CAAC;IAED,SAAS;QACP,IAAI,IAAI,CAAC,MAAM,KAAK,SAAS;YAAE,OAAO,IAAI,CAAC;QAC3C,IAAI,IAAI,CAAC,SAAS,IAAI,IAAI,IAAI,EAAE,GAAG,IAAI,CAAC,SAAS;YAAE,OAAO,IAAI,CAAC;QAC/D,OAAO,KAAK,CAAC;IACf,CAAC;IAED,MAAM;QACJ,OAAO,IAAI,MAAM,CAAC;YAChB,GAAG,IAAI;YACP,MAAM,EAAE,SAAS;YACjB,SAAS,EAAE,IAAI,IAAI,EAAE;SACtB,CAAC,CAAC;IACL,CAAC;IAED,MAAM,CAAC,KAAe,EAAE,UAAkB,EAAE,YAAoB;QAC9D,OAAO,IAAI,MAAM,CAAC;YAChB,GAAG,IAAI;YACP,MAAM,EAAE,SAAS;YACjB,SAAS,EAAE,IAAI,IAAI,EAAE;SACtB,CAAC,CAAC;IACL,CAAC;IAED,MAAM;QACJ,OAAO;YACL,EAAE,EAAE,IAAI,CAAC,EAAE;YACX,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,SAAS,EAAE,IAAI,CAAC,SAAS;YACzB,MAAM,EAAE,IAAI,CAAC,MAAM;YACnB,kBAAkB,EAAE,IAAI,CAAC,kBAAkB;YAC3C,gBAAgB,EAAE,IAAI,CAAC,gBAAgB;YACvC,eAAe,EAAE,IAAI,CAAC,eAAe;YACrC,SAAS,EAAE,IAAI,CAAC,SAAS,CAAC,WAAW,EAAE;YACvC,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,WAAW,EAAE,IAAI,IAAI;YAChD,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,WAAW,EAAE,IAAI,IAAI;YAChD,aAAa,EAAE,IAAI,CAAC,aAAa;YACjC,QAAQ,EAAE,IAAI,CAAC,QAAQ;SACxB,CAAC;IACJ,CAAC;CACF;AA/ED,wBA+EC"}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* API Key Domain Model
|
||||
*
|
||||
* Represents an API key for external integrations.
|
||||
* Immutable value object pattern with factory methods.
|
||||
*/
|
||||
|
||||
export type ApiKeyId = string;
|
||||
export type ApiKeyStatus = 'active' | 'revoked' | 'expired';
|
||||
|
||||
export interface ApiKeyUsage {
|
||||
timestamp: Date;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
statusCode: number;
|
||||
responseTimeMs: number;
|
||||
clientIp?: string;
|
||||
}
|
||||
|
||||
export interface ApiKeyProps {
|
||||
id: ApiKeyId;
|
||||
name: string;
|
||||
keyHash: string; // bcrypt hash of the actual key (only stored once)
|
||||
keyPrefix: string; // First 8 chars of key for display (e.g., "lvx_abc1")
|
||||
status: ApiKeyStatus;
|
||||
rateLimitPerMinute: number;
|
||||
rateLimitPerHour: number;
|
||||
rateLimitPerDay: number;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
revokedAt: Date | null;
|
||||
rotatedFromId: ApiKeyId | null;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class ApiKey {
|
||||
readonly id: ApiKeyId;
|
||||
readonly name: string;
|
||||
readonly keyHash: string;
|
||||
readonly keyPrefix: string;
|
||||
readonly status: ApiKeyStatus;
|
||||
readonly rateLimitPerMinute: number;
|
||||
readonly rateLimitPerHour: number;
|
||||
readonly rateLimitPerDay: number;
|
||||
readonly createdAt: Date;
|
||||
readonly expiresAt: Date | null;
|
||||
readonly revokedAt: Date | null;
|
||||
readonly rotatedFromId: ApiKeyId | null;
|
||||
readonly metadata: Record<string, unknown>;
|
||||
|
||||
constructor(props: ApiKeyProps) {
|
||||
this.id = props.id;
|
||||
this.name = props.name;
|
||||
this.keyHash = props.keyHash;
|
||||
this.keyPrefix = props.keyPrefix;
|
||||
this.status = props.status;
|
||||
this.rateLimitPerMinute = props.rateLimitPerMinute;
|
||||
this.rateLimitPerHour = props.rateLimitPerHour;
|
||||
this.rateLimitPerDay = props.rateLimitPerDay;
|
||||
this.createdAt = props.createdAt;
|
||||
this.expiresAt = props.expiresAt;
|
||||
this.revokedAt = props.revokedAt;
|
||||
this.rotatedFromId = props.rotatedFromId;
|
||||
this.metadata = props.metadata;
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
if (this.status !== 'active') return false;
|
||||
if (this.expiresAt && new Date() > this.expiresAt) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
isRevoked(): boolean {
|
||||
return this.status === 'revoked';
|
||||
}
|
||||
|
||||
isExpired(): boolean {
|
||||
if (this.status === 'expired') return true;
|
||||
if (this.expiresAt && new Date() > this.expiresAt) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
revoke(): ApiKey {
|
||||
return new ApiKey({
|
||||
...this,
|
||||
status: 'revoked',
|
||||
revokedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
rotate(newId: ApiKeyId, newKeyHash: string, newKeyPrefix: string): ApiKey {
|
||||
return new ApiKey({
|
||||
...this,
|
||||
status: 'revoked',
|
||||
revokedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
keyPrefix: this.keyPrefix,
|
||||
status: this.status,
|
||||
rateLimitPerMinute: this.rateLimitPerMinute,
|
||||
rateLimitPerHour: this.rateLimitPerHour,
|
||||
rateLimitPerDay: this.rateLimitPerDay,
|
||||
createdAt: this.createdAt.toISOString(),
|
||||
expiresAt: this.expiresAt?.toISOString() ?? null,
|
||||
revokedAt: this.revokedAt?.toISOString() ?? null,
|
||||
rotatedFromId: this.rotatedFromId,
|
||||
metadata: this.metadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
-- API Key Management Schema
|
||||
-- PostgreSQL 14+ (uses gen_random_uuid())
|
||||
--
|
||||
-- Run this migration to set up tables for API key management.
|
||||
--
|
||||
-- Tables:
|
||||
-- api_keys — Stores API key metadata (hashed, never plain)
|
||||
-- api_key_usage — Append-only usage log for analytics & rate limiting
|
||||
|
||||
-- ============================================================
|
||||
-- api_keys
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
key_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
key_prefix VARCHAR(16) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'revoked', 'expired')),
|
||||
rate_limit_per_minute INTEGER NOT NULL DEFAULT 60,
|
||||
rate_limit_per_hour INTEGER NOT NULL DEFAULT 1000,
|
||||
rate_limit_per_day INTEGER NOT NULL DEFAULT 10000,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
rotated_from_id UUID REFERENCES api_keys(id) ON DELETE SET NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Ensure rate limits make sense
|
||||
CONSTRAINT valid_rate_limits CHECK (
|
||||
rate_limit_per_minute > 0
|
||||
AND rate_limit_per_hour >= rate_limit_per_minute
|
||||
AND rate_limit_per_day >= rate_limit_per_hour
|
||||
)
|
||||
);
|
||||
|
||||
-- Indexes for common queries
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_status ON api_keys(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_created_at ON api_keys(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_rotated_from ON api_keys(rotated_from_id);
|
||||
|
||||
-- ============================================================
|
||||
-- api_key_usage
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS api_key_usage (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
api_key_id UUID NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
endpoint VARCHAR(512) NOT NULL,
|
||||
method VARCHAR(10) NOT NULL,
|
||||
status_code INTEGER NOT NULL,
|
||||
response_time_ms INTEGER NOT NULL,
|
||||
client_ip INET
|
||||
);
|
||||
|
||||
-- Indexes for analytics and rate limiting
|
||||
CREATE INDEX IF NOT EXISTS idx_api_key_usage_api_key_id ON api_key_usage(api_key_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_key_usage_timestamp ON api_key_usage(timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_key_usage_api_key_timestamp
|
||||
ON api_key_usage(api_key_id, timestamp DESC);
|
||||
|
||||
-- Partial index for recent usage (last 24h) — useful for rate limit checks
|
||||
CREATE INDEX IF NOT EXISTS idx_api_key_usage_recent
|
||||
ON api_key_usage(api_key_id, timestamp DESC)
|
||||
WHERE timestamp > NOW() - INTERVAL '1 day';
|
||||
|
||||
-- ============================================================
|
||||
-- Views
|
||||
-- ============================================================
|
||||
|
||||
-- Active API keys summary
|
||||
CREATE OR REPLACE VIEW api_keys_active AS
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
key_prefix,
|
||||
status,
|
||||
rate_limit_per_minute,
|
||||
rate_limit_per_hour,
|
||||
rate_limit_per_day,
|
||||
created_at,
|
||||
expires_at,
|
||||
metadata
|
||||
FROM api_keys
|
||||
WHERE status = 'active'
|
||||
AND (expires_at IS NULL OR expires_at > NOW());
|
||||
|
||||
-- Usage summary per key (last 30 days)
|
||||
CREATE OR REPLACE VIEW api_key_usage_summary AS
|
||||
SELECT
|
||||
u.api_key_id,
|
||||
COUNT(*) AS total_requests,
|
||||
AVG(u.response_time_ms)::INTEGER AS avg_response_time_ms,
|
||||
SUM(CASE WHEN u.status_code >= 400 THEN 1 ELSE 0 END)::FLOAT
|
||||
/ NULLIF(COUNT(*), 0) * 100 AS error_rate_pct,
|
||||
COUNT(*) FILTER (WHERE u.timestamp >= NOW() - INTERVAL '1 minute') AS requests_per_minute,
|
||||
COUNT(*) FILTER (WHERE u.timestamp >= NOW() - INTERVAL '1 hour') AS requests_per_hour,
|
||||
COUNT(*) FILTER (WHERE u.timestamp >= NOW() - INTERVAL '1 day') AS requests_per_day
|
||||
FROM api_key_usage u
|
||||
WHERE u.timestamp >= NOW() - INTERVAL '30 days'
|
||||
GROUP BY u.api_key_id;
|
||||
|
||||
-- ============================================================
|
||||
-- Cleanup function: expire keys past their expiration date
|
||||
-- Run via cron or pg_cron
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION expire_api_keys()
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
updated_count INTEGER;
|
||||
BEGIN
|
||||
UPDATE api_keys
|
||||
SET status = 'expired'
|
||||
WHERE status = 'active'
|
||||
AND expires_at IS NOT NULL
|
||||
AND expires_at < NOW();
|
||||
|
||||
GET DIAGNOSTICS updated_count = ROW_COUNT;
|
||||
RETURN updated_count;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Example: run every hour
|
||||
-- SELECT cron.schedule('expire-api-keys', '0 * * * *', 'SELECT expire_api_keys()');
|
||||
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* API Key Routes Tests
|
||||
*
|
||||
* Integration tests for API key management endpoints.
|
||||
* Uses in-memory repositories for isolation.
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import express, { Express } from 'express';
|
||||
import { createApiKeyRoutes } from './api-keys';
|
||||
import { ApiKeyService } from '../services/api-key-service';
|
||||
import { ApiKeyRepository, ApiKeyUsageRepository } from '../../../infrastructure/src/repositories/api-key-repository';
|
||||
import { ApiKey, ApiKeyId } from '../models/api-key';
|
||||
|
||||
// In-memory implementations for testing
|
||||
class InMemoryApiKeyRepository implements ApiKeyRepository {
|
||||
private keys = new Map<string, ApiKey>();
|
||||
|
||||
async save(apiKey: ApiKey): Promise<void> {
|
||||
this.keys.set(apiKey.id, apiKey);
|
||||
}
|
||||
|
||||
async findById(id: ApiKeyId): Promise<ApiKey | null> {
|
||||
return this.keys.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findByKeyHash(keyHash: string): Promise<ApiKey | null> {
|
||||
return Array.from(this.keys.values()).find(k => k.keyHash === keyHash) ?? null;
|
||||
}
|
||||
|
||||
async findAll(options?: { limit?: number; offset?: number; status?: string }): Promise<ApiKey[]> {
|
||||
let results = Array.from(this.keys.values());
|
||||
if (options?.status) {
|
||||
results = results.filter(k => k.status === options.status);
|
||||
}
|
||||
return results
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
.slice(options?.offset ?? 0, (options?.offset ?? 0) + (options?.limit ?? 100));
|
||||
}
|
||||
|
||||
async count(options?: { status?: string }): Promise<number> {
|
||||
if (options?.status) {
|
||||
return Array.from(this.keys.values()).filter(k => k.status === options.status).length;
|
||||
}
|
||||
return this.keys.size;
|
||||
}
|
||||
|
||||
async update(apiKey: ApiKey): Promise<void> {
|
||||
this.keys.set(apiKey.id, apiKey);
|
||||
}
|
||||
|
||||
async delete(id: ApiKeyId): Promise<void> {
|
||||
this.keys.delete(id);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.keys.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryApiKeyUsageRepository implements ApiKeyUsageRepository {
|
||||
private usages: Array<{
|
||||
apiKeyId: ApiKeyId;
|
||||
timestamp: Date;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
statusCode: number;
|
||||
responseTimeMs: number;
|
||||
clientIp?: string;
|
||||
}> = [];
|
||||
|
||||
async recordUsage(usage: {
|
||||
apiKeyId: ApiKeyId;
|
||||
timestamp: Date;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
statusCode: number;
|
||||
responseTimeMs: number;
|
||||
clientIp?: string;
|
||||
}): Promise<void> {
|
||||
this.usages.push(usage);
|
||||
}
|
||||
|
||||
async getUsageStats(apiKeyId: ApiKeyId, since: Date): Promise<{
|
||||
totalRequests: number;
|
||||
requestsPerMinute: number;
|
||||
requestsPerHour: number;
|
||||
requestsPerDay: number;
|
||||
averageResponseTimeMs: number;
|
||||
errorRate: number;
|
||||
}> {
|
||||
const relevant = this.usages.filter(u => u.apiKeyId === apiKeyId && u.timestamp >= since);
|
||||
const total = relevant.length;
|
||||
const errors = relevant.filter(u => u.statusCode >= 400).length;
|
||||
|
||||
return {
|
||||
totalRequests: total,
|
||||
requestsPerMinute: total, // Simplified for in-memory
|
||||
requestsPerHour: total,
|
||||
requestsPerDay: total,
|
||||
averageResponseTimeMs: total > 0
|
||||
? Math.round(relevant.reduce((sum, u) => sum + u.responseTimeMs, 0) / total)
|
||||
: 0,
|
||||
errorRate: total > 0 ? Math.round((errors / total) * 100) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
async getRecentUsage(apiKeyId: ApiKeyId, limit: number): Promise<Array<{
|
||||
timestamp: Date;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
statusCode: number;
|
||||
responseTimeMs: number;
|
||||
}>> {
|
||||
return this.usages
|
||||
.filter(u => u.apiKeyId === apiKeyId)
|
||||
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
|
||||
.slice(0, limit)
|
||||
.map(u => ({
|
||||
timestamp: u.timestamp,
|
||||
endpoint: u.endpoint,
|
||||
method: u.method,
|
||||
statusCode: u.statusCode,
|
||||
responseTimeMs: u.responseTimeMs,
|
||||
}));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.usages = [];
|
||||
}
|
||||
}
|
||||
|
||||
describe('API Key Routes', () => {
|
||||
let app: Express;
|
||||
let keyRepo: InMemoryApiKeyRepository;
|
||||
let usageRepo: InMemoryApiKeyUsageRepository;
|
||||
let service: ApiKeyService;
|
||||
|
||||
beforeEach(() => {
|
||||
keyRepo = new InMemoryApiKeyRepository();
|
||||
usageRepo = new InMemoryApiKeyUsageRepository();
|
||||
service = new ApiKeyService(keyRepo, usageRepo);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api-keys', createApiKeyRoutes(service));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
keyRepo.clear();
|
||||
usageRepo.clear();
|
||||
});
|
||||
|
||||
describe('POST /api-keys', () => {
|
||||
it('should create a new API key', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api-keys')
|
||||
.send({ name: 'Test Key' })
|
||||
.expect(201);
|
||||
|
||||
expect(response.body).toHaveProperty('id');
|
||||
expect(response.body.name).toBe('Test Key');
|
||||
expect(response.body).toHaveProperty('key');
|
||||
expect(response.body.key).toMatch(/^lvx_/);
|
||||
expect(response.body.status).toBe('active');
|
||||
expect(response.body.keyPrefix).toBe(response.body.key.substring(0, 12));
|
||||
});
|
||||
|
||||
it('should reject missing name', async () => {
|
||||
await request(app)
|
||||
.post('/api-keys')
|
||||
.send({})
|
||||
.expect(400)
|
||||
.expect(res => {
|
||||
expect(res.body.error).toBe('Bad Request');
|
||||
});
|
||||
});
|
||||
|
||||
it('should create key with custom rate limits', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api-keys')
|
||||
.send({
|
||||
name: 'Limited Key',
|
||||
rateLimitPerMinute: 10,
|
||||
rateLimitPerHour: 100,
|
||||
rateLimitPerDay: 500,
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(response.body.rateLimitPerMinute).toBe(10);
|
||||
expect(response.body.rateLimitPerHour).toBe(100);
|
||||
expect(response.body.rateLimitPerDay).toBe(500);
|
||||
});
|
||||
|
||||
it('should create key with expiration', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api-keys')
|
||||
.send({ name: 'Expiring Key', expiresInDays: 30 })
|
||||
.expect(201);
|
||||
|
||||
expect(response.body.expiresAt).not.toBeNull();
|
||||
const expiresAt = new Date(response.body.expiresAt);
|
||||
const now = new Date();
|
||||
const daysDiff = Math.round((expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
expect(daysDiff).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api-keys', () => {
|
||||
it('should list all API keys', async () => {
|
||||
await service.create({ name: 'Key 1' });
|
||||
await service.create({ name: 'Key 2' });
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api-keys')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.keys).toHaveLength(2);
|
||||
expect(response.body.total).toBe(2);
|
||||
expect(response.body.keys[0]).not.toHaveProperty('keyHash');
|
||||
expect(response.body.keys[0]).not.toHaveProperty('key');
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
const { apiKey } = await service.create({ name: 'Active Key' });
|
||||
const { apiKey: revokedKey } = await service.create({ name: 'Revoked Key' });
|
||||
await service.revoke(revokedKey.id);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api-keys?status=active')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.keys).toHaveLength(1);
|
||||
expect(response.body.keys[0].name).toBe('Active Key');
|
||||
});
|
||||
|
||||
it('should support pagination', async () => {
|
||||
await service.create({ name: 'Key 1' });
|
||||
await service.create({ name: 'Key 2' });
|
||||
await service.create({ name: 'Key 3' });
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api-keys?limit=2&offset=0')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.keys).toHaveLength(2);
|
||||
expect(response.body.limit).toBe(2);
|
||||
expect(response.body.offset).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api-keys/:id', () => {
|
||||
it('should return API key with usage stats', async () => {
|
||||
const { apiKey } = await service.create({ name: 'Test Key' });
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api-keys/${apiKey.id}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.key.id).toBe(apiKey.id);
|
||||
expect(response.body.key.name).toBe('Test Key');
|
||||
expect(response.body).toHaveProperty('usage');
|
||||
expect(response.body).toHaveProperty('recentUsage');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent key', async () => {
|
||||
await request(app)
|
||||
.get('/api-keys/non-existent-id')
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api-keys/:id/rotate', () => {
|
||||
it('should rotate an API key', async () => {
|
||||
const { apiKey } = await service.create({ name: 'Old Key' });
|
||||
|
||||
const response = await request(app)
|
||||
.post(`/api-keys/${apiKey.id}/rotate`)
|
||||
.expect(201);
|
||||
|
||||
expect(response.body.rotatedFromId).toBe(apiKey.id);
|
||||
expect(response.body).toHaveProperty('key');
|
||||
expect(response.body.key).toMatch(/^lvx_/);
|
||||
expect(response.body.status).toBe('active');
|
||||
|
||||
// Old key should be revoked
|
||||
const oldKey = await keyRepo.findById(apiKey.id);
|
||||
expect(oldKey?.status).toBe('revoked');
|
||||
});
|
||||
|
||||
it('should not rotate a revoked key', async () => {
|
||||
const { apiKey } = await service.create({ name: 'Revoked Key' });
|
||||
await service.revoke(apiKey.id);
|
||||
|
||||
await request(app)
|
||||
.post(`/api-keys/${apiKey.id}/rotate`)
|
||||
.expect(400)
|
||||
.expect(res => {
|
||||
expect(res.body.message).toBe('Cannot rotate a revoked API key');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent key', async () => {
|
||||
await request(app)
|
||||
.post('/api-keys/non-existent-id/rotate')
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api-keys/:id', () => {
|
||||
it('should revoke an API key (soft delete)', async () => {
|
||||
const { apiKey } = await service.create({ name: 'Key to Revoke' });
|
||||
|
||||
const response = await request(app)
|
||||
.delete(`/api-keys/${apiKey.id}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.revoked).toBe(true);
|
||||
expect(response.body.key.status).toBe('revoked');
|
||||
expect(response.body.key.revokedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should hard delete an API key', async () => {
|
||||
const { apiKey } = await service.create({ name: 'Key to Delete' });
|
||||
|
||||
const response = await request(app)
|
||||
.delete(`/api-keys/${apiKey.id}?hard=true`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.deleted).toBe(true);
|
||||
|
||||
const deleted = await keyRepo.findById(apiKey.id);
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent key', async () => {
|
||||
await request(app)
|
||||
.delete('/api-keys/non-existent-id')
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Key Validation', () => {
|
||||
it('should validate a correct API key', async () => {
|
||||
const { apiKey, plainKey } = await service.create({ name: 'Valid Key' });
|
||||
|
||||
const validated = await service.validateKey(plainKey);
|
||||
expect(validated).not.toBeNull();
|
||||
expect(validated?.id).toBe(apiKey.id);
|
||||
});
|
||||
|
||||
it('should reject an invalid API key', async () => {
|
||||
const validated = await service.validateKey('invalid-key');
|
||||
expect(validated).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject a revoked API key', async () => {
|
||||
const { plainKey } = await service.create({ name: 'Revoked Key' });
|
||||
const validatedBefore = await service.validateKey(plainKey);
|
||||
expect(validatedBefore).not.toBeNull();
|
||||
|
||||
await service.revoke(validatedBefore!.id);
|
||||
|
||||
const validatedAfter = await service.validateKey(plainKey);
|
||||
expect(validatedAfter).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limiting', () => {
|
||||
it('should allow requests within rate limit', async () => {
|
||||
const { apiKey } = await service.create({
|
||||
name: 'Limited Key',
|
||||
rateLimitPerMinute: 100,
|
||||
rateLimitPerHour: 1000,
|
||||
rateLimitPerDay: 10000,
|
||||
});
|
||||
|
||||
const check = await service.checkRateLimit(apiKey.id);
|
||||
expect(check.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should track usage stats', async () => {
|
||||
const { apiKey } = await service.create({ name: 'Tracked Key' });
|
||||
|
||||
await service.recordUsage(apiKey.id, {
|
||||
endpoint: '/test',
|
||||
method: 'GET',
|
||||
statusCode: 200,
|
||||
responseTimeMs: 50,
|
||||
});
|
||||
|
||||
await service.recordUsage(apiKey.id, {
|
||||
endpoint: '/test',
|
||||
method: 'GET',
|
||||
statusCode: 500,
|
||||
responseTimeMs: 100,
|
||||
});
|
||||
|
||||
const stats = await service.checkRateLimit(apiKey.id);
|
||||
expect(stats.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* API Key Management Routes
|
||||
*
|
||||
* Admin endpoints for managing API keys:
|
||||
* - POST /api-keys — Create new API key
|
||||
* - GET /api-keys — List all API keys
|
||||
* - GET /api-keys/:id — Get single API key with usage stats
|
||||
* - POST /api-keys/:id/rotate — Rotate API key
|
||||
* - DELETE /api-keys/:id — Revoke (soft-delete) or hard-delete API key
|
||||
*
|
||||
* All endpoints require admin authentication.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { ApiKeyService } from '../services/api-key-service';
|
||||
|
||||
export function createApiKeyRoutes(service: ApiKeyService): Router {
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* POST /api-keys
|
||||
* Create a new API key.
|
||||
*
|
||||
* Body: {
|
||||
* name: string;
|
||||
* expiresInDays?: number;
|
||||
* rateLimitPerMinute?: number;
|
||||
* rateLimitPerHour?: number;
|
||||
* rateLimitPerDay?: number;
|
||||
* metadata?: Record<string, unknown>;
|
||||
* }
|
||||
*
|
||||
* Response: {
|
||||
* id: string;
|
||||
* name: string;
|
||||
* key: string; // PLAIN KEY — ONLY SHOWN ONCE
|
||||
* keyPrefix: string;
|
||||
* status: 'active';
|
||||
* rateLimitPerMinute: number;
|
||||
* rateLimitPerHour: number;
|
||||
* rateLimitPerDay: number;
|
||||
* createdAt: string;
|
||||
* expiresAt: string | null;
|
||||
* metadata: Record<string, unknown>;
|
||||
* }
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name, expiresInDays, rateLimitPerMinute, rateLimitPerHour, rateLimitPerDay, metadata } = req.body;
|
||||
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'name is required and must be a non-empty string.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await service.create({
|
||||
name: name.trim(),
|
||||
expiresInDays: expiresInDays ? parseInt(expiresInDays, 10) : undefined,
|
||||
rateLimitPerMinute: rateLimitPerMinute ? parseInt(rateLimitPerMinute, 10) : undefined,
|
||||
rateLimitPerHour: rateLimitPerHour ? parseInt(rateLimitPerHour, 10) : undefined,
|
||||
rateLimitPerDay: rateLimitPerDay ? parseInt(rateLimitPerDay, 10) : undefined,
|
||||
metadata,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: result.apiKey.id,
|
||||
name: result.apiKey.name,
|
||||
key: result.plainKey,
|
||||
keyPrefix: result.apiKey.keyPrefix,
|
||||
status: result.apiKey.status,
|
||||
rateLimitPerMinute: result.apiKey.rateLimitPerMinute,
|
||||
rateLimitPerHour: result.apiKey.rateLimitPerHour,
|
||||
rateLimitPerDay: result.apiKey.rateLimitPerDay,
|
||||
createdAt: result.apiKey.createdAt.toISOString(),
|
||||
expiresAt: result.apiKey.expiresAt?.toISOString() ?? null,
|
||||
metadata: result.apiKey.metadata,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create API key:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error', message: 'Failed to create API key.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api-keys
|
||||
* List all API keys (without sensitive data).
|
||||
*
|
||||
* Query: ?status=active&limit=50&offset=0
|
||||
*
|
||||
* Response: {
|
||||
* keys: ApiKeyJSON[];
|
||||
* total: number;
|
||||
* limit: number;
|
||||
* offset: number;
|
||||
* }
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const status = req.query.status as string | undefined;
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 100;
|
||||
const offset = req.query.offset ? parseInt(req.query.offset as string, 10) : 0;
|
||||
|
||||
const result = await service.list({ status, limit, offset });
|
||||
|
||||
res.json({
|
||||
keys: result.keys.map(k => k.toJSON()),
|
||||
total: result.total,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to list API keys:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error', message: 'Failed to list API keys.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api-keys/:id
|
||||
* Get a single API key with usage statistics.
|
||||
*
|
||||
* Response: {
|
||||
* key: ApiKeyJSON;
|
||||
* usage: {
|
||||
* totalRequests: number;
|
||||
* requestsPerMinute: number;
|
||||
* requestsPerHour: number;
|
||||
* requestsPerDay: number;
|
||||
* averageResponseTimeMs: number;
|
||||
* errorRate: number;
|
||||
* };
|
||||
* recentUsage: Array<{
|
||||
* timestamp: string;
|
||||
* endpoint: string;
|
||||
* method: string;
|
||||
* statusCode: number;
|
||||
* responseTimeMs: number;
|
||||
* }>;
|
||||
* }
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stats = await service.getById(req.params.id);
|
||||
|
||||
if (!stats) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'API key not found.' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
key: stats.apiKey.toJSON(),
|
||||
usage: stats.usage,
|
||||
recentUsage: stats.recentUsage.map(u => ({
|
||||
...u,
|
||||
timestamp: u.timestamp.toISOString(),
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get API key:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error', message: 'Failed to get API key.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api-keys/:id/rotate
|
||||
* Rotate an API key: revoke the old one and create a new one with the same config.
|
||||
*
|
||||
* Response: {
|
||||
* id: string;
|
||||
* name: string;
|
||||
* key: string; // NEW PLAIN KEY — ONLY SHOWN ONCE
|
||||
* keyPrefix: string;
|
||||
* status: 'active';
|
||||
* rotatedFromId: string;
|
||||
* rateLimitPerMinute: number;
|
||||
* rateLimitPerHour: number;
|
||||
* rateLimitPerDay: number;
|
||||
* createdAt: string;
|
||||
* expiresAt: string | null;
|
||||
* }
|
||||
*/
|
||||
router.post('/:id/rotate', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await service.rotate(req.params.id);
|
||||
|
||||
if (!result) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'API key not found.' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
id: result.apiKey.id,
|
||||
name: result.apiKey.name,
|
||||
key: result.plainKey,
|
||||
keyPrefix: result.apiKey.keyPrefix,
|
||||
status: result.apiKey.status,
|
||||
rotatedFromId: result.apiKey.rotatedFromId,
|
||||
rateLimitPerMinute: result.apiKey.rateLimitPerMinute,
|
||||
rateLimitPerHour: result.apiKey.rateLimitPerHour,
|
||||
rateLimitPerDay: result.apiKey.rateLimitPerDay,
|
||||
createdAt: result.apiKey.createdAt.toISOString(),
|
||||
expiresAt: result.apiKey.expiresAt?.toISOString() ?? null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.message === 'Cannot rotate a revoked API key') {
|
||||
res.status(400).json({ error: 'Bad Request', message: error.message });
|
||||
return;
|
||||
}
|
||||
console.error('Failed to rotate API key:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error', message: 'Failed to rotate API key.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api-keys/:id
|
||||
* Revoke an API key (soft delete). Pass ?hard=true for permanent deletion.
|
||||
*
|
||||
* Response (soft delete): { revoked: true, key: ApiKeyJSON }
|
||||
* Response (hard delete): { deleted: true }
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const hard = req.query.hard === 'true';
|
||||
|
||||
if (hard) {
|
||||
const deleted = await service.delete(req.params.id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'API key not found.' });
|
||||
return;
|
||||
}
|
||||
res.json({ deleted: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const revoked = await service.revoke(req.params.id);
|
||||
|
||||
if (!revoked) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'API key not found.' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ revoked: true, key: revoked.toJSON() });
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke/delete API key:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error', message: 'Failed to revoke/delete API key.' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* API Key Service
|
||||
*
|
||||
* Business logic for API key lifecycle management.
|
||||
* Handles generation, rotation, revocation, and validation.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { ApiKey, ApiKeyId } from '../models/api-key';
|
||||
import { ApiKeyRepository, ApiKeyUsageRepository } from '../../../infrastructure/src/repositories/api-key-repository';
|
||||
|
||||
export interface CreateApiKeyRequest {
|
||||
name: string;
|
||||
expiresInDays?: number;
|
||||
rateLimitPerMinute?: number;
|
||||
rateLimitPerHour?: number;
|
||||
rateLimitPerDay?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CreateApiKeyResponse {
|
||||
apiKey: ApiKey;
|
||||
plainKey: string; // ONLY returned on creation
|
||||
}
|
||||
|
||||
export interface ApiKeyStats {
|
||||
apiKey: ApiKey;
|
||||
usage: {
|
||||
totalRequests: number;
|
||||
requestsPerMinute: number;
|
||||
requestsPerHour: number;
|
||||
requestsPerDay: number;
|
||||
averageResponseTimeMs: number;
|
||||
errorRate: number;
|
||||
};
|
||||
recentUsage: Array<{
|
||||
timestamp: Date;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
statusCode: number;
|
||||
responseTimeMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class ApiKeyService {
|
||||
constructor(
|
||||
private readonly apiKeyRepo: ApiKeyRepository,
|
||||
private readonly usageRepo: ApiKeyUsageRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generate a new API key.
|
||||
* Returns the plain key ONLY on creation — it cannot be retrieved later.
|
||||
*/
|
||||
async create(request: CreateApiKeyRequest): Promise<CreateApiKeyResponse> {
|
||||
const plainKey = this.generatePlainKey();
|
||||
const keyHash = this.hashKey(plainKey);
|
||||
const keyPrefix = plainKey.substring(0, 12);
|
||||
|
||||
const now = new Date();
|
||||
const expiresAt = request.expiresInDays
|
||||
? new Date(now.getTime() + request.expiresInDays * 24 * 60 * 60 * 1000)
|
||||
: null;
|
||||
|
||||
const apiKey = new ApiKey({
|
||||
id: randomUUID(),
|
||||
name: request.name,
|
||||
keyHash,
|
||||
keyPrefix,
|
||||
status: 'active',
|
||||
rateLimitPerMinute: request.rateLimitPerMinute ?? 60,
|
||||
rateLimitPerHour: request.rateLimitPerHour ?? 1000,
|
||||
rateLimitPerDay: request.rateLimitPerDay ?? 10000,
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
revokedAt: null,
|
||||
rotatedFromId: null,
|
||||
metadata: request.metadata ?? {},
|
||||
});
|
||||
|
||||
await this.apiKeyRepo.save(apiKey);
|
||||
|
||||
return { apiKey, plainKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* List all API keys (without sensitive data).
|
||||
*/
|
||||
async list(options?: { limit?: number; offset?: number; status?: string }): Promise<{
|
||||
keys: ApiKey[];
|
||||
total: number;
|
||||
}> {
|
||||
const [keys, total] = await Promise.all([
|
||||
this.apiKeyRepo.findAll(options),
|
||||
this.apiKeyRepo.count({ status: options?.status }),
|
||||
]);
|
||||
|
||||
return { keys, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single API key by ID with usage stats.
|
||||
*/
|
||||
async getById(id: ApiKeyId): Promise<ApiKeyStats | null> {
|
||||
const apiKey = await this.apiKeyRepo.findById(id);
|
||||
if (!apiKey) return null;
|
||||
|
||||
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days
|
||||
const [usage, recentUsage] = await Promise.all([
|
||||
this.usageRepo.getUsageStats(id, since),
|
||||
this.usageRepo.getRecentUsage(id, 50),
|
||||
]);
|
||||
|
||||
return { apiKey, usage, recentUsage };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate an API key: revoke old, create new.
|
||||
* The new key maintains the same configuration but gets fresh credentials.
|
||||
*/
|
||||
async rotate(id: ApiKeyId): Promise<CreateApiKeyResponse | null> {
|
||||
const oldKey = await this.apiKeyRepo.findById(id);
|
||||
if (!oldKey) return null;
|
||||
|
||||
if (oldKey.isRevoked()) {
|
||||
throw new Error('Cannot rotate a revoked API key');
|
||||
}
|
||||
|
||||
// Revoke old key
|
||||
const revokedKey = oldKey.revoke();
|
||||
await this.apiKeyRepo.update(revokedKey);
|
||||
|
||||
// Create new key with same config
|
||||
const plainKey = this.generatePlainKey();
|
||||
const keyHash = this.hashKey(plainKey);
|
||||
const keyPrefix = plainKey.substring(0, 12);
|
||||
|
||||
const newKey = new ApiKey({
|
||||
id: randomUUID(),
|
||||
name: oldKey.name,
|
||||
keyHash,
|
||||
keyPrefix,
|
||||
status: 'active',
|
||||
rateLimitPerMinute: oldKey.rateLimitPerMinute,
|
||||
rateLimitPerHour: oldKey.rateLimitPerHour,
|
||||
rateLimitPerDay: oldKey.rateLimitPerDay,
|
||||
createdAt: new Date(),
|
||||
expiresAt: oldKey.expiresAt,
|
||||
revokedAt: null,
|
||||
rotatedFromId: oldKey.id,
|
||||
metadata: { ...oldKey.metadata, rotatedFrom: oldKey.id },
|
||||
});
|
||||
|
||||
await this.apiKeyRepo.save(newKey);
|
||||
|
||||
return { apiKey: newKey, plainKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an API key immediately.
|
||||
*/
|
||||
async revoke(id: ApiKeyId): Promise<ApiKey | null> {
|
||||
const apiKey = await this.apiKeyRepo.findById(id);
|
||||
if (!apiKey) return null;
|
||||
|
||||
if (apiKey.isRevoked()) {
|
||||
return apiKey; // Already revoked
|
||||
}
|
||||
|
||||
const revoked = apiKey.revoke();
|
||||
await this.apiKeyRepo.update(revoked);
|
||||
|
||||
return revoked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an API key permanently.
|
||||
*/
|
||||
async delete(id: ApiKeyId): Promise<boolean> {
|
||||
const apiKey = await this.apiKeyRepo.findById(id);
|
||||
if (!apiKey) return false;
|
||||
|
||||
await this.apiKeyRepo.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an API key from a request.
|
||||
* Returns the key if valid, null otherwise.
|
||||
*/
|
||||
async validateKey(plainKey: string): Promise<ApiKey | null> {
|
||||
const keyHash = this.hashKey(plainKey);
|
||||
const apiKey = await this.apiKeyRepo.findByKeyHash(keyHash);
|
||||
|
||||
if (!apiKey) return null;
|
||||
if (!apiKey.isActive()) return null;
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record API usage for analytics and rate limiting.
|
||||
*/
|
||||
async recordUsage(
|
||||
apiKeyId: ApiKeyId,
|
||||
usage: {
|
||||
endpoint: string;
|
||||
method: string;
|
||||
statusCode: number;
|
||||
responseTimeMs: number;
|
||||
clientIp?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
await this.usageRepo.recordUsage({
|
||||
apiKeyId,
|
||||
timestamp: new Date(),
|
||||
...usage,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an API key is within rate limits.
|
||||
*/
|
||||
async checkRateLimit(apiKeyId: ApiKeyId): Promise<{
|
||||
allowed: boolean;
|
||||
limits: {
|
||||
perMinute: { current: number; limit: number };
|
||||
perHour: { current: number; limit: number };
|
||||
perDay: { current: number; limit: number };
|
||||
};
|
||||
}> {
|
||||
const apiKey = await this.apiKeyRepo.findById(apiKeyId);
|
||||
if (!apiKey) {
|
||||
return {
|
||||
allowed: false,
|
||||
limits: {
|
||||
perMinute: { current: 0, limit: 0 },
|
||||
perHour: { current: 0, limit: 0 },
|
||||
perDay: { current: 0, limit: 0 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const stats = await this.usageRepo.getUsageStats(apiKeyId, since);
|
||||
|
||||
const limits = {
|
||||
perMinute: {
|
||||
current: stats.requestsPerMinute,
|
||||
limit: apiKey.rateLimitPerMinute,
|
||||
},
|
||||
perHour: {
|
||||
current: stats.requestsPerHour,
|
||||
limit: apiKey.rateLimitPerHour,
|
||||
},
|
||||
perDay: {
|
||||
current: stats.requestsPerDay,
|
||||
limit: apiKey.rateLimitPerDay,
|
||||
},
|
||||
};
|
||||
|
||||
const allowed =
|
||||
limits.perMinute.current < limits.perMinute.limit &&
|
||||
limits.perHour.current < limits.perHour.limit &&
|
||||
limits.perDay.current < limits.perDay.limit;
|
||||
|
||||
return { allowed, limits };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure API key.
|
||||
* Format: lvx_<base64url-encoded-random-bytes>
|
||||
*/
|
||||
private generatePlainKey(): string {
|
||||
const random = randomBytes(32).toString('base64url');
|
||||
return `lvx_${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash an API key for storage.
|
||||
* Uses SHA-256 for fast lookups (keys are high-entropy, so bcrypt is overkill).
|
||||
*/
|
||||
private hashKey(plainKey: string): string {
|
||||
return createHash('sha256').update(plainKey).digest('hex');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user