@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/README.md +8 -0
- package/dist/routes/index.js +1164 -145
- package/dist/server.d.ts +29 -1
- package/dist/server.js +1514 -173
- package/package.json +25 -11
- package/src/config.ts +121 -0
- package/src/logger.ts +99 -0
- package/src/middleware/auth.ts +128 -0
- package/src/middleware/cors.ts +163 -0
- package/src/middleware/error-handler.ts +18 -7
- package/src/middleware/index.ts +13 -1
- package/src/middleware/rate-limit.ts +203 -0
- package/src/middleware/request-id.ts +66 -0
- package/src/middleware/validation.ts +100 -11
- package/src/monitoring/index.ts +36 -0
- package/src/monitoring/metrics.ts +163 -0
- package/src/monitoring/sentry.ts +179 -0
- package/src/routes/commitment.ts +1 -1
- package/src/routes/health.ts +35 -3
- package/src/routes/index.ts +2 -0
- package/src/routes/metrics.ts +27 -0
- package/src/routes/proof.ts +68 -1
- package/src/routes/swap.ts +116 -47
- package/src/routes/webhook.ts +156 -0
- package/src/server.ts +142 -19
- package/src/services/helius-listener.ts +104 -0
- package/src/services/index.ts +15 -0
- package/src/services/token-metadata.ts +91 -0
- package/src/services/webhook-delivery.ts +146 -0
- package/src/shutdown.ts +119 -0
- package/src/stores/index.ts +2 -0
- package/src/stores/swap-store.ts +158 -0
- package/src/stores/webhook-store.ts +120 -0
- package/src/types/api.ts +67 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sip-protocol/api",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
"@
|
|
23
|
-
"@
|
|
22
|
+
"@noble/hashes": "^1.3.3",
|
|
23
|
+
"@sentry/node": "^10.56.0",
|
|
24
24
|
"compression": "^1.8.1",
|
|
25
|
-
"cors": "^2.8.
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
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": "^
|
|
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.
|
|
51
|
+
"tsx": "^4.22.4",
|
|
38
52
|
"typescript": "^5.3.0",
|
|
39
|
-
"vitest": "^
|
|
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
|
|
14
|
-
|
|
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: (
|
|
31
|
-
|
|
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:
|
|
54
|
+
details: env.isDevelopment ? err.message : undefined,
|
|
44
55
|
},
|
|
45
56
|
})
|
|
46
57
|
}
|
package/src/middleware/index.ts
CHANGED
|
@@ -1,2 +1,14 @@
|
|
|
1
1
|
export { errorHandler, notFoundHandler } from './error-handler'
|
|
2
|
-
export {
|
|
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'
|