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:
Bernt
2026-07-05 06:41:32 +00:00
parent f4f853d94b
commit aee0f09db8
19583 changed files with 1450867 additions and 1153 deletions
+161
View File
@@ -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) |
+141 -2
View File
@@ -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",
+10 -9
View File
@@ -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"
}
}
+87
View File
@@ -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;
+138
View File
@@ -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();
}
};
}
+67
View File
@@ -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
+1
View File
@@ -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"}
+88
View File
@@ -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
+1
View File
@@ -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"}
+115
View File
@@ -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,
};
}
}
+127
View File
@@ -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()');
+403
View File
@@ -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);
});
});
});
+249
View File
@@ -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');
}
}