@sip-protocol/api 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sip-protocol/api",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "REST API service for SIP Protocol SDK - Optional wrapper for non-JS backends",
5
5
  "author": "SIP Protocol <hello@sip-protocol.org>",
6
6
  "homepage": "https://sip-protocol.org",
@@ -19,24 +19,38 @@
19
19
  "src"
20
20
  ],
21
21
  "dependencies": {
22
- "@sip-protocol/sdk": "^0.6.0",
23
- "@sip-protocol/types": "^0.2.0",
22
+ "@noble/hashes": "^1.3.3",
23
+ "@sentry/node": "^10.56.0",
24
24
  "compression": "^1.8.1",
25
- "cors": "^2.8.5",
26
- "express": "^4.22.1",
27
- "helmet": "^7.2.0",
28
- "morgan": "^1.10.1",
29
- "zod": "^3.25.76"
25
+ "cors": "^2.8.6",
26
+ "envalid": "^8.1.1",
27
+ "express": "^5.2.1",
28
+ "express-rate-limit": "^8.5.2",
29
+ "helmet": "^8.2.0",
30
+ "ioredis": "^5.11.0",
31
+ "lru-cache": "^11.5.1",
32
+ "morgan": "^1.11.0",
33
+ "pino": "^10.3.1",
34
+ "pino-http": "^11.0.0",
35
+ "prom-client": "^15.1.3",
36
+ "rate-limit-redis": "^4.3.1",
37
+ "zod": "^4.4.3",
38
+ "@sip-protocol/sdk": "0.11.0",
39
+ "@sip-protocol/types": "0.2.2"
30
40
  },
31
41
  "devDependencies": {
32
42
  "@types/compression": "^1.8.1",
33
43
  "@types/cors": "^2.8.19",
34
- "@types/express": "^4.17.25",
44
+ "@types/express": "^5.0.6",
35
45
  "@types/morgan": "^1.9.10",
46
+ "@types/pino-http": "^6.1.0",
47
+ "@types/supertest": "^6.0.3",
48
+ "pino-pretty": "^13.1.3",
49
+ "supertest": "^7.2.2",
36
50
  "tsup": "^8.5.1",
37
- "tsx": "^4.21.0",
51
+ "tsx": "^4.22.4",
38
52
  "typescript": "^5.3.0",
39
- "vitest": "^1.1.0"
53
+ "vitest": "^4.1.0"
40
54
  },
41
55
  "keywords": [
42
56
  "sip",
package/src/config.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Environment Configuration with Validation
3
+ *
4
+ * Validates all environment variables at startup with clear error messages.
5
+ * Uses envalid for type-safe environment access.
6
+ */
7
+
8
+ import { cleanEnv, str, port, bool, num } from 'envalid'
9
+
10
+ /**
11
+ * Validated environment configuration
12
+ */
13
+ export const env = cleanEnv(process.env, {
14
+ // Server configuration
15
+ NODE_ENV: str({
16
+ choices: ['development', 'production', 'test'] as const,
17
+ default: 'development',
18
+ desc: 'Application environment',
19
+ }),
20
+ PORT: port({
21
+ default: 3000,
22
+ desc: 'Server port',
23
+ }),
24
+
25
+ // CORS configuration
26
+ CORS_ORIGINS: str({
27
+ default: '',
28
+ desc: 'Comma-separated list of allowed origins (empty = localhost only in dev)',
29
+ }),
30
+
31
+ // Authentication
32
+ API_KEYS: str({
33
+ default: '',
34
+ desc: 'Comma-separated list of valid API keys (empty = auth disabled in dev)',
35
+ }),
36
+
37
+ // Rate limiting
38
+ RATE_LIMIT_MAX: num({
39
+ default: 100,
40
+ desc: 'Maximum requests per window',
41
+ }),
42
+ RATE_LIMIT_WINDOW_MS: num({
43
+ default: 60000,
44
+ desc: 'Rate limit window in milliseconds',
45
+ }),
46
+
47
+ // Proxy trust configuration (for X-Forwarded-For header)
48
+ // Set to number of trusted proxies (e.g., 1 for single nginx)
49
+ // or 'loopback' for local proxies, or 'uniquelocal' for private IPs
50
+ TRUST_PROXY: str({
51
+ default: '1',
52
+ desc: 'Express trust proxy setting (number of hops, "loopback", "uniquelocal", or "false")',
53
+ }),
54
+
55
+ // Logging
56
+ LOG_LEVEL: str({
57
+ choices: ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const,
58
+ default: 'info',
59
+ desc: 'Logging level',
60
+ }),
61
+
62
+ // Graceful shutdown
63
+ SHUTDOWN_TIMEOUT_MS: num({
64
+ default: 30000,
65
+ desc: 'Graceful shutdown timeout in milliseconds',
66
+ }),
67
+
68
+ // Monitoring
69
+ SENTRY_DSN: str({
70
+ default: '',
71
+ desc: 'Sentry DSN for error tracking (optional)',
72
+ }),
73
+ METRICS_ENABLED: str({
74
+ choices: ['true', 'false'] as const,
75
+ default: 'true',
76
+ desc: 'Enable Prometheus metrics endpoint',
77
+ }),
78
+
79
+ // Webhook configuration
80
+ HELIUS_WEBHOOK_SECRET: str({
81
+ default: '',
82
+ desc: 'Helius webhook HMAC secret for signature verification (optional)',
83
+ }),
84
+ WEBHOOK_DELIVERY_MAX_RETRIES: num({
85
+ default: 3,
86
+ desc: 'Maximum delivery attempts for webhook notifications',
87
+ }),
88
+ WEBHOOK_STORE_MAX_SIZE: num({
89
+ default: 1000,
90
+ desc: 'Maximum number of registered webhooks',
91
+ }),
92
+ })
93
+
94
+ /**
95
+ * Log startup warnings for potentially insecure configurations
96
+ */
97
+ export function logConfigWarnings(logger: { warn: (msg: string) => void }): void {
98
+ if (env.isProduction) {
99
+ if (!env.API_KEYS) {
100
+ logger.warn('API_KEYS not set in production - authentication disabled')
101
+ }
102
+ if (!env.CORS_ORIGINS) {
103
+ logger.warn('CORS_ORIGINS not set in production - only localhost allowed')
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Check if running in production
110
+ */
111
+ export const isProduction = env.isProduction
112
+
113
+ /**
114
+ * Check if running in development
115
+ */
116
+ export const isDevelopment = env.isDevelopment
117
+
118
+ /**
119
+ * Check if running in test
120
+ */
121
+ export const isTest = env.isTest
package/src/logger.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Structured Logger
3
+ *
4
+ * Production-ready logging with pino.
5
+ * - JSON format in production for log aggregation
6
+ * - Pretty printed in development
7
+ * - Request ID tracking
8
+ * - Log levels configurable via LOG_LEVEL
9
+ */
10
+
11
+ import pino from 'pino'
12
+ import pinoHttp from 'pino-http'
13
+ import crypto from 'crypto'
14
+ import { env } from './config'
15
+
16
+ /**
17
+ * Base logger configuration
18
+ */
19
+ const loggerConfig: pino.LoggerOptions = {
20
+ level: env.LOG_LEVEL,
21
+ base: {
22
+ service: 'sip-api',
23
+ version: '0.1.0',
24
+ },
25
+ timestamp: pino.stdTimeFunctions.isoTime,
26
+ }
27
+
28
+ /**
29
+ * Add pretty printing in development
30
+ */
31
+ if (env.isDevelopment) {
32
+ loggerConfig.transport = {
33
+ target: 'pino-pretty',
34
+ options: {
35
+ colorize: true,
36
+ translateTime: 'HH:MM:ss',
37
+ ignore: 'pid,hostname,service,version',
38
+ },
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Main application logger
44
+ */
45
+ export const logger = pino(loggerConfig)
46
+
47
+ /**
48
+ * HTTP request logger middleware
49
+ *
50
+ * Automatically logs all incoming requests with:
51
+ * - Request ID (from header or generated)
52
+ * - Method and URL
53
+ * - Response status and time
54
+ * - Log level based on status code
55
+ */
56
+ export const requestLogger = pinoHttp({
57
+ logger,
58
+ // Use requestId from requestIdMiddleware (set earlier in chain)
59
+ // Falls back to header or generates new if middleware didn't run
60
+ genReqId: (req) => {
61
+ // Type assertion for augmented request
62
+ const augmentedReq = req as typeof req & { requestId?: string }
63
+ if (augmentedReq.requestId) {
64
+ return augmentedReq.requestId
65
+ }
66
+ // Fallback for direct logger usage (tests, etc.)
67
+ const existingId = req.headers['x-request-id']
68
+ if (existingId && typeof existingId === 'string') {
69
+ return existingId
70
+ }
71
+ return crypto.randomUUID()
72
+ },
73
+ customLogLevel: (_req, res, err) => {
74
+ if (res.statusCode >= 500 || err) return 'error'
75
+ if (res.statusCode >= 400) return 'warn'
76
+ return 'info'
77
+ },
78
+ customSuccessMessage: (req, res) => {
79
+ return `${req.method} ${req.url} ${res.statusCode}`
80
+ },
81
+ customErrorMessage: (req, res, err) => {
82
+ return `${req.method} ${req.url} ${res.statusCode} - ${err.message}`
83
+ },
84
+ // Don't log health checks in production
85
+ autoLogging: {
86
+ ignore: (req) => {
87
+ return req.url === '/api/v1/health' && env.isProduction
88
+ },
89
+ },
90
+ })
91
+
92
+ /**
93
+ * Create a child logger with additional context
94
+ */
95
+ export function createChildLogger(context: Record<string, unknown>) {
96
+ return logger.child(context)
97
+ }
98
+
99
+ export default logger
@@ -0,0 +1,128 @@
1
+ import { Request, Response, NextFunction } from 'express'
2
+ import { timingSafeEqual } from 'crypto'
3
+
4
+ /**
5
+ * API Key Authentication Middleware
6
+ *
7
+ * Environment variables:
8
+ * - API_KEYS: Comma-separated list of valid API keys
9
+ * - AUTH_ENABLED: Enable/disable authentication (default: true in production)
10
+ * - AUTH_SKIP_PATHS: Comma-separated paths to skip auth (default: /health,/)
11
+ *
12
+ * Headers:
13
+ * - X-API-Key: The API key to authenticate with
14
+ * - Authorization: Bearer <api-key> (alternative)
15
+ */
16
+
17
+ const API_KEYS = (process.env.API_KEYS || '').split(',').filter(Boolean)
18
+ const NODE_ENV = process.env.NODE_ENV || 'development'
19
+ const AUTH_ENABLED = process.env.AUTH_ENABLED !== 'false' && NODE_ENV === 'production'
20
+ const SKIP_PATHS = (process.env.AUTH_SKIP_PATHS || '/health,/,/webhooks/internal/helius').split(',').map(p => p.trim())
21
+
22
+ /**
23
+ * Timing-safe comparison of API keys
24
+ */
25
+ function safeCompare(a: string, b: string): boolean {
26
+ if (a.length !== b.length) {
27
+ return false
28
+ }
29
+ try {
30
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b))
31
+ } catch {
32
+ return false
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Check if a given API key is valid
38
+ */
39
+ function isValidApiKey(key: string): boolean {
40
+ return API_KEYS.some(validKey => safeCompare(key, validKey))
41
+ }
42
+
43
+ /**
44
+ * Extract API key from request headers
45
+ */
46
+ function extractApiKey(req: Request): string | null {
47
+ // Check X-API-Key header first
48
+ const apiKeyHeader = req.headers['x-api-key']
49
+ if (typeof apiKeyHeader === 'string' && apiKeyHeader) {
50
+ return apiKeyHeader
51
+ }
52
+
53
+ // Check Authorization header (Bearer token)
54
+ const authHeader = req.headers.authorization
55
+ if (authHeader?.startsWith('Bearer ')) {
56
+ return authHeader.slice(7)
57
+ }
58
+
59
+ return null
60
+ }
61
+
62
+ /**
63
+ * Authentication middleware
64
+ */
65
+ export function authenticate(req: Request, res: Response, next: NextFunction) {
66
+ // Skip auth in development if not explicitly enabled
67
+ if (!AUTH_ENABLED) {
68
+ return next()
69
+ }
70
+
71
+ // Skip auth for certain paths
72
+ const path = req.path.replace('/api/v1', '')
73
+ if (SKIP_PATHS.some(skipPath => path === skipPath || path.startsWith(skipPath + '/'))) {
74
+ return next()
75
+ }
76
+
77
+ // Check if API keys are configured
78
+ if (API_KEYS.length === 0) {
79
+ console.warn('[Auth] No API keys configured. Set API_KEYS environment variable.')
80
+ return res.status(500).json({
81
+ success: false,
82
+ error: {
83
+ code: 'AUTH_NOT_CONFIGURED',
84
+ message: 'Authentication is enabled but no API keys are configured',
85
+ },
86
+ })
87
+ }
88
+
89
+ // Extract and validate API key
90
+ const apiKey = extractApiKey(req)
91
+
92
+ if (!apiKey) {
93
+ return res.status(401).json({
94
+ success: false,
95
+ error: {
96
+ code: 'UNAUTHORIZED',
97
+ message: 'API key required. Provide via X-API-Key header or Authorization: Bearer <key>',
98
+ },
99
+ })
100
+ }
101
+
102
+ if (!isValidApiKey(apiKey)) {
103
+ return res.status(401).json({
104
+ success: false,
105
+ error: {
106
+ code: 'INVALID_API_KEY',
107
+ message: 'Invalid API key',
108
+ },
109
+ })
110
+ }
111
+
112
+ // API key is valid
113
+ next()
114
+ }
115
+
116
+ /**
117
+ * Check if authentication is enabled
118
+ */
119
+ export function isAuthEnabled(): boolean {
120
+ return AUTH_ENABLED
121
+ }
122
+
123
+ /**
124
+ * Get the number of configured API keys
125
+ */
126
+ export function getApiKeyCount(): number {
127
+ return API_KEYS.length
128
+ }
@@ -0,0 +1,163 @@
1
+ import cors, { CorsOptions } from 'cors'
2
+ import { Request, RequestHandler } from 'express'
3
+
4
+ /**
5
+ * CORS Configuration Middleware
6
+ *
7
+ * Environment variables:
8
+ * - CORS_ORIGINS: Comma-separated list of allowed origins (required in production)
9
+ * - CORS_CREDENTIALS: Allow credentials (default: true)
10
+ * - CORS_MAX_AGE: Preflight cache duration in seconds (default: 86400 = 24 hours)
11
+ *
12
+ * Security:
13
+ * - In production, CORS_ORIGINS must be explicitly set (no wildcards allowed)
14
+ * - In development, allows localhost origins by default
15
+ */
16
+
17
+ const NODE_ENV = process.env.NODE_ENV || 'development'
18
+ const CORS_ORIGINS = process.env.CORS_ORIGINS?.split(',').map(o => o.trim()).filter(Boolean) || []
19
+ const CORS_CREDENTIALS = process.env.CORS_CREDENTIALS !== 'false'
20
+ const CORS_MAX_AGE = parseInt(process.env.CORS_MAX_AGE || '86400', 10)
21
+
22
+ // Default development origins
23
+ const DEV_ORIGINS = [
24
+ 'http://localhost:3000',
25
+ 'http://localhost:3001',
26
+ 'http://localhost:4000',
27
+ 'http://localhost:5173', // Vite
28
+ 'http://127.0.0.1:3000',
29
+ 'http://127.0.0.1:3001',
30
+ 'http://127.0.0.1:4000',
31
+ 'http://127.0.0.1:5173',
32
+ ]
33
+
34
+ /**
35
+ * Get allowed origins based on environment
36
+ */
37
+ function getAllowedOrigins(): string[] {
38
+ if (CORS_ORIGINS.length > 0) {
39
+ return CORS_ORIGINS
40
+ }
41
+
42
+ if (NODE_ENV === 'development' || NODE_ENV === 'test') {
43
+ return DEV_ORIGINS
44
+ }
45
+
46
+ // Production with no origins configured - deny all
47
+ return []
48
+ }
49
+
50
+ /**
51
+ * Safely parse a URL, returning null if malformed
52
+ */
53
+ function safeParseUrl(url: string): URL | null {
54
+ try {
55
+ return new URL(url)
56
+ } catch {
57
+ return null
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Check if origin is allowed
63
+ *
64
+ * Security: Handles malformed URLs gracefully (denies instead of crashing)
65
+ * and enforces HTTPS in production mode for wildcard subdomain matches.
66
+ */
67
+ function isOriginAllowed(origin: string | undefined): boolean {
68
+ if (!origin) {
69
+ // Allow requests with no origin (same-origin, curl, etc.)
70
+ return true
71
+ }
72
+
73
+ const allowedOrigins = getAllowedOrigins()
74
+
75
+ // Check exact match
76
+ if (allowedOrigins.includes(origin)) {
77
+ return true
78
+ }
79
+
80
+ // Parse origin URL for wildcard matching
81
+ // Security: Wrap in try/catch to handle malformed URLs gracefully
82
+ let originUrl: URL
83
+ try {
84
+ originUrl = new URL(origin)
85
+ } catch {
86
+ // Malformed URL = denied (don't crash, just reject)
87
+ console.warn(`[CORS] Rejected malformed origin: ${origin}`)
88
+ return false
89
+ }
90
+
91
+ // Check wildcard subdomains (e.g., *.example.com)
92
+ for (const allowed of allowedOrigins) {
93
+ if (allowed.startsWith('*.')) {
94
+ const baseDomain = allowed.slice(2)
95
+ const originHost = originUrl.host
96
+
97
+ // Security: Enforce HTTPS in production for wildcard matches
98
+ // This prevents attackers from using HTTP to bypass HSTS
99
+ if (NODE_ENV === 'production' && originUrl.protocol !== 'https:') {
100
+ console.warn(`[CORS] Rejected non-HTTPS origin in production: ${origin}`)
101
+ continue
102
+ }
103
+
104
+ // Match base domain or subdomains
105
+ if (originHost === baseDomain || originHost.endsWith('.' + baseDomain)) {
106
+ return true
107
+ }
108
+ }
109
+ }
110
+
111
+ return false
112
+ }
113
+
114
+ /**
115
+ * Dynamic CORS options based on request origin
116
+ */
117
+ const corsOptionsDelegate = (req: Request, callback: (err: Error | null, options?: CorsOptions) => void) => {
118
+ const origin = req.headers.origin
119
+ const allowed = isOriginAllowed(origin)
120
+
121
+ const options: CorsOptions = {
122
+ origin: allowed ? origin : false,
123
+ credentials: CORS_CREDENTIALS,
124
+ maxAge: CORS_MAX_AGE,
125
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
126
+ allowedHeaders: [
127
+ 'Content-Type',
128
+ 'Authorization',
129
+ 'X-API-Key',
130
+ 'X-Request-ID',
131
+ 'X-Forwarded-For',
132
+ ],
133
+ exposedHeaders: [
134
+ 'RateLimit-Limit',
135
+ 'RateLimit-Remaining',
136
+ 'RateLimit-Reset',
137
+ 'X-Request-ID',
138
+ ],
139
+ }
140
+
141
+ if (!allowed && origin) {
142
+ console.warn(`[CORS] Blocked request from origin: ${origin}`)
143
+ }
144
+
145
+ callback(null, options)
146
+ }
147
+
148
+ /**
149
+ * Secure CORS middleware
150
+ */
151
+ export const secureCors: RequestHandler = cors(corsOptionsDelegate) as RequestHandler
152
+
153
+ /**
154
+ * Get current CORS configuration (for debugging/health checks)
155
+ */
156
+ export function getCorsConfig() {
157
+ return {
158
+ origins: getAllowedOrigins(),
159
+ credentials: CORS_CREDENTIALS,
160
+ maxAge: CORS_MAX_AGE,
161
+ environment: NODE_ENV,
162
+ }
163
+ }
@@ -1,5 +1,8 @@
1
1
  import { Request, Response, NextFunction } from 'express'
2
- import { SIPError, isSIPError } from '@sip-protocol/sdk'
2
+ import { SIPError, ValidationError, isSIPError } from '@sip-protocol/sdk'
3
+ import { logger } from '../logger'
4
+ import { env } from '../config'
5
+ import { captureException } from '../monitoring'
3
6
 
4
7
  /**
5
8
  * Global error handler middleware
@@ -10,37 +13,45 @@ export function errorHandler(
10
13
  res: Response,
11
14
  next: NextFunction
12
15
  ) {
13
- // Log error for debugging
14
- console.error('[API Error]', {
16
+ // Log error with structured logger
17
+ logger.error({
15
18
  path: req.path,
16
19
  method: req.method,
17
20
  error: err.message,
18
21
  stack: err.stack,
19
- })
22
+ }, 'API Error')
20
23
 
21
24
  // Handle SIP SDK errors
22
25
  if (isSIPError(err)) {
23
26
  const sipError = err as SIPError
27
+ // Check if it's a ValidationError with field property
28
+ const isValidationError = err instanceof ValidationError
24
29
  return res.status(400).json({
25
30
  success: false,
26
31
  error: {
27
32
  code: sipError.code,
28
33
  message: sipError.message,
29
34
  details: {
30
- field: (sipError as any).field,
31
- expected: (sipError as any).expected,
35
+ ...(isValidationError && { field: (err as ValidationError).field }),
36
+ ...sipError.context,
32
37
  },
33
38
  },
34
39
  })
35
40
  }
36
41
 
42
+ // Capture to Sentry (for 5xx errors)
43
+ captureException(err, {
44
+ path: req.path,
45
+ method: req.method,
46
+ })
47
+
37
48
  // Handle generic errors
38
49
  res.status(500).json({
39
50
  success: false,
40
51
  error: {
41
52
  code: 'INTERNAL_SERVER_ERROR',
42
53
  message: 'An unexpected error occurred',
43
- details: process.env.NODE_ENV === 'development' ? err.message : undefined,
54
+ details: env.isDevelopment ? err.message : undefined,
44
55
  },
45
56
  })
46
57
  }
@@ -1,2 +1,14 @@
1
1
  export { errorHandler, notFoundHandler } from './error-handler'
2
- export { validateRequest, schemas } from './validation'
2
+ export {
3
+ validateRequest,
4
+ schemas,
5
+ amountSchema,
6
+ minAmountSchema,
7
+ calculateMinAmount,
8
+ percentToBps,
9
+ MAX_UINT256
10
+ } from './validation'
11
+ export { rateLimiter, strictRateLimiter } from './rate-limit'
12
+ export { authenticate, isAuthEnabled, getApiKeyCount } from './auth'
13
+ export { secureCors, getCorsConfig } from './cors'
14
+ export { requestIdMiddleware } from './request-id'