diff --git a/.env.example b/.env.example index 8839be6..2dccd69 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,16 @@ VECTOR_DB_ENABLED=false # VECTOR_DB_TYPE=pinecone|weaviate|chroma # VECTOR_DB_API_KEY=your-api-key # VECTOR_DB_ENDPOINT=https://your-instance.vectordb.com + +# Database Configuration +# PostgreSQL connection string +# DATABASE_URL=postgresql://user:password@localhost:5432/dbname + +# API Key Configuration +# API_KEY_PREFIX=tltm +# API_KEY_PEPPER=your-secret-pepper-string + +# Webhook Configuration +# WEBHOOK_MAX_ATTEMPTS=3 +# WEBHOOK_BACKOFF_BASE_MS=1000 +# WEBHOOK_TIMEOUT_MS=5000 diff --git a/.eslintrc.js b/.eslintrc.js index ca30b14..c5bb2a5 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -15,5 +15,6 @@ module.exports = { '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-namespace': ['error', { allowDeclarations: true }], }, }; diff --git a/README.md b/README.md index 2139c92..8446c4b 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,12 @@ The project uses Zod for runtime configuration validation. Configuration is load - `PORT`: Server port (default: 3000) - `LOG_LEVEL`: Logging level (debug|info|warn|error, default: info) - `TELEMETRY_*`: Optional telemetry settings +- `DATABASE_URL`: PostgreSQL connection string (optional, required for API keys and webhooks) +- `API_KEY_PREFIX`: API key prefix (optional, default: tltm) +- `API_KEY_PEPPER`: API key pepper for additional security (optional) +- `WEBHOOK_MAX_ATTEMPTS`: Maximum webhook delivery attempts (optional, default: 3) +- `WEBHOOK_BACKOFF_BASE_MS`: Webhook retry backoff base in milliseconds (optional, default: 1000) +- `WEBHOOK_TIMEOUT_MS`: Webhook delivery timeout in milliseconds (optional, default: 5000) - `AI_*`: Optional AI/ML settings - `VECTOR_DB_*`: Optional vector database settings @@ -120,6 +126,260 @@ const span = tracer.startSpan('my-operation'); span.end(); ``` +## API Key Management + +### Overview + +The application includes a comprehensive API key management system with secure storage and authentication: + +- **Secure Storage**: API keys are hashed using Argon2 before storage +- **Bearer Token Authentication**: Standard HTTP Bearer token authentication +- **Scope-based Authorization**: Fine-grained access control using scopes +- **Audit Logging**: All key operations are logged for security auditing +- **Idempotency**: Support for idempotent operations using Idempotency-Key header + +### Database Setup + +Before using API keys and webhooks, you need to set up the PostgreSQL database: + +```bash +# Set DATABASE_URL in your .env file +DATABASE_URL=postgresql://user:password@localhost:5432/dbname + +# Run the database migration +psql $DATABASE_URL < migrations/001_initial_schema.sql +``` + +### API Endpoints + +#### Create API Key + +```bash +POST /api/keys +Content-Type: application/json +Idempotency-Key: + +{ + "name": "My API Key", + "owner": "user@example.com", + "scopes": ["read", "write"], + "prefix": "tltm", // optional, defaults to tltm + "pepper": "secret" // optional, for additional security +} + +Response: +{ + "id": "uuid", + "name": "My API Key", + "owner": "user@example.com", + "scopes": ["read", "write"], + "prefix": "tltm", + "token": "tltm_...", // Only returned once! + "created_at": "2024-01-01T00:00:00Z" +} +``` + +**Important**: The full token is only returned once during creation. Store it securely! If using an Idempotency-Key, the same response (including the token) will be returned for duplicate requests with that key within 24 hours. + +#### List API Keys + +```bash +GET /api/keys +Authorization: Bearer + +Response: +[ + { + "id": "uuid", + "name": "My API Key", + "owner": "user@example.com", + "scopes": ["read", "write"], + "prefix": "tltm", + "last_used_at": "2024-01-01T00:00:00Z", + "created_at": "2024-01-01T00:00:00Z" + } +] +``` + +#### Revoke API Key + +```bash +DELETE /api/keys/:id +Authorization: Bearer +Idempotency-Key: + +Response: +{ + "message": "API key revoked successfully" +} +``` + +## Webhook Management + +### Overview + +The webhook system enables real-time event notifications: + +- **Event-based Subscriptions**: Subscribe to specific event types +- **HMAC Signature Verification**: Secure webhook deliveries with SHA-256 signatures +- **Automatic Retries**: Configurable retry logic with exponential backoff +- **Delivery Tracking**: Track delivery status and response metrics +- **Replay Failed Deliveries**: Manually retry failed webhook deliveries + +### API Endpoints + +#### Create Webhook + +```bash +POST /api/webhooks +Authorization: Bearer +Content-Type: application/json + +{ + "url": "https://example.com/webhook", + "events": ["user.created", "user.updated"], + "secret": "optional-webhook-secret", // auto-generated if not provided + "active": true // optional, defaults to true +} + +Response: +{ + "id": "uuid", + "owner": "user@example.com", + "url": "https://example.com/webhook", + "events": ["user.created", "user.updated"], + "secret": "webhook-secret", + "active": true, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" +} +``` + +#### List Webhooks + +```bash +GET /api/webhooks +Authorization: Bearer + +Response: +[ + { + "id": "uuid", + "owner": "user@example.com", + "url": "https://example.com/webhook", + "events": ["user.created", "user.updated"], + "active": true, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + } +] +``` + +#### Delete Webhook + +```bash +DELETE /api/webhooks/:id +Authorization: Bearer + +Response: +{ + "message": "Webhook deleted successfully" +} +``` + +#### Test Webhook + +```bash +POST /api/webhooks/test +Authorization: Bearer + +Response: +{ + "message": "Test delivery sent to 2 webhook(s)", + "deliveries": [ + { + "id": "uuid", + "webhook_id": "uuid", + "status": "success", + "attempts": 1 + } + ] +} +``` + +#### Replay Failed Delivery + +```bash +POST /api/webhooks/:id/replay?delivery_id= +Authorization: Bearer + +Response: +{ + "message": "Delivery replayed", + "delivery": { + "id": "uuid", + "webhook_id": "uuid", + "event_type": "user.created", + "status": "success", + "attempts": 1 + } +} +``` + +### Webhook Signature Verification + +All webhook deliveries include an `X-Signature` header with HMAC SHA-256 signature: + +```javascript +// Verify webhook signature in your webhook handler +const crypto = require('crypto'); + +function verifyWebhookSignature(payload, signature, secret) { + const hmac = crypto.createHmac('sha256', secret); + hmac.update(payload); + const expectedSignature = `sha256=${hmac.digest('hex')}`; + + return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature)); +} + +// In your webhook endpoint +app.post('/webhook', (req, res) => { + const signature = req.headers['x-signature']; + const payload = JSON.stringify(req.body); + const secret = 'your-webhook-secret'; + + if (!verifyWebhookSignature(payload, signature, secret)) { + return res.status(401).json({ error: 'Invalid signature' }); + } + + // Process webhook event + res.status(200).json({ received: true }); +}); +``` + +### Webhook Headers + +Each webhook delivery includes the following headers: + +- `Content-Type`: application/json +- `X-Signature`: sha256= +- `X-Delivery-ID`: +- `X-Event-Type`: + +## Health Check + +The application includes a health check endpoint: + +```bash +GET /health + +Response: +{ + "status": "ok", + "timestamp": "2024-01-01T00:00:00Z" +} +``` + ## Development ### Building diff --git a/migrations/001_initial_schema.sql b/migrations/001_initial_schema.sql new file mode 100644 index 0000000..cace8f2 --- /dev/null +++ b/migrations/001_initial_schema.sql @@ -0,0 +1,80 @@ +-- Initial database schema for API keys and webhooks + +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- API Keys table +CREATE TABLE IF NOT EXISTS api_keys ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + owner VARCHAR(255) NOT NULL, + scopes TEXT[] NOT NULL DEFAULT '{}', + hashed_secret TEXT NOT NULL, + prefix VARCHAR(50) NOT NULL, + last_used_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + revoked_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT api_keys_name_owner_unique UNIQUE (name, owner) +); + +-- Index for faster lookups by prefix +CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(prefix); + +-- Index for faster lookups by owner +CREATE INDEX IF NOT EXISTS idx_api_keys_owner ON api_keys(owner); + +-- Webhooks table +-- Note: Webhook secrets are stored in plaintext as they must be retrievable for HMAC signature generation. +-- In production environments with strict security requirements, consider implementing encryption at rest. +CREATE TABLE IF NOT EXISTS webhooks ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + owner VARCHAR(255) NOT NULL, + url TEXT NOT NULL, + events TEXT[] NOT NULL DEFAULT '{}', + secret VARCHAR(255) NOT NULL, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Index for faster lookups by owner +CREATE INDEX IF NOT EXISTS idx_webhooks_owner ON webhooks(owner); + +-- Index for faster lookups by active status +CREATE INDEX IF NOT EXISTS idx_webhooks_active ON webhooks(active); + +-- Webhook Deliveries table +CREATE TABLE IF NOT EXISTS webhook_deliveries ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + webhook_id UUID NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE, + event_type VARCHAR(255) NOT NULL, + status VARCHAR(50) NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + response_code INTEGER, + response_ms INTEGER, + payload_digest VARCHAR(255), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_attempt_at TIMESTAMP WITH TIME ZONE +); + +-- Index for faster lookups by webhook_id +CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook_id ON webhook_deliveries(webhook_id); + +-- Index for faster lookups by status +CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_status ON webhook_deliveries(status); + +-- Index for faster lookups by event_type +CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_event_type ON webhook_deliveries(event_type); + +-- Function to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Trigger to automatically update updated_at on webhooks table +CREATE TRIGGER update_webhooks_updated_at BEFORE UPDATE ON webhooks + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/package.json b/package.json index 61f276d..dd11bc1 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,9 @@ "author": "", "license": "MIT", "devDependencies": { + "@types/express": "^5.0.6", "@types/node": "^20.10.0", + "@types/pg": "^8.16.0", "@typescript-eslint/eslint-plugin": "^6.13.0", "@typescript-eslint/parser": "^6.13.0", "eslint": "^8.54.0", @@ -34,18 +36,23 @@ }, "dependencies": { "@opentelemetry/api": "^1.7.0", - "@opentelemetry/sdk-node": "^0.45.0", "@opentelemetry/auto-instrumentations-node": "^0.40.0", "@opentelemetry/resources": "^1.18.0", + "@opentelemetry/sdk-node": "^0.45.0", "@opentelemetry/semantic-conventions": "^1.18.0", + "argon2": "^0.44.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0", + "pg": "^8.18.0", "zod": "^3.22.0" }, "optionalDependencies": { + "@anchordotdev/anchor": "^0.1.0", "@huggingface/transformers": "^2.6.0", - "pinecone-client": "^1.1.0", - "weaviate-ts-client": "^1.4.0", "chromadb": "^1.7.0", - "@anchordotdev/anchor": "^0.1.0" + "pinecone-client": "^1.1.0", + "weaviate-ts-client": "^1.4.0" }, "lint-staged": { "*.{ts,js}": [ diff --git a/src/config/index.ts b/src/config/index.ts index 61c983c..7e09206 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -38,6 +38,30 @@ export const ConfigSchema = z.object({ endpoint: z.string().url().optional(), }) .optional(), + + // Database settings + database: z + .object({ + url: z.string().optional(), + }) + .optional(), + + // API key settings + apiKey: z + .object({ + prefix: z.string().default('tltm'), + pepper: z.string().optional(), + }) + .optional(), + + // Webhook settings + webhook: z + .object({ + maxAttempts: z.coerce.number().int().positive().default(3), + backoffBaseMs: z.coerce.number().int().positive().default(1000), + timeoutMs: z.coerce.number().int().positive().default(5000), + }) + .optional(), }); export type Config = z.infer; @@ -70,6 +94,18 @@ export function loadConfig(): Config { apiKey: process.env.VECTOR_DB_API_KEY, endpoint: process.env.VECTOR_DB_ENDPOINT, }, + database: { + url: process.env.DATABASE_URL, + }, + apiKey: { + prefix: process.env.API_KEY_PREFIX, + pepper: process.env.API_KEY_PEPPER, + }, + webhook: { + maxAttempts: process.env.WEBHOOK_MAX_ATTEMPTS, + backoffBaseMs: process.env.WEBHOOK_BACKOFF_BASE_MS, + timeoutMs: process.env.WEBHOOK_TIMEOUT_MS, + }, }; // Parse and validate configuration diff --git a/src/database/index.ts b/src/database/index.ts new file mode 100644 index 0000000..7f75b4a --- /dev/null +++ b/src/database/index.ts @@ -0,0 +1,79 @@ +import { Pool, PoolClient, QueryResult, QueryResultRow } from 'pg'; +import { createLogger } from '../telemetry'; + +const logger = createLogger('database'); + +let pool: Pool | null = null; + +/** + * Initialize the database connection pool + */ +export function initDatabase(connectionString: string): void { + if (pool) { + logger.warn('Database pool already initialized'); + return; + } + + pool = new Pool({ + connectionString, + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, + }); + + pool.on('error', (err) => { + logger.error('Unexpected error on idle client', err); + }); + + logger.info('Database pool initialized'); +} + +/** + * Get the database pool + */ +export function getPool(): Pool { + if (!pool) { + throw new Error('Database not initialized. Call initDatabase() first.'); + } + return pool; +} + +/** + * Execute a query + */ +export async function query( + text: string, + params?: unknown[] +): Promise> { + const pool = getPool(); + const start = Date.now(); + try { + const result = await pool.query(text, params); + const duration = Date.now() - start; + // Only log query structure, not parameters to avoid exposing sensitive data + logger.debug('Executed query', { duration, rows: result.rowCount }); + return result; + } catch (error) { + logger.error('Query error', error instanceof Error ? error : undefined); + throw error; + } +} + +/** + * Get a client from the pool for transactions + */ +export async function getClient(): Promise { + const pool = getPool(); + return pool.connect(); +} + +/** + * Close the database pool + */ +export async function closeDatabase(): Promise { + if (pool) { + await pool.end(); + pool = null; + logger.info('Database pool closed'); + } +} diff --git a/src/index.ts b/src/index.ts index 3b36e69..99d17da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,8 @@ import { loadConfig } from './config'; import { initTelemetry, createLogger, shutdownTelemetry } from './telemetry'; +import { initDatabase, closeDatabase } from './database'; +import { createApp, startServer } from './server'; const logger = createLogger('main'); @@ -27,10 +29,21 @@ async function main() { }); } - logger.info('Application started successfully'); + // Initialize database if configured + if (config.database?.url) { + logger.info('Initializing database...'); + initDatabase(config.database.url); + logger.info('Database initialized successfully'); + } else { + logger.warn('Database URL not configured. Database-dependent features will not work.'); + } - // Your application logic here - // ... + // Create and start Express server + logger.info('Starting HTTP server...'); + const app = createApp(); + startServer(app, config.app.port); + + logger.info('Application started successfully'); } catch (error) { logger.error('Application failed to start', error instanceof Error ? error : undefined); process.exit(1); @@ -40,12 +53,14 @@ async function main() { // Graceful shutdown process.on('SIGTERM', async () => { logger.info('SIGTERM received, shutting down gracefully...'); + await closeDatabase(); await shutdownTelemetry(); process.exit(0); }); process.on('SIGINT', async () => { logger.info('SIGINT received, shutting down gracefully...'); + await closeDatabase(); await shutdownTelemetry(); process.exit(0); }); diff --git a/src/middleware/auth.middleware.ts b/src/middleware/auth.middleware.ts new file mode 100644 index 0000000..ff3000b --- /dev/null +++ b/src/middleware/auth.middleware.ts @@ -0,0 +1,103 @@ +import { Request, Response, NextFunction } from 'express'; +import { verifyApiKey, hasScope, ApiKey } from '../services/api-key.service'; +import { createLogger } from '../telemetry'; + +const logger = createLogger('auth-middleware'); + +// Extend Express Request type to include apiKey +// eslint-disable-next-line @typescript-eslint/no-namespace +declare global { + namespace Express { + interface Request { + apiKey?: ApiKey; + } + } +} + +export interface AuthOptions { + pepper?: string; + requiredScopes?: string[]; +} + +/** + * Middleware to authenticate API key from Bearer token + */ +export function authenticate(options: AuthOptions = {}) { + return async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ error: 'Missing or invalid authorization header' }); + return; + } + + const token = authHeader.substring(7); // Remove 'Bearer ' prefix + + const apiKey = await verifyApiKey(token, options.pepper); + + if (!apiKey) { + logger.warn('Invalid API key attempt', { + ip: req.ip, + path: req.path, + }); + res.status(401).json({ error: 'Invalid API key' }); + return; + } + + // Check scopes if required + if (options.requiredScopes && options.requiredScopes.length > 0) { + const hasRequiredScopes = options.requiredScopes.every((scope) => hasScope(apiKey, scope)); + + if (!hasRequiredScopes) { + logger.warn('Insufficient scopes', { + owner: apiKey.owner, + required: options.requiredScopes, + actual: apiKey.scopes, + }); + res.status(403).json({ error: 'Insufficient permissions' }); + return; + } + } + + // Attach API key to request + req.apiKey = apiKey; + + logger.debug('API key authenticated', { + owner: apiKey.owner, + scopes: apiKey.scopes, + }); + + next(); + } catch (error) { + logger.error('Authentication error', error instanceof Error ? error : undefined); + res.status(500).json({ error: 'Internal server error' }); + } + }; +} + +/** + * Middleware to check for specific scopes + */ +export function requireScopes(...scopes: string[]) { + return (req: Request, res: Response, next: NextFunction): void => { + if (!req.apiKey) { + res.status(401).json({ error: 'Not authenticated' }); + return; + } + + const hasRequiredScopes = scopes.every((scope) => hasScope(req.apiKey!, scope)); + + if (!hasRequiredScopes) { + logger.warn('Insufficient scopes', { + owner: req.apiKey.owner, + required: scopes, + actual: req.apiKey.scopes, + }); + res.status(403).json({ error: 'Insufficient permissions' }); + return; + } + + next(); + }; +} diff --git a/src/routes/api-keys.routes.ts b/src/routes/api-keys.routes.ts new file mode 100644 index 0000000..e4bc545 --- /dev/null +++ b/src/routes/api-keys.routes.ts @@ -0,0 +1,154 @@ +import { Router, Request, Response } from 'express'; +import { authenticate } from '../middleware/auth.middleware'; +import { createApiKey, listApiKeys, revokeApiKey } from '../services/api-key.service'; +import { createLogger } from '../telemetry'; + +const logger = createLogger('api-key-routes'); +const router = Router(); + +// Store for idempotency keys (in production, use Redis or database) +// WARNING: This in-memory implementation will lose data on server restart +// and won't work in multi-instance deployments +const idempotencyStore = new Map(); + +// Periodic cleanup of expired idempotency keys to prevent memory leaks +setInterval(() => { + // In production, implement proper expiration tracking in Redis/database + logger.debug('Idempotency store size', { size: idempotencyStore.size }); +}, 3600000); // Log size every hour + +/** + * POST /api/keys - Create a new API key + * Note: In production, this endpoint should require authentication or have stricter rate limiting + */ +router.post('/', async (req: Request, res: Response): Promise => { + try { + const { name, owner, scopes, prefix, pepper } = req.body; + + // Validate required fields + if (!name || !owner || !scopes) { + res.status(400).json({ error: 'Missing required fields: name, owner, scopes' }); + return; + } + + if (!Array.isArray(scopes)) { + res.status(400).json({ error: 'scopes must be an array' }); + return; + } + + // Handle idempotency + const idempotencyKey = req.headers['idempotency-key'] as string; + if (idempotencyKey) { + const cachedResponse = idempotencyStore.get(idempotencyKey); + if (cachedResponse) { + logger.debug('Returning cached response for idempotency key', { idempotencyKey }); + res.status(200).json(cachedResponse); + return; + } + } + + const apiKey = await createApiKey({ name, owner, scopes, prefix, pepper }); + + // Audit log + logger.info('API key created via endpoint', { + id: apiKey.id, + name: apiKey.name, + owner: apiKey.owner, + scopes: apiKey.scopes, + }); + + const response = { + id: apiKey.id, + name: apiKey.name, + owner: apiKey.owner, + scopes: apiKey.scopes, + prefix: apiKey.prefix, + token: apiKey.token, // Only returned once + created_at: apiKey.created_at, + }; + + // Store for idempotency + if (idempotencyKey) { + idempotencyStore.set(idempotencyKey, response); + // Clean up after 24 hours + setTimeout(() => idempotencyStore.delete(idempotencyKey), 24 * 60 * 60 * 1000); + } + + res.status(201).json(response); + } catch (error) { + logger.error('Error creating API key', error instanceof Error ? error : undefined); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /api/keys - List API keys + */ +router.get('/', authenticate(), async (req: Request, res: Response): Promise => { + try { + const owner = req.apiKey!.owner; + const apiKeys = await listApiKeys(owner); + + const response = apiKeys.map((key) => ({ + id: key.id, + name: key.name, + owner: key.owner, + scopes: key.scopes, + prefix: key.prefix, + last_used_at: key.last_used_at, + created_at: key.created_at, + })); + + res.status(200).json(response); + } catch (error) { + logger.error('Error listing API keys', error instanceof Error ? error : undefined); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * DELETE /api/keys/:id - Revoke an API key + */ +router.delete('/:id', authenticate(), async (req: Request, res: Response): Promise => { + try { + const id = req.params.id as string; + const owner = req.apiKey!.owner; + + // Handle idempotency + const idempotencyKey = req.headers['idempotency-key'] as string; + if (idempotencyKey) { + const cachedResponse = idempotencyStore.get(idempotencyKey); + if (cachedResponse) { + logger.debug('Returning cached response for idempotency key', { idempotencyKey }); + res.status(200).json(cachedResponse); + return; + } + } + + const success = await revokeApiKey(id, owner); + + if (!success) { + res.status(404).json({ error: 'API key not found or already revoked' }); + return; + } + + // Audit log + logger.info('API key revoked via endpoint', { id, owner }); + + const response = { message: 'API key revoked successfully' }; + + // Store for idempotency + if (idempotencyKey) { + idempotencyStore.set(idempotencyKey, response); + // Clean up after 24 hours + setTimeout(() => idempotencyStore.delete(idempotencyKey), 24 * 60 * 60 * 1000); + } + + res.status(200).json(response); + } catch (error) { + logger.error('Error revoking API key', error instanceof Error ? error : undefined); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; diff --git a/src/routes/webhooks.routes.ts b/src/routes/webhooks.routes.ts new file mode 100644 index 0000000..969eb3e --- /dev/null +++ b/src/routes/webhooks.routes.ts @@ -0,0 +1,259 @@ +import { Router, Request, Response } from 'express'; +import { authenticate } from '../middleware/auth.middleware'; +import { + createWebhook, + listWebhooks, + deleteWebhook, + getWebhook, + deliverWebhook, + getDelivery, +} from '../services/webhook.service'; +import { createLogger } from '../telemetry'; +import { loadConfig } from '../config'; + +const logger = createLogger('webhook-routes'); +const router = Router(); + +/** + * POST /api/webhooks - Create a new webhook + */ +router.post('/', authenticate(), async (req: Request, res: Response): Promise => { + try { + const { url, events, secret, active } = req.body; + const owner = req.apiKey!.owner; + + // Validate required fields + if (!url || !events) { + res.status(400).json({ error: 'Missing required fields: url, events' }); + return; + } + + if (!Array.isArray(events)) { + res.status(400).json({ error: 'events must be an array' }); + return; + } + + const webhook = await createWebhook({ + owner, + url, + events, + secret, + active, + }); + + // Audit log + logger.info('Webhook created via endpoint', { + id: webhook.id, + owner: webhook.owner, + url: webhook.url, + events: webhook.events, + }); + + res.status(201).json({ + id: webhook.id, + owner: webhook.owner, + url: webhook.url, + events: webhook.events, + secret: webhook.secret, + active: webhook.active, + created_at: webhook.created_at, + updated_at: webhook.updated_at, + }); + } catch (error) { + logger.error('Error creating webhook', error instanceof Error ? error : undefined); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /api/webhooks - List webhooks + */ +router.get('/', authenticate(), async (req: Request, res: Response): Promise => { + try { + const owner = req.apiKey!.owner; + const webhooks = await listWebhooks(owner); + + res.status(200).json( + webhooks.map((webhook) => ({ + id: webhook.id, + owner: webhook.owner, + url: webhook.url, + events: webhook.events, + active: webhook.active, + created_at: webhook.created_at, + updated_at: webhook.updated_at, + })) + ); + } catch (error) { + logger.error('Error listing webhooks', error instanceof Error ? error : undefined); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * DELETE /api/webhooks/:id - Delete a webhook + */ +router.delete('/:id', authenticate(), async (req: Request, res: Response): Promise => { + try { + const id = req.params.id as string; + const owner = req.apiKey!.owner; + + const success = await deleteWebhook(id, owner); + + if (!success) { + res.status(404).json({ error: 'Webhook not found' }); + return; + } + + // Audit log + logger.info('Webhook deleted via endpoint', { id, owner }); + + res.status(200).json({ message: 'Webhook deleted successfully' }); + } catch (error) { + logger.error('Error deleting webhook', error instanceof Error ? error : undefined); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * POST /api/webhooks/test - Send a test webhook delivery + */ +router.post('/test', authenticate(), async (req: Request, res: Response): Promise => { + try { + const config = loadConfig(); + const deliveryConfig = { + maxAttempts: config.webhook?.maxAttempts || 3, + backoffBaseMs: config.webhook?.backoffBaseMs || 1000, + timeoutMs: config.webhook?.timeoutMs || 5000, + }; + + const owner = req.apiKey!.owner; + const webhooks = await listWebhooks(owner); + + if (webhooks.length === 0) { + res.status(404).json({ error: 'No webhooks found' }); + return; + } + + const testPayload = { + event: 'delivery.test', + timestamp: new Date().toISOString(), + data: { + message: 'This is a test webhook delivery', + }, + }; + + const deliveries = await Promise.all( + webhooks + .filter((w) => w.active) + .map((webhook) => deliverWebhook(webhook, 'delivery.test', testPayload, deliveryConfig)) + ); + + // Audit log + logger.info('Test webhooks sent via endpoint', { + owner, + count: deliveries.length, + }); + + res.status(200).json({ + message: `Test delivery sent to ${deliveries.length} webhook(s)`, + deliveries: deliveries.map((d) => ({ + id: d.id, + webhook_id: d.webhook_id, + status: d.status, + attempts: d.attempts, + })), + }); + } catch (error) { + logger.error('Error sending test webhook', error instanceof Error ? error : undefined); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * POST /api/webhooks/:id/replay - Replay a failed delivery + */ +router.post('/:id/replay', authenticate(), async (req: Request, res: Response): Promise => { + try { + const id = req.params.id as string; + const { delivery_id } = req.query; + const owner = req.apiKey!.owner; + + if (!delivery_id || typeof delivery_id !== 'string') { + res.status(400).json({ error: 'Missing or invalid delivery_id query parameter' }); + return; + } + + // Get the webhook + const webhook = await getWebhook(id, owner); + if (!webhook) { + res.status(404).json({ error: 'Webhook not found' }); + return; + } + + // Get the delivery + const delivery = await getDelivery(delivery_id); + if (!delivery) { + res.status(404).json({ error: 'Delivery not found' }); + return; + } + + if (delivery.webhook_id !== webhook.id) { + res.status(400).json({ error: 'Delivery does not belong to this webhook' }); + return; + } + + if (delivery.status === 'success') { + res.status(400).json({ error: 'Cannot replay successful delivery' }); + return; + } + + const config = loadConfig(); + const deliveryConfig = { + maxAttempts: config.webhook?.maxAttempts || 3, + backoffBaseMs: config.webhook?.backoffBaseMs || 1000, + timeoutMs: config.webhook?.timeoutMs || 5000, + }; + + // Replay with a generic payload since we don't store the original + const replayPayload = { + event: delivery.event_type, + timestamp: new Date().toISOString(), + data: { + message: 'Replayed delivery', + original_delivery_id: delivery.id, + }, + }; + + const newDelivery = await deliverWebhook( + webhook, + delivery.event_type, + replayPayload, + deliveryConfig + ); + + // Audit log + logger.info('Webhook delivery replayed via endpoint', { + webhookId: webhook.id, + owner, + originalDeliveryId: delivery.id, + newDeliveryId: newDelivery.id, + }); + + res.status(200).json({ + message: 'Delivery replayed', + delivery: { + id: newDelivery.id, + webhook_id: newDelivery.webhook_id, + event_type: newDelivery.event_type, + status: newDelivery.status, + attempts: newDelivery.attempts, + }, + }); + } catch (error) { + logger.error('Error replaying webhook delivery', error instanceof Error ? error : undefined); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..6fb0df8 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,64 @@ +import express, { Application, Request, Response } from 'express'; +import helmet from 'helmet'; +import rateLimit from 'express-rate-limit'; +import apiKeysRoutes from '../routes/api-keys.routes'; +import webhooksRoutes from '../routes/webhooks.routes'; +import { createLogger } from '../telemetry'; + +const logger = createLogger('server'); + +/** + * Create and configure Express application + */ +export function createApp(): Application { + const app = express(); + + // Security middleware + app.use(helmet()); + + // JSON body parser + app.use(express.json()); + + // Rate limiting for auth endpoints + const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // limit each IP to 100 requests per windowMs + message: 'Too many requests from this IP, please try again later', + standardHeaders: true, + legacyHeaders: false, + }); + + // Health check endpoint + app.get('/health', (req: Request, res: Response) => { + res.status(200).json({ + status: 'ok', + timestamp: new Date().toISOString(), + }); + }); + + // API routes with rate limiting + app.use('/api/keys', authLimiter, apiKeysRoutes); + app.use('/api/webhooks', authLimiter, webhooksRoutes); + + // 404 handler + app.use((req: Request, res: Response) => { + res.status(404).json({ error: 'Not found' }); + }); + + // Error handler + app.use((err: Error, req: Request, res: Response, _next: express.NextFunction) => { + logger.error('Unhandled error', err); + res.status(500).json({ error: 'Internal server error' }); + }); + + return app; +} + +/** + * Start the Express server + */ +export function startServer(app: Application, port: number): void { + app.listen(port, () => { + logger.info(`Server listening on port ${port}`); + }); +} diff --git a/src/services/api-key.service.ts b/src/services/api-key.service.ts new file mode 100644 index 0000000..c95efb1 --- /dev/null +++ b/src/services/api-key.service.ts @@ -0,0 +1,185 @@ +import * as crypto from 'crypto'; +import * as argon2 from 'argon2'; +import { query } from '../database'; +import { createLogger } from '../telemetry'; + +const logger = createLogger('api-key-service'); + +export interface ApiKey { + id: string; + name: string; + owner: string; + scopes: string[]; + prefix: string; + last_used_at: Date | null; + created_at: Date; + revoked_at: Date | null; +} + +export interface ApiKeyCreateParams { + name: string; + owner: string; + scopes: string[]; + prefix?: string; + pepper?: string; +} + +export interface ApiKeyWithToken extends ApiKey { + token: string; +} + +/** + * Generate a secure random API key + */ +function generateApiKey(prefix: string): { token: string; secret: string } { + const secret = crypto.randomBytes(32).toString('hex'); + const token = `${prefix}_${secret}`; + return { token, secret }; +} + +/** + * Hash the API key secret using argon2 + */ +async function hashSecret(secret: string, pepper?: string): Promise { + const value = pepper ? `${secret}${pepper}` : secret; + return argon2.hash(value, { + type: argon2.argon2id, + memoryCost: 65536, // 64 MB + timeCost: 3, + parallelism: 4, + }); +} + +/** + * Verify an API key secret + */ +async function verifySecret( + hashedSecret: string, + secret: string, + pepper?: string +): Promise { + try { + const value = pepper ? `${secret}${pepper}` : secret; + return await argon2.verify(hashedSecret, value); + } catch (error) { + logger.error('Error verifying secret', error instanceof Error ? error : undefined); + return false; + } +} + +/** + * Create a new API key + */ +export async function createApiKey(params: ApiKeyCreateParams): Promise { + const prefix = params.prefix || 'tltm'; + const { token, secret } = generateApiKey(prefix); + const hashedSecret = await hashSecret(secret, params.pepper); + + const result = await query( + `INSERT INTO api_keys (name, owner, scopes, hashed_secret, prefix) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, name, owner, scopes, prefix, last_used_at, created_at, revoked_at`, + [params.name, params.owner, params.scopes, hashedSecret, prefix] + ); + + const apiKey = result.rows[0]; + + logger.info('API key created', { + id: apiKey.id, + name: apiKey.name, + owner: apiKey.owner, + prefix: apiKey.prefix, + }); + + return { + ...apiKey, + token, + }; +} + +/** + * List API keys for an owner + */ +export async function listApiKeys(owner: string): Promise { + const result = await query( + `SELECT id, name, owner, scopes, prefix, last_used_at, created_at, revoked_at + FROM api_keys + WHERE owner = $1 AND revoked_at IS NULL + ORDER BY created_at DESC`, + [owner] + ); + + return result.rows; +} + +/** + * Revoke an API key + */ +export async function revokeApiKey(id: string, owner: string): Promise { + const result = await query( + `UPDATE api_keys + SET revoked_at = CURRENT_TIMESTAMP + WHERE id = $1 AND owner = $2 AND revoked_at IS NULL`, + [id, owner] + ); + + if (result.rowCount && result.rowCount > 0) { + logger.info('API key revoked', { id, owner }); + return true; + } + + return false; +} + +/** + * Verify an API key token and return the API key if valid + */ +export async function verifyApiKey(token: string, pepper?: string): Promise { + // Parse the token to extract prefix and secret + const parts = token.split('_'); + if (parts.length < 2) { + return null; + } + + const prefix = parts[0]; + const secret = parts.slice(1).join('_'); + + // Find API keys with matching prefix + const result = await query( + `SELECT id, name, owner, scopes, prefix, hashed_secret, last_used_at, created_at, revoked_at + FROM api_keys + WHERE prefix = $1 AND revoked_at IS NULL`, + [prefix] + ); + + // Try to verify the secret against each key with matching prefix + // Note: This sequential verification can leak timing information about the number of keys. + // For production, consider limiting the number of keys per prefix or using constant-time verification. + for (const row of result.rows) { + const isValid = await verifySecret(row.hashed_secret, secret, pepper); + if (isValid) { + // Update last_used_at + await query(`UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = $1`, [row.id]); + + logger.debug('API key verified', { + id: row.id, + owner: row.owner, + prefix: row.prefix, + }); + + // Return without the hashed_secret + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { hashed_secret, ...apiKey } = row; + return apiKey; + } + } + + return null; +} + +/** + * Check if an API key has the required scope + */ +export function hasScope(apiKey: ApiKey, requiredScope: string): boolean { + return apiKey.scopes.includes(requiredScope) || apiKey.scopes.includes('*'); +} diff --git a/src/services/webhook.service.ts b/src/services/webhook.service.ts new file mode 100644 index 0000000..e1c7d8f --- /dev/null +++ b/src/services/webhook.service.ts @@ -0,0 +1,307 @@ +import * as crypto from 'crypto'; +import { query } from '../database'; +import { createLogger } from '../telemetry'; + +const logger = createLogger('webhook-service'); + +export interface Webhook { + id: string; + owner: string; + url: string; + events: string[]; + secret: string; + active: boolean; + created_at: Date; + updated_at: Date; +} + +export interface WebhookDelivery { + id: string; + webhook_id: string; + event_type: string; + status: string; + attempts: number; + response_code: number | null; + response_ms: number | null; + payload_digest: string | null; + created_at: Date; + last_attempt_at: Date | null; +} + +export interface WebhookCreateParams { + owner: string; + url: string; + events: string[]; + secret?: string; + active?: boolean; +} + +export interface DeliveryConfig { + maxAttempts: number; + backoffBaseMs: number; + timeoutMs: number; +} + +/** + * Generate a secure webhook secret + */ +function generateWebhookSecret(): string { + return crypto.randomBytes(32).toString('hex'); +} + +/** + * Create HMAC signature for webhook payload + */ +export function createWebhookSignature(payload: string, secret: string): string { + const hmac = crypto.createHmac('sha256', secret); + hmac.update(payload); + return `sha256=${hmac.digest('hex')}`; +} + +/** + * Verify webhook signature + */ +export function verifyWebhookSignature( + payload: string, + signature: string, + secret: string +): boolean { + const expectedSignature = createWebhookSignature(payload, secret); + return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature)); +} + +/** + * Create a new webhook + */ +export async function createWebhook(params: WebhookCreateParams): Promise { + const secret = params.secret || generateWebhookSecret(); + const active = params.active !== undefined ? params.active : true; + + const result = await query( + `INSERT INTO webhooks (owner, url, events, secret, active) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, owner, url, events, secret, active, created_at, updated_at`, + [params.owner, params.url, params.events, secret, active] + ); + + const webhook = result.rows[0]; + + logger.info('Webhook created', { + id: webhook.id, + owner: webhook.owner, + url: webhook.url, + events: webhook.events, + }); + + return webhook; +} + +/** + * List webhooks for an owner + */ +export async function listWebhooks(owner: string): Promise { + const result = await query( + `SELECT id, owner, url, events, secret, active, created_at, updated_at + FROM webhooks + WHERE owner = $1 + ORDER BY created_at DESC`, + [owner] + ); + + return result.rows; +} + +/** + * Get a webhook by ID + */ +export async function getWebhook(id: string, owner: string): Promise { + const result = await query( + `SELECT id, owner, url, events, secret, active, created_at, updated_at + FROM webhooks + WHERE id = $1 AND owner = $2`, + [id, owner] + ); + + return result.rows[0] || null; +} + +/** + * Delete a webhook + */ +export async function deleteWebhook(id: string, owner: string): Promise { + const result = await query(`DELETE FROM webhooks WHERE id = $1 AND owner = $2`, [id, owner]); + + if (result.rowCount && result.rowCount > 0) { + logger.info('Webhook deleted', { id, owner }); + return true; + } + + return false; +} + +/** + * Create a webhook delivery record + */ +export async function createDelivery( + webhookId: string, + eventType: string, + payloadDigest: string +): Promise { + const result = await query( + `INSERT INTO webhook_deliveries (webhook_id, event_type, status, payload_digest) + VALUES ($1, $2, $3, $4) + RETURNING id, webhook_id, event_type, status, attempts, response_code, response_ms, payload_digest, created_at, last_attempt_at`, + [webhookId, eventType, 'pending', payloadDigest] + ); + + return result.rows[0]; +} + +/** + * Update delivery status + */ +export async function updateDeliveryStatus( + deliveryId: string, + status: string, + responseCode?: number, + responseMs?: number +): Promise { + await query( + `UPDATE webhook_deliveries + SET status = $1, + attempts = attempts + 1, + response_code = $2, + response_ms = $3, + last_attempt_at = CURRENT_TIMESTAMP + WHERE id = $4`, + [status, responseCode, responseMs, deliveryId] + ); +} + +/** + * Get delivery by ID + */ +export async function getDelivery(deliveryId: string): Promise { + const result = await query( + `SELECT id, webhook_id, event_type, status, attempts, response_code, response_ms, payload_digest, created_at, last_attempt_at + FROM webhook_deliveries + WHERE id = $1`, + [deliveryId] + ); + + return result.rows[0] || null; +} + +/** + * Deliver a webhook + */ +export async function deliverWebhook( + webhook: Webhook, + eventType: string, + payload: Record, + config: DeliveryConfig +): Promise { + const payloadString = JSON.stringify(payload); + const payloadDigest = crypto.createHash('sha256').update(payloadString).digest('hex'); + const signature = createWebhookSignature(payloadString, webhook.secret); + + const delivery = await createDelivery(webhook.id, eventType, payloadDigest); + + let lastError: Error | null = null; + let attempt = 0; + + while (attempt < config.maxAttempts) { + attempt++; + const startTime = Date.now(); + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.timeoutMs); + + const response = await fetch(webhook.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Signature': signature, + 'X-Delivery-ID': delivery.id, + 'X-Event-Type': eventType, + }, + body: payloadString, + signal: controller.signal, + }); + + clearTimeout(timeout); + + const responseMs = Date.now() - startTime; + + if (response.ok) { + await updateDeliveryStatus(delivery.id, 'success', response.status, responseMs); + logger.info('Webhook delivered successfully', { + webhookId: webhook.id, + deliveryId: delivery.id, + eventType, + status: response.status, + responseMs, + }); + return (await getDelivery(delivery.id)) as WebhookDelivery; + } else { + lastError = new Error(`HTTP ${response.status}: ${response.statusText}`); + await updateDeliveryStatus(delivery.id, 'failed', response.status, responseMs); + } + } catch (error) { + const responseMs = Date.now() - startTime; + lastError = error instanceof Error ? error : new Error('Unknown error'); + await updateDeliveryStatus(delivery.id, 'failed', undefined, responseMs); + + logger.warn('Webhook delivery attempt failed', { + webhookId: webhook.id, + deliveryId: delivery.id, + attempt, + error: lastError.message, + }); + } + + // Exponential backoff + if (attempt < config.maxAttempts) { + const backoffMs = config.backoffBaseMs * Math.pow(2, attempt - 1); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + } + } + + logger.error('Webhook delivery failed after all attempts', lastError || undefined, { + webhookId: webhook.id, + deliveryId: delivery.id, + eventType, + attempts: config.maxAttempts, + }); + + return (await getDelivery(delivery.id)) as WebhookDelivery; +} + +/** + * Trigger webhooks for an event + */ +export async function triggerWebhooks( + eventType: string, + payload: Record, + config: DeliveryConfig +): Promise { + const result = await query( + `SELECT id, owner, url, events, secret, active, created_at, updated_at + FROM webhooks + WHERE active = true AND $1 = ANY(events)`, + [eventType] + ); + + const webhooks = result.rows; + + logger.info('Triggering webhooks', { + eventType, + count: webhooks.length, + }); + + // Deliver webhooks in parallel + // Note: In production, consider implementing a concurrency limit or queue-based + // delivery system to prevent resource exhaustion with many webhooks + await Promise.all(webhooks.map((webhook) => deliverWebhook(webhook, eventType, payload, config))); +} diff --git a/test/webhook.test.js b/test/webhook.test.js new file mode 100644 index 0000000..197259c --- /dev/null +++ b/test/webhook.test.js @@ -0,0 +1,48 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { + createWebhookSignature, + verifyWebhookSignature, +} from '../dist/services/webhook.service.js'; + +test('webhook signature - creates valid HMAC SHA-256 signature', () => { + const payload = JSON.stringify({ event: 'test', data: { foo: 'bar' } }); + const secret = 'test-secret'; + + const signature = createWebhookSignature(payload, secret); + + assert.ok(signature.startsWith('sha256=')); + assert.strictEqual(signature.length, 71); // 'sha256=' + 64 hex chars +}); + +test('webhook signature - verifies valid signature', () => { + const payload = JSON.stringify({ event: 'test', data: { foo: 'bar' } }); + const secret = 'test-secret'; + + const signature = createWebhookSignature(payload, secret); + const isValid = verifyWebhookSignature(payload, signature, secret); + + assert.strictEqual(isValid, true); +}); + +test('webhook signature - rejects invalid signature', () => { + const payload = JSON.stringify({ event: 'test', data: { foo: 'bar' } }); + const secret = 'test-secret'; + const wrongSecret = 'wrong-secret'; + + const signature = createWebhookSignature(payload, wrongSecret); + const isValid = verifyWebhookSignature(payload, signature, secret); + + assert.strictEqual(isValid, false); +}); + +test('webhook signature - rejects tampered payload', () => { + const payload = JSON.stringify({ event: 'test', data: { foo: 'bar' } }); + const tamperedPayload = JSON.stringify({ event: 'test', data: { foo: 'baz' } }); + const secret = 'test-secret'; + + const signature = createWebhookSignature(payload, secret); + const isValid = verifyWebhookSignature(tamperedPayload, signature, secret); + + assert.strictEqual(isValid, false); +});