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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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 ApiKeyProps {
|
||||
id: ApiKeyId;
|
||||
name: string;
|
||||
keyHash: string; // SHA-256 hash of the actual key (only stored once)
|
||||
keyPrefix: string; // First 12 chars of key for display (e.g., "lvx_Af7x9K2m")
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,13 @@ export {
|
||||
enable,
|
||||
disable,
|
||||
} from './auth/feature-flags';
|
||||
|
||||
export {
|
||||
ApiKey,
|
||||
ApiKeyId,
|
||||
ApiKeyStatus,
|
||||
ApiKeyProps,
|
||||
} from './auth/api-key';
|
||||
export * from './common/errors';
|
||||
|
||||
// Artifacts
|
||||
|
||||
+148
-1
@@ -8,7 +8,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",
|
||||
@@ -3154,6 +3155,95 @@
|
||||
"dev": true,
|
||||
"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",
|
||||
@@ -3197,6 +3287,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",
|
||||
@@ -3406,6 +3535,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",
|
||||
@@ -3817,6 +3955,15 @@
|
||||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@landvex/domain": "file:../domain"
|
||||
"@landvex/domain": "file:../domain",
|
||||
"pg": "^8.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.0",
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* In-Memory API Key Repository
|
||||
*
|
||||
* For testing and development. Fast, isolated, no external dependencies.
|
||||
*/
|
||||
|
||||
import { ApiKey, ApiKeyId } from '../../../api/src/models/api-key';
|
||||
import { ApiKeyRepository, ApiKeyUsageRepository } from '../../repositories/api-key-repository';
|
||||
|
||||
export 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();
|
||||
}
|
||||
}
|
||||
|
||||
export 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 = [];
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* PostgreSQL API Key Repository
|
||||
*
|
||||
* Production-ready adapter for API key persistence.
|
||||
* Uses pg client for database operations.
|
||||
*/
|
||||
import { ApiKey, ApiKeyId } from '../../../../api/src/models/api-key';
|
||||
import { ApiKeyRepository, ApiKeyUsageRepository } from '../../repositories/api-key-repository';
|
||||
export interface PostgresApiKeyConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
user: string;
|
||||
password: string;
|
||||
ssl?: boolean | object;
|
||||
}
|
||||
export declare class PostgresApiKeyRepository implements ApiKeyRepository {
|
||||
private pool;
|
||||
constructor(config: PostgresApiKeyConfig);
|
||||
init(): Promise<void>;
|
||||
save(apiKey: ApiKey): Promise<void>;
|
||||
findById(id: ApiKeyId): Promise<ApiKey | null>;
|
||||
findByKeyHash(keyHash: string): Promise<ApiKey | null>;
|
||||
findAll(options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
status?: string;
|
||||
}): Promise<ApiKey[]>;
|
||||
count(options?: {
|
||||
status?: string;
|
||||
}): Promise<number>;
|
||||
update(apiKey: ApiKey): Promise<void>;
|
||||
delete(id: ApiKeyId): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
export declare class PostgresApiKeyUsageRepository implements ApiKeyUsageRepository {
|
||||
private pool;
|
||||
constructor(config: PostgresApiKeyConfig);
|
||||
init(): Promise<void>;
|
||||
recordUsage(usage: {
|
||||
apiKeyId: ApiKeyId;
|
||||
timestamp: Date;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
statusCode: number;
|
||||
responseTimeMs: number;
|
||||
clientIp?: string;
|
||||
}): Promise<void>;
|
||||
getUsageStats(apiKeyId: ApiKeyId, since: Date): Promise<{
|
||||
totalRequests: number;
|
||||
requestsPerMinute: number;
|
||||
requestsPerHour: number;
|
||||
requestsPerDay: number;
|
||||
averageResponseTimeMs: number;
|
||||
errorRate: number;
|
||||
}>;
|
||||
getRecentUsage(apiKeyId: ApiKeyId, limit: number): Promise<Array<{
|
||||
timestamp: Date;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
statusCode: number;
|
||||
responseTimeMs: number;
|
||||
}>>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
//# sourceMappingURL=postgres-api-key-repository.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"postgres-api-key-repository.d.ts","sourceRoot":"","sources":["postgres-api-key-repository.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAe,MAAM,oCAAoC,CAAC;AACnF,OAAO,EAAE,gBAAgB,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAEhG,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;CACxB;AAkCD,qBAAa,wBAAyB,YAAW,gBAAgB;IAC/D,OAAO,CAAC,IAAI,CAAO;gBAEP,MAAM,EAAE,oBAAoB;IAIlC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAwCrB,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAmCnC,QAAQ,CAAC,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAS9C,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAStD,OAAO,CAAC,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAqB1F,KAAK,CAAC,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC;IAarD,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIrC,MAAM,CAAC,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAInC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAG7B;AAED,qBAAa,6BAA8B,YAAW,qBAAqB;IACzE,OAAO,CAAC,IAAI,CAAO;gBAEP,MAAM,EAAE,oBAAoB;IAIlC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IA+BrB,WAAW,CAAC,KAAK,EAAE;QACvB,QAAQ,EAAE,QAAQ,CAAC;QACnB,SAAS,EAAE,IAAI,CAAC;QAChB,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,EAAE,MAAM,CAAC;QACnB,cAAc,EAAE,MAAM,CAAC;QACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,GAAG,OAAO,CAAC,IAAI,CAAC;IAiBX,aAAa,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,IAAI,GAAG,OAAO,CAAC;QAC5D,aAAa,EAAE,MAAM,CAAC;QACtB,iBAAiB,EAAE,MAAM,CAAC;QAC1B,eAAe,EAAE,MAAM,CAAC;QACxB,cAAc,EAAE,MAAM,CAAC;QACvB,qBAAqB,EAAE,MAAM,CAAC;QAC9B,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;IAyBI,cAAc,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;QACrE,SAAS,EAAE,IAAI,CAAC;QAChB,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,EAAE,MAAM,CAAC;QACnB,cAAc,EAAE,MAAM,CAAC;KACxB,CAAC,CAAC;IAmBG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAG7B"}
|
||||
@@ -0,0 +1,254 @@
|
||||
"use strict";
|
||||
/**
|
||||
* PostgreSQL API Key Repository
|
||||
*
|
||||
* Production-ready adapter for API key persistence.
|
||||
* Uses pg client for database operations.
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.PostgresApiKeyUsageRepository = exports.PostgresApiKeyRepository = void 0;
|
||||
const pg_1 = require("pg");
|
||||
const api_key_1 = require("../../../../api/src/models/api-key");
|
||||
function createPool(config) {
|
||||
return new pg_1.Pool({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
database: config.database,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
ssl: config.ssl,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
}
|
||||
function rowToApiKey(row) {
|
||||
return new api_key_1.ApiKey({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
keyHash: row.key_hash,
|
||||
keyPrefix: row.key_prefix,
|
||||
status: row.status,
|
||||
rateLimitPerMinute: row.rate_limit_per_minute,
|
||||
rateLimitPerHour: row.rate_limit_per_hour,
|
||||
rateLimitPerDay: row.rate_limit_per_day,
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at,
|
||||
revokedAt: row.revoked_at,
|
||||
rotatedFromId: row.rotated_from_id,
|
||||
metadata: row.metadata ?? {},
|
||||
});
|
||||
}
|
||||
class PostgresApiKeyRepository {
|
||||
pool;
|
||||
constructor(config) {
|
||||
this.pool = createPool(config);
|
||||
}
|
||||
async init() {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query(`
|
||||
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 '{}',
|
||||
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
|
||||
)
|
||||
)
|
||||
`);
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_status ON api_keys(status)
|
||||
`);
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash)
|
||||
`);
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_created_at ON api_keys(created_at DESC)
|
||||
`);
|
||||
}
|
||||
finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
async save(apiKey) {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query(`INSERT INTO api_keys (
|
||||
id, name, key_hash, key_prefix, status,
|
||||
rate_limit_per_minute, rate_limit_per_hour, rate_limit_per_day,
|
||||
created_at, expires_at, revoked_at, rotated_from_id, metadata
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
status = EXCLUDED.status,
|
||||
revoked_at = EXCLUDED.revoked_at,
|
||||
metadata = EXCLUDED.metadata`, [
|
||||
apiKey.id,
|
||||
apiKey.name,
|
||||
apiKey.keyHash,
|
||||
apiKey.keyPrefix,
|
||||
apiKey.status,
|
||||
apiKey.rateLimitPerMinute,
|
||||
apiKey.rateLimitPerHour,
|
||||
apiKey.rateLimitPerDay,
|
||||
apiKey.createdAt,
|
||||
apiKey.expiresAt,
|
||||
apiKey.revokedAt,
|
||||
apiKey.rotatedFromId,
|
||||
JSON.stringify(apiKey.metadata),
|
||||
]);
|
||||
}
|
||||
finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
async findById(id) {
|
||||
const result = await this.pool.query('SELECT * FROM api_keys WHERE id = $1', [id]);
|
||||
if (result.rows.length === 0)
|
||||
return null;
|
||||
return rowToApiKey(result.rows[0]);
|
||||
}
|
||||
async findByKeyHash(keyHash) {
|
||||
const result = await this.pool.query('SELECT * FROM api_keys WHERE key_hash = $1', [keyHash]);
|
||||
if (result.rows.length === 0)
|
||||
return null;
|
||||
return rowToApiKey(result.rows[0]);
|
||||
}
|
||||
async findAll(options) {
|
||||
const limit = options?.limit ?? 100;
|
||||
const offset = options?.offset ?? 0;
|
||||
const status = options?.status;
|
||||
let query = 'SELECT * FROM api_keys';
|
||||
const params = [];
|
||||
if (status) {
|
||||
query += ' WHERE status = $1';
|
||||
params.push(status);
|
||||
}
|
||||
query += ' ORDER BY created_at DESC';
|
||||
query += ` LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
|
||||
params.push(limit, offset);
|
||||
const result = await this.pool.query(query, params);
|
||||
return result.rows.map(rowToApiKey);
|
||||
}
|
||||
async count(options) {
|
||||
let query = 'SELECT COUNT(*) FROM api_keys';
|
||||
const params = [];
|
||||
if (options?.status) {
|
||||
query += ' WHERE status = $1';
|
||||
params.push(options.status);
|
||||
}
|
||||
const result = await this.pool.query(query, params);
|
||||
return parseInt(result.rows[0].count, 10);
|
||||
}
|
||||
async update(apiKey) {
|
||||
await this.save(apiKey);
|
||||
}
|
||||
async delete(id) {
|
||||
await this.pool.query('DELETE FROM api_keys WHERE id = $1', [id]);
|
||||
}
|
||||
async close() {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
exports.PostgresApiKeyRepository = PostgresApiKeyRepository;
|
||||
class PostgresApiKeyUsageRepository {
|
||||
pool;
|
||||
constructor(config) {
|
||||
this.pool = createPool(config);
|
||||
}
|
||||
async init() {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query(`
|
||||
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
|
||||
)
|
||||
`);
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_api_key_usage_api_key_id ON api_key_usage(api_key_id)
|
||||
`);
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_api_key_usage_timestamp ON api_key_usage(timestamp DESC)
|
||||
`);
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_api_key_usage_api_key_timestamp
|
||||
ON api_key_usage(api_key_id, timestamp DESC)
|
||||
`);
|
||||
}
|
||||
finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
async recordUsage(usage) {
|
||||
await this.pool.query(`INSERT INTO api_key_usage (
|
||||
api_key_id, timestamp, endpoint, method, status_code, response_time_ms, client_ip
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [
|
||||
usage.apiKeyId,
|
||||
usage.timestamp,
|
||||
usage.endpoint,
|
||||
usage.method,
|
||||
usage.statusCode,
|
||||
usage.responseTimeMs,
|
||||
usage.clientIp ?? null,
|
||||
]);
|
||||
}
|
||||
async getUsageStats(apiKeyId, since) {
|
||||
const result = await this.pool.query(`SELECT
|
||||
COUNT(*) as total_requests,
|
||||
AVG(response_time_ms) as avg_response_time,
|
||||
SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END)::FLOAT / NULLIF(COUNT(*), 0) as error_rate,
|
||||
COUNT(*) FILTER (WHERE timestamp >= NOW() - INTERVAL '1 minute') as requests_per_minute,
|
||||
COUNT(*) FILTER (WHERE timestamp >= NOW() - INTERVAL '1 hour') as requests_per_hour,
|
||||
COUNT(*) FILTER (WHERE timestamp >= NOW() - INTERVAL '1 day') as requests_per_day
|
||||
FROM api_key_usage
|
||||
WHERE api_key_id = $1 AND timestamp >= $2`, [apiKeyId, since]);
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
totalRequests: parseInt(row.total_requests, 10),
|
||||
requestsPerMinute: parseInt(row.requests_per_minute, 10),
|
||||
requestsPerHour: parseInt(row.requests_per_hour, 10),
|
||||
requestsPerDay: parseInt(row.requests_per_day, 10),
|
||||
averageResponseTimeMs: Math.round(parseFloat(row.avg_response_time ?? '0')),
|
||||
errorRate: Math.round((parseFloat(row.error_rate ?? '0') * 100)),
|
||||
};
|
||||
}
|
||||
async getRecentUsage(apiKeyId, limit) {
|
||||
const result = await this.pool.query(`SELECT timestamp, endpoint, method, status_code, response_time_ms
|
||||
FROM api_key_usage
|
||||
WHERE api_key_id = $1
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT $2`, [apiKeyId, limit]);
|
||||
return result.rows.map(row => ({
|
||||
timestamp: row.timestamp,
|
||||
endpoint: row.endpoint,
|
||||
method: row.method,
|
||||
statusCode: row.status_code,
|
||||
responseTimeMs: row.response_time_ms,
|
||||
}));
|
||||
}
|
||||
async close() {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
exports.PostgresApiKeyUsageRepository = PostgresApiKeyUsageRepository;
|
||||
//# sourceMappingURL=postgres-api-key-repository.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* PostgreSQL API Key Repository
|
||||
*
|
||||
* Production-ready adapter for API key persistence.
|
||||
* Uses pg client for database operations.
|
||||
*/
|
||||
|
||||
import { Pool, PoolClient } from 'pg';
|
||||
import { ApiKey, ApiKeyId, ApiKeyProps } from '../../../../api/src/models/api-key';
|
||||
import { ApiKeyRepository, ApiKeyUsageRepository } from '../../repositories/api-key-repository';
|
||||
|
||||
export interface PostgresApiKeyConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
user: string;
|
||||
password: string;
|
||||
ssl?: boolean | object;
|
||||
}
|
||||
|
||||
function createPool(config: PostgresApiKeyConfig): Pool {
|
||||
return new Pool({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
database: config.database,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
ssl: config.ssl,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
}
|
||||
|
||||
function rowToApiKey(row: Record<string, unknown>): ApiKey {
|
||||
return new ApiKey({
|
||||
id: row.id as string,
|
||||
name: row.name as string,
|
||||
keyHash: row.key_hash as string,
|
||||
keyPrefix: row.key_prefix as string,
|
||||
status: row.status as ApiKeyProps['status'],
|
||||
rateLimitPerMinute: row.rate_limit_per_minute as number,
|
||||
rateLimitPerHour: row.rate_limit_per_hour as number,
|
||||
rateLimitPerDay: row.rate_limit_per_day as number,
|
||||
createdAt: row.created_at as Date,
|
||||
expiresAt: row.expires_at as Date | null,
|
||||
revokedAt: row.revoked_at as Date | null,
|
||||
rotatedFromId: row.rotated_from_id as string | null,
|
||||
metadata: (row.metadata as Record<string, unknown>) ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
export class PostgresApiKeyRepository implements ApiKeyRepository {
|
||||
private pool: Pool;
|
||||
|
||||
constructor(config: PostgresApiKeyConfig) {
|
||||
this.pool = createPool(config);
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query(`
|
||||
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 '{}',
|
||||
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
|
||||
)
|
||||
)
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_status ON api_keys(status)
|
||||
`);
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash)
|
||||
`);
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_created_at ON api_keys(created_at DESC)
|
||||
`);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async save(apiKey: ApiKey): Promise<void> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query(
|
||||
`INSERT INTO api_keys (
|
||||
id, name, key_hash, key_prefix, status,
|
||||
rate_limit_per_minute, rate_limit_per_hour, rate_limit_per_day,
|
||||
created_at, expires_at, revoked_at, rotated_from_id, metadata
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
status = EXCLUDED.status,
|
||||
revoked_at = EXCLUDED.revoked_at,
|
||||
metadata = EXCLUDED.metadata`,
|
||||
[
|
||||
apiKey.id,
|
||||
apiKey.name,
|
||||
apiKey.keyHash,
|
||||
apiKey.keyPrefix,
|
||||
apiKey.status,
|
||||
apiKey.rateLimitPerMinute,
|
||||
apiKey.rateLimitPerHour,
|
||||
apiKey.rateLimitPerDay,
|
||||
apiKey.createdAt,
|
||||
apiKey.expiresAt,
|
||||
apiKey.revokedAt,
|
||||
apiKey.rotatedFromId,
|
||||
JSON.stringify(apiKey.metadata),
|
||||
]
|
||||
);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: ApiKeyId): Promise<ApiKey | null> {
|
||||
const result = await this.pool.query(
|
||||
'SELECT * FROM api_keys WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return rowToApiKey(result.rows[0]);
|
||||
}
|
||||
|
||||
async findByKeyHash(keyHash: string): Promise<ApiKey | null> {
|
||||
const result = await this.pool.query(
|
||||
'SELECT * FROM api_keys WHERE key_hash = $1',
|
||||
[keyHash]
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return rowToApiKey(result.rows[0]);
|
||||
}
|
||||
|
||||
async findAll(options?: { limit?: number; offset?: number; status?: string }): Promise<ApiKey[]> {
|
||||
const limit = options?.limit ?? 100;
|
||||
const offset = options?.offset ?? 0;
|
||||
const status = options?.status;
|
||||
|
||||
let query = 'SELECT * FROM api_keys';
|
||||
const params: (string | number)[] = [];
|
||||
|
||||
if (status) {
|
||||
query += ' WHERE status = $1';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
query += ` LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
|
||||
params.push(limit, offset);
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
return result.rows.map(rowToApiKey);
|
||||
}
|
||||
|
||||
async count(options?: { status?: string }): Promise<number> {
|
||||
let query = 'SELECT COUNT(*) FROM api_keys';
|
||||
const params: string[] = [];
|
||||
|
||||
if (options?.status) {
|
||||
query += ' WHERE status = $1';
|
||||
params.push(options.status);
|
||||
}
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
return parseInt(result.rows[0].count, 10);
|
||||
}
|
||||
|
||||
async update(apiKey: ApiKey): Promise<void> {
|
||||
await this.save(apiKey);
|
||||
}
|
||||
|
||||
async delete(id: ApiKeyId): Promise<void> {
|
||||
await this.pool.query('DELETE FROM api_keys WHERE id = $1', [id]);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
export class PostgresApiKeyUsageRepository implements ApiKeyUsageRepository {
|
||||
private pool: Pool;
|
||||
|
||||
constructor(config: PostgresApiKeyConfig) {
|
||||
this.pool = createPool(config);
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query(`
|
||||
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
|
||||
)
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_api_key_usage_api_key_id ON api_key_usage(api_key_id)
|
||||
`);
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_api_key_usage_timestamp ON api_key_usage(timestamp DESC)
|
||||
`);
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_api_key_usage_api_key_timestamp
|
||||
ON api_key_usage(api_key_id, timestamp DESC)
|
||||
`);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async recordUsage(usage: {
|
||||
apiKeyId: ApiKeyId;
|
||||
timestamp: Date;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
statusCode: number;
|
||||
responseTimeMs: number;
|
||||
clientIp?: string;
|
||||
}): Promise<void> {
|
||||
await this.pool.query(
|
||||
`INSERT INTO api_key_usage (
|
||||
api_key_id, timestamp, endpoint, method, status_code, response_time_ms, client_ip
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
usage.apiKeyId,
|
||||
usage.timestamp,
|
||||
usage.endpoint,
|
||||
usage.method,
|
||||
usage.statusCode,
|
||||
usage.responseTimeMs,
|
||||
usage.clientIp ?? null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async getUsageStats(apiKeyId: ApiKeyId, since: Date): Promise<{
|
||||
totalRequests: number;
|
||||
requestsPerMinute: number;
|
||||
requestsPerHour: number;
|
||||
requestsPerDay: number;
|
||||
averageResponseTimeMs: number;
|
||||
errorRate: number;
|
||||
}> {
|
||||
const result = await this.pool.query(
|
||||
`SELECT
|
||||
COUNT(*) as total_requests,
|
||||
AVG(response_time_ms) as avg_response_time,
|
||||
SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END)::FLOAT / NULLIF(COUNT(*), 0) as error_rate,
|
||||
COUNT(*) FILTER (WHERE timestamp >= NOW() - INTERVAL '1 minute') as requests_per_minute,
|
||||
COUNT(*) FILTER (WHERE timestamp >= NOW() - INTERVAL '1 hour') as requests_per_hour,
|
||||
COUNT(*) FILTER (WHERE timestamp >= NOW() - INTERVAL '1 day') as requests_per_day
|
||||
FROM api_key_usage
|
||||
WHERE api_key_id = $1 AND timestamp >= $2`,
|
||||
[apiKeyId, since]
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
totalRequests: parseInt(row.total_requests, 10),
|
||||
requestsPerMinute: parseInt(row.requests_per_minute, 10),
|
||||
requestsPerHour: parseInt(row.requests_per_hour, 10),
|
||||
requestsPerDay: parseInt(row.requests_per_day, 10),
|
||||
averageResponseTimeMs: Math.round(parseFloat(row.avg_response_time ?? '0')),
|
||||
errorRate: Math.round((parseFloat(row.error_rate ?? '0') * 100)),
|
||||
};
|
||||
}
|
||||
|
||||
async getRecentUsage(apiKeyId: ApiKeyId, limit: number): Promise<Array<{
|
||||
timestamp: Date;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
statusCode: number;
|
||||
responseTimeMs: number;
|
||||
}>> {
|
||||
const result = await this.pool.query(
|
||||
`SELECT timestamp, endpoint, method, status_code, response_time_ms
|
||||
FROM api_key_usage
|
||||
WHERE api_key_id = $1
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT $2`,
|
||||
[apiKeyId, limit]
|
||||
);
|
||||
|
||||
return result.rows.map(row => ({
|
||||
timestamp: row.timestamp as Date,
|
||||
endpoint: row.endpoint as string,
|
||||
method: row.method as string,
|
||||
statusCode: row.status_code as number,
|
||||
responseTimeMs: row.response_time_ms as number,
|
||||
}));
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
@@ -36,3 +36,20 @@ export {
|
||||
PostgresArtifactRegistry,
|
||||
PostgresConfig,
|
||||
} from './adapters/postgresql';
|
||||
|
||||
// API Key repositories
|
||||
export {
|
||||
ApiKeyRepository,
|
||||
ApiKeyUsageRepository,
|
||||
} from './repositories/api-key-repository';
|
||||
|
||||
export {
|
||||
PostgresApiKeyRepository,
|
||||
PostgresApiKeyUsageRepository,
|
||||
PostgresApiKeyConfig,
|
||||
} from './adapters/postgresql/postgres-api-key-repository';
|
||||
|
||||
export {
|
||||
InMemoryApiKeyRepository,
|
||||
InMemoryApiKeyUsageRepository,
|
||||
} from './adapters/in-memory-api-key-repository';
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* API Key Repository Interface
|
||||
*
|
||||
* Defines WHAT the domain needs from API key persistence.
|
||||
* Infrastructure adapters implement HOW.
|
||||
*/
|
||||
import { ApiKey, ApiKeyId } from '@landvex/domain';
|
||||
export interface ApiKeyRepository {
|
||||
save(apiKey: ApiKey): Promise<void>;
|
||||
findById(id: ApiKeyId): Promise<ApiKey | null>;
|
||||
findByKeyHash(keyHash: string): Promise<ApiKey | null>;
|
||||
findAll(options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
status?: string;
|
||||
}): Promise<ApiKey[]>;
|
||||
count(options?: {
|
||||
status?: string;
|
||||
}): Promise<number>;
|
||||
update(apiKey: ApiKey): Promise<void>;
|
||||
delete(id: ApiKeyId): Promise<void>;
|
||||
}
|
||||
export interface ApiKeyUsageRepository {
|
||||
recordUsage(usage: {
|
||||
apiKeyId: ApiKeyId;
|
||||
timestamp: Date;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
statusCode: number;
|
||||
responseTimeMs: number;
|
||||
clientIp?: string;
|
||||
}): Promise<void>;
|
||||
getUsageStats(apiKeyId: ApiKeyId, since: Date): Promise<{
|
||||
totalRequests: number;
|
||||
requestsPerMinute: number;
|
||||
requestsPerHour: number;
|
||||
requestsPerDay: number;
|
||||
averageResponseTimeMs: number;
|
||||
errorRate: number;
|
||||
}>;
|
||||
getRecentUsage(apiKeyId: ApiKeyId, limit: number): Promise<Array<{
|
||||
timestamp: Date;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
statusCode: number;
|
||||
responseTimeMs: number;
|
||||
}>>;
|
||||
}
|
||||
//# sourceMappingURL=api-key-repository.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"api-key-repository.d.ts","sourceRoot":"","sources":["api-key-repository.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAEnD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,QAAQ,CAAC,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/C,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACvD,OAAO,CAAC,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC3F,KAAK,CAAC,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACtD,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,MAAM,CAAC,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACrC;AAED,MAAM,WAAW,qBAAqB;IACpC,WAAW,CAAC,KAAK,EAAE;QACjB,QAAQ,EAAE,QAAQ,CAAC;QACnB,SAAS,EAAE,IAAI,CAAC;QAChB,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,EAAE,MAAM,CAAC;QACnB,cAAc,EAAE,MAAM,CAAC;QACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAElB,aAAa,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,IAAI,GAAG,OAAO,CAAC;QACtD,aAAa,EAAE,MAAM,CAAC;QACtB,iBAAiB,EAAE,MAAM,CAAC;QAC1B,eAAe,EAAE,MAAM,CAAC;QACxB,cAAc,EAAE,MAAM,CAAC;QACvB,qBAAqB,EAAE,MAAM,CAAC;QAC9B,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC,CAAC;IAEH,cAAc,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;QAC/D,SAAS,EAAE,IAAI,CAAC;QAChB,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,EAAE,MAAM,CAAC;QACnB,cAAc,EAAE,MAAM,CAAC;KACxB,CAAC,CAAC,CAAC;CACL"}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use strict";
|
||||
/**
|
||||
* API Key Repository Interface
|
||||
*
|
||||
* Defines WHAT the domain needs from API key persistence.
|
||||
* Infrastructure adapters implement HOW.
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
//# sourceMappingURL=api-key-repository.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"api-key-repository.js","sourceRoot":"","sources":["api-key-repository.ts"],"names":[],"mappings":";AAAA;;;;;GAKG"}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* API Key Repository Interface
|
||||
*
|
||||
* Defines WHAT the domain needs from API key persistence.
|
||||
* Infrastructure adapters implement HOW.
|
||||
*/
|
||||
|
||||
import { ApiKey, ApiKeyId } from '@landvex/domain';
|
||||
|
||||
export interface ApiKeyRepository {
|
||||
save(apiKey: ApiKey): Promise<void>;
|
||||
findById(id: ApiKeyId): Promise<ApiKey | null>;
|
||||
findByKeyHash(keyHash: string): Promise<ApiKey | null>;
|
||||
findAll(options?: { limit?: number; offset?: number; status?: string }): Promise<ApiKey[]>;
|
||||
count(options?: { status?: string }): Promise<number>;
|
||||
update(apiKey: ApiKey): Promise<void>;
|
||||
delete(id: ApiKeyId): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ApiKeyUsageRepository {
|
||||
recordUsage(usage: {
|
||||
apiKeyId: ApiKeyId;
|
||||
timestamp: Date;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
statusCode: number;
|
||||
responseTimeMs: number;
|
||||
clientIp?: string;
|
||||
}): Promise<void>;
|
||||
|
||||
getUsageStats(apiKeyId: ApiKeyId, since: Date): Promise<{
|
||||
totalRequests: number;
|
||||
requestsPerMinute: number;
|
||||
requestsPerHour: number;
|
||||
requestsPerDay: number;
|
||||
averageResponseTimeMs: number;
|
||||
errorRate: number;
|
||||
}>;
|
||||
|
||||
getRecentUsage(apiKeyId: ApiKeyId, limit: number): Promise<Array<{
|
||||
timestamp: Date;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
statusCode: number;
|
||||
responseTimeMs: number;
|
||||
}>>;
|
||||
}
|
||||
@@ -5,11 +5,13 @@
|
||||
|
||||
import http from 'http';
|
||||
import { URL } from 'url';
|
||||
import { createCustomer, createPaymentIntent, createSetupIntent, listPaymentMethods, createInvoice, createInvoiceItem, finalizeInvoice, handleWebhookEvent } from './stripe.mjs';
|
||||
|
||||
// In-memory store (replace with PostgreSQL)
|
||||
const customers = {};
|
||||
const watchAreas = {};
|
||||
const users = {};
|
||||
const invoices = {};
|
||||
|
||||
export function handleRequest(req, res) {
|
||||
// CORS
|
||||
@@ -50,6 +52,16 @@ export function handleRequest(req, res) {
|
||||
handleListAreas(req, res);
|
||||
} else if (path === '/api/v1/government/users' && req.method === 'POST') {
|
||||
handleInviteUser(req, res);
|
||||
} else if (path === '/api/v1/government/payment/setup' && req.method === 'POST') {
|
||||
handleSetupPayment(req, res);
|
||||
} else if (path === '/api/v1/government/payment/methods' && req.method === 'GET') {
|
||||
handleListPaymentMethods(req, res);
|
||||
} else if (path === '/api/v1/government/invoices' && req.method === 'POST') {
|
||||
handleCreateInvoice(req, res);
|
||||
} else if (path === '/api/v1/government/invoices' && req.method === 'GET') {
|
||||
handleListInvoices(req, res);
|
||||
} else if (path === '/webhooks/stripe' && req.method === 'POST') {
|
||||
handleStripeWebhook(req, res);
|
||||
} else {
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ error: 'Not Found', path }));
|
||||
@@ -189,3 +201,100 @@ function handleInviteUser(req, res) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Stripe payment endpoints
|
||||
async function handleSetupPayment(req, res) {
|
||||
try {
|
||||
const setupIntent = await createSetupIntent('cus_mock_001');
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
clientSecret: setupIntent.client_secret,
|
||||
setupIntentId: setupIntent.id,
|
||||
}));
|
||||
} catch (error) {
|
||||
res.statusCode = 500;
|
||||
res.end(JSON.stringify({
|
||||
error: 'Payment setup failed',
|
||||
message: error.message,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleListPaymentMethods(req, res) {
|
||||
try {
|
||||
const methods = await listPaymentMethods('cus_mock_001');
|
||||
res.end(JSON.stringify({
|
||||
methods: methods.data || [],
|
||||
count: methods.data?.length || 0,
|
||||
}));
|
||||
} catch (error) {
|
||||
res.statusCode = 500;
|
||||
res.end(JSON.stringify({
|
||||
error: 'Failed to list payment methods',
|
||||
message: error.message,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateInvoice(req, res) {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
|
||||
// Create invoice items
|
||||
for (const item of data.items || []) {
|
||||
await createInvoiceItem('cus_mock_001', item.amount, item.currency || 'usd', item.description);
|
||||
}
|
||||
|
||||
// Create and finalize invoice
|
||||
const invoice = await createInvoice('cus_mock_001');
|
||||
const finalized = await finalizeInvoice(invoice.id);
|
||||
|
||||
const customerId = 'gov-001';
|
||||
if (!invoices[customerId]) invoices[customerId] = [];
|
||||
invoices[customerId].push(finalized);
|
||||
|
||||
res.statusCode = 201;
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
invoice: finalized,
|
||||
}));
|
||||
} catch (error) {
|
||||
res.statusCode = 500;
|
||||
res.end(JSON.stringify({
|
||||
error: 'Invoice creation failed',
|
||||
message: error.message,
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleListInvoices(req, res) {
|
||||
const customerId = 'gov-001';
|
||||
const customerInvoices = invoices[customerId] || [];
|
||||
|
||||
res.end(JSON.stringify({
|
||||
invoices: customerInvoices,
|
||||
count: customerInvoices.length,
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleStripeWebhook(req, res) {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const event = JSON.parse(body);
|
||||
const result = await handleWebhookEvent(event);
|
||||
res.end(JSON.stringify(result));
|
||||
} catch (error) {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({
|
||||
error: 'Webhook processing failed',
|
||||
message: error.message,
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Stripe Integration for LandveX Operations
|
||||
*
|
||||
* Handles:
|
||||
* - Payment method setup (card, autogiro)
|
||||
* - Subscription billing
|
||||
* - Invoice generation
|
||||
* - Webhook handling
|
||||
*/
|
||||
|
||||
import https from 'https';
|
||||
|
||||
const STRIPE_API_KEY = process.env.STRIPE_SECRET_KEY;
|
||||
const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
function stripeRequest(method, path, data = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!STRIPE_API_KEY) {
|
||||
reject(new Error('Stripe not configured'));
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
hostname: 'api.stripe.com',
|
||||
port: 443,
|
||||
path: `/v1${path}`,
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${STRIPE_API_KEY}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let body = '';
|
||||
res.on('data', chunk => body += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(body);
|
||||
if (json.error) reject(json.error);
|
||||
else resolve(json);
|
||||
} catch (e) {
|
||||
resolve(body);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
|
||||
if (data) {
|
||||
const params = new URLSearchParams(data);
|
||||
req.write(params.toString());
|
||||
}
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Create a customer
|
||||
export async function createCustomer(email, name, metadata = {}) {
|
||||
return stripeRequest('POST', '/customers', {
|
||||
email,
|
||||
name,
|
||||
...Object.entries(metadata).reduce((acc, [k, v]) => ({ ...acc, [`metadata[${k}]`]: v }), {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Create a payment intent
|
||||
export async function createPaymentIntent(amount, currency, customerId, metadata = {}) {
|
||||
return stripeRequest('POST', '/payment_intents', {
|
||||
amount: Math.round(amount * 100), // Convert to cents
|
||||
currency: currency.toLowerCase(),
|
||||
customer: customerId,
|
||||
automatic_payment_methods: { enabled: true },
|
||||
...Object.entries(metadata).reduce((acc, [k, v]) => ({ ...acc, [`metadata[${k}]`]: v }), {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Create a subscription
|
||||
export async function createSubscription(customerId, priceId, metadata = {}) {
|
||||
return stripeRequest('POST', '/subscriptions', {
|
||||
customer: customerId,
|
||||
items: [{ price: priceId }],
|
||||
payment_behavior: 'default_incomplete',
|
||||
expand: ['latest_invoice.payment_intent'],
|
||||
...Object.entries(metadata).reduce((acc, [k, v]) => ({ ...acc, [`metadata[${k}]`]: v }), {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Create a setup intent for saving payment methods
|
||||
export async function createSetupIntent(customerId) {
|
||||
return stripeRequest('POST', '/setup_intents', {
|
||||
customer: customerId,
|
||||
usage: 'off_session',
|
||||
});
|
||||
}
|
||||
|
||||
// List payment methods for a customer
|
||||
export async function listPaymentMethods(customerId, type = 'card') {
|
||||
return stripeRequest('GET', `/customers/${customerId}/payment_methods?type=${type}`);
|
||||
}
|
||||
|
||||
// Create invoice
|
||||
export async function createInvoice(customerId, autoAdvance = true) {
|
||||
return stripeRequest('POST', '/invoices', {
|
||||
customer: customerId,
|
||||
auto_advance: autoAdvance,
|
||||
});
|
||||
}
|
||||
|
||||
// Add invoice item
|
||||
export async function createInvoiceItem(customerId, amount, currency, description) {
|
||||
return stripeRequest('POST', '/invoiceitems', {
|
||||
customer: customerId,
|
||||
amount: Math.round(amount * 100),
|
||||
currency: currency.toLowerCase(),
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
// Finalize and send invoice
|
||||
export async function finalizeInvoice(invoiceId) {
|
||||
return stripeRequest('POST', `/invoices/${invoiceId}/finalize`);
|
||||
}
|
||||
|
||||
// Verify webhook signature
|
||||
export function verifyWebhookSignature(payload, signature) {
|
||||
// In production, use Stripe's official library
|
||||
// This is a simplified version
|
||||
if (!STRIPE_WEBHOOK_SECRET) {
|
||||
return { valid: false, error: 'Webhook secret not configured' };
|
||||
}
|
||||
|
||||
// For now, accept all webhooks in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
return { valid: false, error: 'Webhook verification not implemented' };
|
||||
}
|
||||
|
||||
// Handle webhook events
|
||||
export async function handleWebhookEvent(event) {
|
||||
switch (event.type) {
|
||||
case 'payment_intent.succeeded':
|
||||
console.log('Payment succeeded:', event.data.object.id);
|
||||
return { status: 'success', paymentIntent: event.data.object.id };
|
||||
|
||||
case 'payment_intent.payment_failed':
|
||||
console.log('Payment failed:', event.data.object.id);
|
||||
return { status: 'failed', paymentIntent: event.data.object.id, error: event.data.object.last_payment_error };
|
||||
|
||||
case 'invoice.paid':
|
||||
console.log('Invoice paid:', event.data.object.id);
|
||||
return { status: 'paid', invoice: event.data.object.id };
|
||||
|
||||
case 'invoice.payment_failed':
|
||||
console.log('Invoice payment failed:', event.data.object.id);
|
||||
return { status: 'failed', invoice: event.data.object.id };
|
||||
|
||||
case 'customer.subscription.created':
|
||||
console.log('Subscription created:', event.data.object.id);
|
||||
return { status: 'created', subscription: event.data.object.id };
|
||||
|
||||
case 'customer.subscription.deleted':
|
||||
console.log('Subscription cancelled:', event.data.object.id);
|
||||
return { status: 'cancelled', subscription: event.data.object.id };
|
||||
|
||||
default:
|
||||
console.log('Unhandled event:', event.type);
|
||||
return { status: 'unhandled', type: event.type };
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Stripe module loaded (API key:', STRIPE_API_KEY ? 'configured' : 'NOT CONFIGURED', ')');
|
||||
@@ -8,6 +8,7 @@ import HealthDashboard from './pages/HealthDashboard';
|
||||
import PilotChecklist from './pages/PilotChecklist';
|
||||
import ReadinessDashboard from './pages/ReadinessDashboard';
|
||||
import KillList from './pages/KillList';
|
||||
import GovernmentPortal from './pages/GovernmentPortal';
|
||||
|
||||
function App() {
|
||||
const [developerMode, setDeveloperMode] = useState(false);
|
||||
@@ -68,6 +69,7 @@ function App() {
|
||||
<Link to="/pilot" onClick={() => setMenuOpen(false)} style={{ padding: '8px 0', textDecoration: 'none', color: '#333' }}>✅ Pilot</Link>
|
||||
<Link to="/readiness" onClick={() => setMenuOpen(false)} style={{ padding: '8px 0', textDecoration: 'none', color: '#333' }}>📋 Ready</Link>
|
||||
<Link to="/kill" onClick={() => setMenuOpen(false)} style={{ padding: '8px 0', textDecoration: 'none', color: '#333' }}>🔥 Kill</Link>
|
||||
<Link to="/government" onClick={() => setMenuOpen(false)} style={{ padding: '8px 0', textDecoration: 'none', color: '#333' }}>🏛️ Gov</Link>
|
||||
{developerMode && (
|
||||
<>
|
||||
<Link to="/datasets" onClick={() => setMenuOpen(false)} style={{ padding: '8px 0', textDecoration: 'none', color: '#666' }}>🗂️ Data</Link>
|
||||
@@ -88,6 +90,7 @@ function App() {
|
||||
<Route path="/pilot" element={<PilotChecklist />} />
|
||||
<Route path="/readiness" element={<ReadinessDashboard />} />
|
||||
<Route path="/kill" element={<KillList />} />
|
||||
<Route path="/government" element={<GovernmentPortal />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface GovernmentProfile {
|
||||
name: string;
|
||||
orgNumber: string;
|
||||
address: string;
|
||||
contactEmail: string;
|
||||
contactPhone: string;
|
||||
}
|
||||
|
||||
interface BudgetOverview {
|
||||
monthlyLimit: number;
|
||||
yearlyLimit: number;
|
||||
currentSpend: number;
|
||||
forecast: number;
|
||||
alerts: string[];
|
||||
}
|
||||
|
||||
interface PaymentSettings {
|
||||
method: 'card' | 'invoice' | 'autogiro';
|
||||
cardLast4?: string;
|
||||
invoiceAddress?: string;
|
||||
invoiceReference?: string;
|
||||
}
|
||||
|
||||
const API_BASE = (import.meta as any).env?.VITE_API_URL || 'https://pilot.landvex.com';
|
||||
|
||||
export default function GovernmentPortal() {
|
||||
const [activeTab, setActiveTab] = useState<'profile' | 'budget' | 'payment' | 'areas' | 'users'>('profile');
|
||||
const [profile, setProfile] = useState<GovernmentProfile | null>(null);
|
||||
const [budget, setBudget] = useState<BudgetOverview | null>(null);
|
||||
const [payment, setPayment] = useState<PaymentSettings>({ method: 'invoice' });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfile();
|
||||
}, []);
|
||||
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/government/me`);
|
||||
if (!res.ok) throw new Error('Failed to load profile');
|
||||
const data = await res.json();
|
||||
setProfile(data);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBudget = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/government/budget`);
|
||||
if (!res.ok) throw new Error('Failed to load budget');
|
||||
const data = await res.json();
|
||||
setBudget(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
const updatePayment = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/government/payment`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payment),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update payment');
|
||||
alert('Payment settings updated');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ padding: 40, textAlign: 'center' }}>Loading...</div>;
|
||||
if (error) return <div style={{ padding: 40, color: '#ef4444' }}>Error: {error}</div>;
|
||||
|
||||
const tabs = [
|
||||
{ id: 'profile' as const, label: 'Profile' },
|
||||
{ id: 'budget' as const, label: 'Budget' },
|
||||
{ id: 'payment' as const, label: 'Payment' },
|
||||
{ id: 'areas' as const, label: 'Watch Areas' },
|
||||
{ id: 'users' as const, label: 'Users' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 800, margin: '0 auto', padding: 24 }}>
|
||||
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Government Portal</h1>
|
||||
<p style={{ color: '#64748b', marginBottom: 24 }}>
|
||||
Manage your organization's settings, budget, and users
|
||||
</p>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 24, borderBottom: '1px solid #e2e8f0' }}>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => {
|
||||
setActiveTab(tab.id);
|
||||
if (tab.id === 'budget') fetchBudget();
|
||||
}}
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
borderBottom: activeTab === tab.id ? '2px solid #3b82f6' : '2px solid transparent',
|
||||
color: activeTab === tab.id ? '#3b82f6' : '#64748b',
|
||||
fontWeight: activeTab === tab.id ? 600 : 400,
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Profile Tab */}
|
||||
{activeTab === 'profile' && profile && (
|
||||
<div style={{ background: '#f8fafc', padding: 24, borderRadius: 12 }}>
|
||||
<h2 style={{ fontSize: 20, marginBottom: 16 }}>Organization Profile</h2>
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, color: '#64748b', marginBottom: 4 }}>Organization Name</label>
|
||||
<div style={{ fontSize: 16, fontWeight: 600 }}>{profile.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, color: '#64748b', marginBottom: 4 }}>Organization Number</label>
|
||||
<div style={{ fontSize: 16, fontFamily: 'monospace' }}>{profile.orgNumber}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, color: '#64748b', marginBottom: 4 }}>Address</label>
|
||||
<div>{profile.address}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, color: '#64748b', marginBottom: 4 }}>Contact Email</label>
|
||||
<div>{profile.contactEmail}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, color: '#64748b', marginBottom: 4 }}>Contact Phone</label>
|
||||
<div>{profile.contactPhone}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Budget Tab */}
|
||||
{activeTab === 'budget' && (
|
||||
<div style={{ background: '#f8fafc', padding: 24, borderRadius: 12 }}>
|
||||
<h2 style={{ fontSize: 20, marginBottom: 16 }}>Budget Overview</h2>
|
||||
{budget ? (
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<div style={{ background: '#fff', padding: 16, borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 12, color: '#64748b' }}>Monthly Limit</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700 }}>${budget.monthlyLimit.toLocaleString()}</div>
|
||||
</div>
|
||||
<div style={{ background: '#fff', padding: 16, borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 12, color: '#64748b' }}>Yearly Limit</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700 }}>${budget.yearlyLimit.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ background: '#fff', padding: 16, borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 12, color: '#64748b' }}>Current Spend</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: budget.currentSpend > budget.monthlyLimit * 0.8 ? '#ef4444' : '#22c55e' }}>
|
||||
${budget.currentSpend.toLocaleString()}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, height: 8, background: '#e2e8f0', borderRadius: 4 }}>
|
||||
<div style={{
|
||||
width: `${Math.min((budget.currentSpend / budget.monthlyLimit) * 100, 100)}%`,
|
||||
height: '100%',
|
||||
background: budget.currentSpend > budget.monthlyLimit * 0.8 ? '#ef4444' : '#3b82f6',
|
||||
borderRadius: 4,
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
{budget.alerts.length > 0 && (
|
||||
<div style={{ background: '#fef3c7', padding: 16, borderRadius: 8 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Alerts</div>
|
||||
{budget.alerts.map((alert, i) => (
|
||||
<div key={i} style={{ fontSize: 14, color: '#92400e' }}>{alert}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>Loading budget...</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Tab */}
|
||||
{activeTab === 'payment' && (
|
||||
<div style={{ background: '#f8fafc', padding: 24, borderRadius: 12 }}>
|
||||
<h2 style={{ fontSize: 20, marginBottom: 16 }}>Payment Settings</h2>
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, color: '#64748b', marginBottom: 4 }}>Payment Method</label>
|
||||
<select
|
||||
value={payment.method}
|
||||
onChange={e => setPayment({ ...payment, method: e.target.value as any })}
|
||||
style={{ width: '100%', padding: 10, borderRadius: 6, border: '1px solid #e2e8f0' }}
|
||||
>
|
||||
<option value="card">Credit Card</option>
|
||||
<option value="invoice">Invoice</option>
|
||||
<option value="autogiro">Autogiro</option>
|
||||
</select>
|
||||
</div>
|
||||
{payment.method === 'invoice' && (
|
||||
<>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, color: '#64748b', marginBottom: 4 }}>Invoice Address</label>
|
||||
<textarea
|
||||
value={payment.invoiceAddress || ''}
|
||||
onChange={e => setPayment({ ...payment, invoiceAddress: e.target.value })}
|
||||
style={{ width: '100%', padding: 10, borderRadius: 6, border: '1px solid #e2e8f0', minHeight: 80 }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, color: '#64748b', marginBottom: 4 }}>Invoice Reference</label>
|
||||
<input
|
||||
type="text"
|
||||
value={payment.invoiceReference || ''}
|
||||
onChange={e => setPayment({ ...payment, invoiceReference: e.target.value })}
|
||||
style={{ width: '100%', padding: 10, borderRadius: 6, border: '1px solid #e2e8f0' }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={updatePayment}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: '#3b82f6',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Save Payment Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Watch Areas Tab */}
|
||||
{activeTab === 'areas' && (
|
||||
<div style={{ background: '#f8fafc', padding: 24, borderRadius: 12 }}>
|
||||
<h2 style={{ fontSize: 20, marginBottom: 16 }}>Watch Areas</h2>
|
||||
<p style={{ color: '#64748b' }}>Define geographic areas for automatic mission monitoring</p>
|
||||
<div style={{ marginTop: 16, padding: 40, textAlign: 'center', background: '#fff', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="1 6 1 22 8 18 16 22 21 18 21 2 16 6 8 2 1 6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="18"/>
|
||||
<line x1="16" y1="6" x2="16" y2="22"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p>Map integration coming soon</p>
|
||||
<p style={{ fontSize: 12, color: '#64748b' }}>Draw polygons on map to define watch areas</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Tab */}
|
||||
{activeTab === 'users' && (
|
||||
<div style={{ background: '#f8fafc', padding: 24, borderRadius: 12 }}>
|
||||
<h2 style={{ fontSize: 20, marginBottom: 16 }}>Users & Permissions</h2>
|
||||
<p style={{ color: '#64748b' }}>Manage team members and their access levels</p>
|
||||
<div style={{ marginTop: 16, padding: 40, textAlign: 'center', background: '#fff', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p>User management coming soon</p>
|
||||
<p style={{ fontSize: 12, color: '#64748b' }}>Invite users, assign roles, manage permissions</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user