landvex: Fixar och tester klara för alla komponenter

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