ai-inference-stepper 1.0.0
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/.env.example +169 -0
- package/.eslintrc.cjs +23 -0
- package/.github/workflows/ci.yml +51 -0
- package/.github/workflows/keep-alive.yml +22 -0
- package/.github/workflows/publish.yml +34 -0
- package/ARCHITECTURE.md +594 -0
- package/Dockerfile +16 -0
- package/LICENSE +28 -0
- package/README.md +261 -0
- package/dist/alerts/discord.d.ts +19 -0
- package/dist/alerts/discord.d.ts.map +1 -0
- package/dist/alerts/discord.js +70 -0
- package/dist/alerts/discord.js.map +1 -0
- package/dist/cache/redisCache.d.ts +45 -0
- package/dist/cache/redisCache.d.ts.map +1 -0
- package/dist/cache/redisCache.js +171 -0
- package/dist/cache/redisCache.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +8 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +251 -0
- package/dist/config.js.map +1 -0
- package/dist/fallback/templateFallback.d.ts +7 -0
- package/dist/fallback/templateFallback.d.ts.map +1 -0
- package/dist/fallback/templateFallback.js +29 -0
- package/dist/fallback/templateFallback.js.map +1 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +198 -0
- package/dist/index.js.map +1 -0
- package/dist/logging.d.ts +10 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +44 -0
- package/dist/logging.js.map +1 -0
- package/dist/metrics/metrics.d.ts +22 -0
- package/dist/metrics/metrics.d.ts.map +1 -0
- package/dist/metrics/metrics.js +78 -0
- package/dist/metrics/metrics.js.map +1 -0
- package/dist/providers/factory.d.ts +11 -0
- package/dist/providers/factory.d.ts.map +1 -0
- package/dist/providers/factory.js +52 -0
- package/dist/providers/factory.js.map +1 -0
- package/dist/providers/hfSpace.adapter.d.ts +21 -0
- package/dist/providers/hfSpace.adapter.d.ts.map +1 -0
- package/dist/providers/hfSpace.adapter.js +110 -0
- package/dist/providers/hfSpace.adapter.js.map +1 -0
- package/dist/providers/httpTemplate.adapter.d.ts +42 -0
- package/dist/providers/httpTemplate.adapter.d.ts.map +1 -0
- package/dist/providers/httpTemplate.adapter.js +98 -0
- package/dist/providers/httpTemplate.adapter.js.map +1 -0
- package/dist/providers/promptBuilder.d.ts +34 -0
- package/dist/providers/promptBuilder.d.ts.map +1 -0
- package/dist/providers/promptBuilder.js +315 -0
- package/dist/providers/promptBuilder.js.map +1 -0
- package/dist/providers/provider.interface.d.ts +45 -0
- package/dist/providers/provider.interface.d.ts.map +1 -0
- package/dist/providers/provider.interface.js +47 -0
- package/dist/providers/provider.interface.js.map +1 -0
- package/dist/providers/specs.d.ts +18 -0
- package/dist/providers/specs.d.ts.map +1 -0
- package/dist/providers/specs.js +326 -0
- package/dist/providers/specs.js.map +1 -0
- package/dist/providers/unified.adapter.d.ts +37 -0
- package/dist/providers/unified.adapter.d.ts.map +1 -0
- package/dist/providers/unified.adapter.js +141 -0
- package/dist/providers/unified.adapter.js.map +1 -0
- package/dist/queue/producer.d.ts +30 -0
- package/dist/queue/producer.d.ts.map +1 -0
- package/dist/queue/producer.js +87 -0
- package/dist/queue/producer.js.map +1 -0
- package/dist/queue/worker.d.ts +9 -0
- package/dist/queue/worker.d.ts.map +1 -0
- package/dist/queue/worker.js +137 -0
- package/dist/queue/worker.js.map +1 -0
- package/dist/server/app.d.ts +4 -0
- package/dist/server/app.d.ts.map +1 -0
- package/dist/server/app.js +394 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/start.d.ts +16 -0
- package/dist/server/start.d.ts.map +1 -0
- package/dist/server/start.js +45 -0
- package/dist/server/start.js.map +1 -0
- package/dist/stepper/orchestrator.d.ts +22 -0
- package/dist/stepper/orchestrator.d.ts.map +1 -0
- package/dist/stepper/orchestrator.js +333 -0
- package/dist/stepper/orchestrator.js.map +1 -0
- package/dist/types.d.ts +216 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +14 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/redaction.d.ts +9 -0
- package/dist/utils/redaction.d.ts.map +1 -0
- package/dist/utils/redaction.js +41 -0
- package/dist/utils/redaction.js.map +1 -0
- package/dist/utils/safeRequest.d.ts +38 -0
- package/dist/utils/safeRequest.d.ts.map +1 -0
- package/dist/utils/safeRequest.js +104 -0
- package/dist/utils/safeRequest.js.map +1 -0
- package/dist/validation/report.schema.d.ts +48 -0
- package/dist/validation/report.schema.d.ts.map +1 -0
- package/dist/validation/report.schema.js +72 -0
- package/dist/validation/report.schema.js.map +1 -0
- package/dist/webhooks/delivery.d.ts +31 -0
- package/dist/webhooks/delivery.d.ts.map +1 -0
- package/dist/webhooks/delivery.js +102 -0
- package/dist/webhooks/delivery.js.map +1 -0
- package/docs/assets/architecture.png +0 -0
- package/package.json +75 -0
- package/render.yaml +25 -0
- package/src/alerts/README.md +25 -0
- package/src/alerts/discord.ts +86 -0
- package/src/cache/How redis caching works in package stepper.md +971 -0
- package/src/cache/README.md +51 -0
- package/src/cache/redisCache.ts +194 -0
- package/src/ci/deploy.sh +36 -0
- package/src/cli.ts +9 -0
- package/src/config.ts +265 -0
- package/src/fallback/templateFallback.ts +32 -0
- package/src/index.ts +246 -0
- package/src/logging.ts +46 -0
- package/src/metrics/README.md +24 -0
- package/src/metrics/metrics.ts +84 -0
- package/src/providers/How the providers interact.md +121 -0
- package/src/providers/README.md +121 -0
- package/src/providers/factory.ts +57 -0
- package/src/providers/hfSpace.adapter.ts +119 -0
- package/src/providers/httpTemplate.adapter.ts +138 -0
- package/src/providers/promptBuilder.ts +330 -0
- package/src/providers/provider.interface.ts +73 -0
- package/src/providers/specs.ts +366 -0
- package/src/providers/unified.adapter.ts +172 -0
- package/src/queue/How queue works in package stepper.md +149 -0
- package/src/queue/README.md +41 -0
- package/src/queue/producer.ts +108 -0
- package/src/queue/worker.ts +170 -0
- package/src/server/app.ts +451 -0
- package/src/server/start.ts +68 -0
- package/src/stepper/Dockerfile +48 -0
- package/src/stepper/How orchestrator works in package stepper.md +746 -0
- package/src/stepper/README.md +43 -0
- package/src/stepper/orchestrator.ts +437 -0
- package/src/types.ts +238 -0
- package/src/utils/redaction.ts +50 -0
- package/src/utils/safeRequest.ts +140 -0
- package/src/validation/README.md +25 -0
- package/src/validation/report.schema.ts +96 -0
- package/src/webhooks/delivery.ts +162 -0
- package/tests/integration/full-flow.test.ts +192 -0
- package/tests/unit/alerts/discord.test.ts +119 -0
- package/tests/unit/cache.test.ts +87 -0
- package/tests/unit/orchestrator-fallback.test.ts +92 -0
- package/tests/unit/orchestrator.test.ts +105 -0
- package/tests/unit/providers/factory.test.ts +161 -0
- package/tests/unit/providers/unified.adapter.test.ts +206 -0
- package/tests/unit/utils/redaction.test.ts +140 -0
- package/tests/unit/utils/safeRequest.test.ts +164 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
// packages/stepper/src/server/app.ts
|
|
2
|
+
|
|
3
|
+
import express, { Request, Response, NextFunction, Application } from 'express';
|
|
4
|
+
import cors from 'cors';
|
|
5
|
+
import helmet from 'helmet';
|
|
6
|
+
import rateLimit from 'express-rate-limit';
|
|
7
|
+
import { enqueueReport, generateReport, getJob, healthcheck, deleteReport, PromptInput } from '../index.js';
|
|
8
|
+
import { getMetrics } from '../metrics/metrics.js';
|
|
9
|
+
import { config } from '../config.js';
|
|
10
|
+
import { logger } from '../logging.js';
|
|
11
|
+
|
|
12
|
+
const app: Application = express();
|
|
13
|
+
|
|
14
|
+
// Trust proxy for proper IP detection behind reverse proxies (nginx, ELB, etc.)
|
|
15
|
+
// Set to 1 for single proxy, true for any proxy, or specific IPs for security
|
|
16
|
+
if (process.env.TRUST_PROXY) {
|
|
17
|
+
const trustProxy = process.env.TRUST_PROXY === 'true' ? true : parseInt(process.env.TRUST_PROXY, 10) || process.env.TRUST_PROXY;
|
|
18
|
+
app.set('trust proxy', trustProxy);
|
|
19
|
+
logger.info({ trustProxy }, 'Trust proxy configured');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 1. Helmet - Security headers (XSS protection, clickjacking prevention, etc.)
|
|
24
|
+
*/
|
|
25
|
+
if (config.security.helmet.enabled) {
|
|
26
|
+
app.use(helmet({
|
|
27
|
+
contentSecurityPolicy: {
|
|
28
|
+
directives: {
|
|
29
|
+
defaultSrc: ["'self'"],
|
|
30
|
+
scriptSrc: ["'self'"],
|
|
31
|
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
32
|
+
imgSrc: ["'self'", 'data:', 'https:'],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
crossOriginEmbedderPolicy: false, // Disable for API compatibility
|
|
36
|
+
}));
|
|
37
|
+
logger.info('Helmet security headers enabled');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 2. CORS - Cross-Origin Resource Sharing protection
|
|
42
|
+
*/
|
|
43
|
+
if (config.security.cors.enabled) {
|
|
44
|
+
const corsOptions: cors.CorsOptions = {
|
|
45
|
+
origin: (origin, callback) => {
|
|
46
|
+
const allowedOrigins = config.security.cors.allowedOrigins;
|
|
47
|
+
|
|
48
|
+
// Allow requests with no origin (like mobile apps or curl)
|
|
49
|
+
if (!origin) {
|
|
50
|
+
return callback(null, true);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// If wildcard is allowed, accept all origins
|
|
54
|
+
if (allowedOrigins.includes('*')) {
|
|
55
|
+
return callback(null, true);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Check if origin is in allowed list
|
|
59
|
+
if (allowedOrigins.includes(origin)) {
|
|
60
|
+
return callback(null, true);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Origin not allowed
|
|
64
|
+
logger.warn({ origin }, 'CORS: Origin not allowed');
|
|
65
|
+
return callback(new Error('Not allowed by CORS'), false);
|
|
66
|
+
},
|
|
67
|
+
credentials: config.security.cors.allowCredentials,
|
|
68
|
+
methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
|
69
|
+
allowedHeaders: ['Content-Type', 'Authorization', 'x-api-key', 'X-Request-ID'],
|
|
70
|
+
maxAge: 86400, // Cache preflight for 24 hours
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
app.use(cors(corsOptions));
|
|
74
|
+
logger.info({ origins: config.security.cors.allowedOrigins }, 'CORS protection enabled');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
// 3. Rate Limiting - Prevent abuse and DDoS attacks
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
// Store for user-based rate limiting (in-memory, consider Redis for multi-instance)
|
|
82
|
+
const userRequestCounts = new Map<string, { count: number; resetTime: number }>();
|
|
83
|
+
|
|
84
|
+
// IP-based rate limiter
|
|
85
|
+
if (config.security.rateLimit.enabled) {
|
|
86
|
+
const ipRateLimiter = rateLimit({
|
|
87
|
+
windowMs: config.security.rateLimit.windowMs,
|
|
88
|
+
max: config.security.rateLimit.maxRequests,
|
|
89
|
+
standardHeaders: true, // Return rate limit info in headers
|
|
90
|
+
legacyHeaders: false, // Disable X-RateLimit headers
|
|
91
|
+
skip: (req) => {
|
|
92
|
+
// Skip rate limiting for health endpoints if configured
|
|
93
|
+
if (config.security.rateLimit.skipHealthEndpoints) {
|
|
94
|
+
return req.path === '/health' || req.path === '/metrics' || req.path === '/';
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
},
|
|
98
|
+
handler: (req, res) => {
|
|
99
|
+
logger.warn({ ip: req.ip, path: req.path }, 'Rate limit exceeded (IP)');
|
|
100
|
+
res.status(429).json({
|
|
101
|
+
error: 'Too many requests',
|
|
102
|
+
message: 'You have exceeded the rate limit. Please try again later.',
|
|
103
|
+
retryAfter: Math.ceil(config.security.rateLimit.windowMs / 1000),
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
// Note: Using default keyGenerator which handles IPv6 properly
|
|
107
|
+
// If behind a proxy, set app.set('trust proxy', 1) before this middleware
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
app.use(ipRateLimiter);
|
|
111
|
+
logger.info({
|
|
112
|
+
windowMs: config.security.rateLimit.windowMs,
|
|
113
|
+
maxRequests: config.security.rateLimit.maxRequests,
|
|
114
|
+
}, 'IP-based rate limiting enabled');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// User-based rate limiting middleware (applied to /v1 routes)
|
|
118
|
+
const userRateLimiter = (req: Request, res: Response, next: NextFunction) => {
|
|
119
|
+
if (!config.security.rateLimit.enabled) {
|
|
120
|
+
return next();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Extract userId from body (for POST requests) or skip if not present
|
|
124
|
+
const userId = req.body?.userId;
|
|
125
|
+
if (!userId) {
|
|
126
|
+
return next();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
const windowMs = config.security.rateLimit.windowMs;
|
|
131
|
+
const maxPerUser = config.security.rateLimit.maxRequestsPerUser;
|
|
132
|
+
|
|
133
|
+
// Get or create user entry
|
|
134
|
+
let userEntry = userRequestCounts.get(userId);
|
|
135
|
+
if (!userEntry || now > userEntry.resetTime) {
|
|
136
|
+
userEntry = { count: 0, resetTime: now + windowMs };
|
|
137
|
+
userRequestCounts.set(userId, userEntry);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
userEntry.count++;
|
|
141
|
+
|
|
142
|
+
if (userEntry.count > maxPerUser) {
|
|
143
|
+
logger.warn({ userId, count: userEntry.count, path: req.path }, 'Rate limit exceeded (User)');
|
|
144
|
+
return res.status(429).json({
|
|
145
|
+
error: 'Too many requests',
|
|
146
|
+
message: 'You have exceeded the rate limit for your user. Please try again later.',
|
|
147
|
+
retryAfter: Math.ceil((userEntry.resetTime - now) / 1000),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
next();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Cleanup stale entries periodically (every 15 minutes)
|
|
155
|
+
setInterval(() => {
|
|
156
|
+
const now = Date.now();
|
|
157
|
+
let cleaned = 0;
|
|
158
|
+
for (const [userId, entry] of userRequestCounts.entries()) {
|
|
159
|
+
if (now > entry.resetTime) {
|
|
160
|
+
userRequestCounts.delete(userId);
|
|
161
|
+
cleaned++;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (cleaned > 0) {
|
|
165
|
+
logger.debug({ cleaned }, 'Cleaned stale user rate limit entries');
|
|
166
|
+
}
|
|
167
|
+
}, 15 * 60 * 1000);
|
|
168
|
+
|
|
169
|
+
//4. API Key Authentication - Protect endpoints from unauthorized access
|
|
170
|
+
const apiKeyAuth = (req: Request, res: Response, next: NextFunction) => {
|
|
171
|
+
if (!config.security.apiKey.enabled) {
|
|
172
|
+
return next();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Skip health endpoints if configured
|
|
176
|
+
if (config.security.apiKey.skipHealthEndpoints) {
|
|
177
|
+
if (req.path === '/health' || req.path === '/metrics' || req.path === '/') {
|
|
178
|
+
return next();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const headerName = config.security.apiKey.headerName;
|
|
183
|
+
const providedKey = req.headers[headerName] as string;
|
|
184
|
+
const validKey = process.env.STEPPER_API_KEY;
|
|
185
|
+
|
|
186
|
+
if (!validKey) {
|
|
187
|
+
logger.error('API_KEY_ENABLED is true but STEPPER_API_KEY is not set!');
|
|
188
|
+
return res.status(500).json({ error: 'Server configuration error' });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!providedKey) {
|
|
192
|
+
logger.warn({ path: req.path, ip: req.ip }, 'Missing API key');
|
|
193
|
+
return res.status(401).json({
|
|
194
|
+
error: 'Unauthorized',
|
|
195
|
+
message: `Missing API key. Include it in the '${headerName}' header.`,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Constant-time comparison to prevent timing attacks
|
|
200
|
+
if (!timingSafeEqual(providedKey, validKey)) {
|
|
201
|
+
logger.warn({ path: req.path, ip: req.ip }, 'Invalid API key');
|
|
202
|
+
return res.status(401).json({
|
|
203
|
+
error: 'Unauthorized',
|
|
204
|
+
message: 'Invalid API key.',
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
next();
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Constant-time string comparison to prevent timing attacks
|
|
212
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
213
|
+
if (a.length !== b.length) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
let result = 0;
|
|
217
|
+
for (let i = 0; i < a.length; i++) {
|
|
218
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
219
|
+
}
|
|
220
|
+
return result === 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Apply API key authentication to all routes
|
|
224
|
+
app.use(apiKeyAuth);
|
|
225
|
+
|
|
226
|
+
if (config.security.apiKey.enabled) {
|
|
227
|
+
logger.info({ headerName: config.security.apiKey.headerName }, 'API key authentication enabled');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
app.use(express.json({ limit: '10mb' }));
|
|
232
|
+
|
|
233
|
+
// REQUEST LOGGING
|
|
234
|
+
|
|
235
|
+
app.use((req, res, next) => {
|
|
236
|
+
const start = Date.now();
|
|
237
|
+
res.on('finish', () => {
|
|
238
|
+
const duration = Date.now() - start;
|
|
239
|
+
logger.info({
|
|
240
|
+
method: req.method,
|
|
241
|
+
path: req.path,
|
|
242
|
+
status: res.statusCode,
|
|
243
|
+
duration,
|
|
244
|
+
ip: (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || req.ip,
|
|
245
|
+
}, 'HTTP request');
|
|
246
|
+
});
|
|
247
|
+
next();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// API ROUTES
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* POST /v1/reports
|
|
254
|
+
* Enqueue or immediately return a report
|
|
255
|
+
*/
|
|
256
|
+
app.post('/v1/reports', userRateLimiter, async (req: Request, res: Response, next: NextFunction) => {
|
|
257
|
+
try {
|
|
258
|
+
const input: PromptInput = req.body;
|
|
259
|
+
|
|
260
|
+
// Validate required fields
|
|
261
|
+
if (!input.userId || !input.commitSha || !input.repo || !input.message) {
|
|
262
|
+
return res.status(400).json({
|
|
263
|
+
error: 'Missing required fields: userId, commitSha, repo, message',
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const result = await enqueueReport(input);
|
|
268
|
+
|
|
269
|
+
if (result.status === 200) {
|
|
270
|
+
return res.status(200).json({
|
|
271
|
+
status: 'completed',
|
|
272
|
+
cached: true,
|
|
273
|
+
stale: result.stale,
|
|
274
|
+
data: result.data,
|
|
275
|
+
});
|
|
276
|
+
} else {
|
|
277
|
+
return res.status(202).json({
|
|
278
|
+
status: 'queued',
|
|
279
|
+
jobId: result.jobId,
|
|
280
|
+
statusUrl: `/v1/reports/${result.jobId}`,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
} catch (error) {
|
|
284
|
+
return next(error);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* POST /v1/reports/immediate
|
|
290
|
+
* Generate report synchronously (blocking)
|
|
291
|
+
*/
|
|
292
|
+
app.post('/v1/reports/immediate', userRateLimiter, async (req: Request, res: Response, next: NextFunction) => {
|
|
293
|
+
try {
|
|
294
|
+
const input: PromptInput = req.body;
|
|
295
|
+
|
|
296
|
+
if (!input.userId || !input.commitSha || !input.repo || !input.message) {
|
|
297
|
+
return res.status(400).json({
|
|
298
|
+
error: 'Missing required fields: userId, commitSha, repo, message',
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const result = await generateReport(input);
|
|
303
|
+
|
|
304
|
+
return res.status(200).json({
|
|
305
|
+
status: 'completed',
|
|
306
|
+
data: result.result,
|
|
307
|
+
metadata: {
|
|
308
|
+
provider: result.usedProvider,
|
|
309
|
+
fallback: result.fallback,
|
|
310
|
+
timings: result.timings,
|
|
311
|
+
providersAttempted: result.providersAttempted,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
} catch (error) {
|
|
315
|
+
return next(error);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* GET /v1/reports/:jobId
|
|
321
|
+
* Get job status and result
|
|
322
|
+
*/
|
|
323
|
+
app.get('/v1/reports/:jobId', async (req: Request, res: Response, next: NextFunction) => {
|
|
324
|
+
try {
|
|
325
|
+
const { jobId } = req.params;
|
|
326
|
+
const job = await getJob(jobId);
|
|
327
|
+
|
|
328
|
+
if (!job) {
|
|
329
|
+
return res.status(404).json({ error: 'Job not found' });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const response: Record<string, unknown> = {
|
|
333
|
+
id: job.id,
|
|
334
|
+
status: job.state,
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
if (job.progress !== undefined) {
|
|
338
|
+
response.progress = job.progress;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (job.state === 'completed' && job.result) {
|
|
342
|
+
response.data = job.result;
|
|
343
|
+
|
|
344
|
+
// Auto-cleanup: Once returned to the poller, we can clear the cache
|
|
345
|
+
// because the backend is expected to save it to Supabase immediately.
|
|
346
|
+
const jobData = job.data as { input?: { userId?: string; commitSha?: string; template?: string } } | undefined;
|
|
347
|
+
if (jobData?.input?.userId && jobData.input.commitSha) {
|
|
348
|
+
deleteReport(jobData.input.userId, jobData.input.commitSha, jobData.input.template).catch(err => {
|
|
349
|
+
logger.error({ err, jobId }, 'Failed to auto-cleanup cache after polling');
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (job.state === 'failed' && job.failedReason) {
|
|
355
|
+
response.error = job.failedReason;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return res.status(200).json(response);
|
|
359
|
+
} catch (error) {
|
|
360
|
+
return next(error);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* DELETE /v1/reports
|
|
366
|
+
* Manually purge a report from cache.
|
|
367
|
+
* Use this after saving the result to your primary database.
|
|
368
|
+
*/
|
|
369
|
+
app.delete('/v1/reports', async (req: Request, res: Response, next: NextFunction) => {
|
|
370
|
+
try {
|
|
371
|
+
const { userId, commitSha, template } = req.query;
|
|
372
|
+
|
|
373
|
+
if (!userId || !commitSha) {
|
|
374
|
+
return res.status(400).json({
|
|
375
|
+
error: 'Missing required query parameters: userId, commitSha',
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
await deleteReport(userId as string, commitSha as string, template as string);
|
|
380
|
+
|
|
381
|
+
return res.status(200).json({ success: true, message: 'Cache entry deleted' });
|
|
382
|
+
} catch (error) {
|
|
383
|
+
return next(error);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* GET /health
|
|
389
|
+
* Health check endpoint
|
|
390
|
+
*/
|
|
391
|
+
app.get('/health', async (_req: Request, res: Response, next: NextFunction) => {
|
|
392
|
+
try {
|
|
393
|
+
const health = await healthcheck();
|
|
394
|
+
const statusCode = health.status === 'healthy' ? 200 : health.status === 'degraded' ? 200 : 503;
|
|
395
|
+
return res.status(statusCode).json(health);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
return next(error);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* GET /metrics
|
|
403
|
+
* Prometheus metrics endpoint
|
|
404
|
+
*/
|
|
405
|
+
app.get('/metrics', async (_req: Request, res: Response, next: NextFunction) => {
|
|
406
|
+
try {
|
|
407
|
+
const metrics = await getMetrics();
|
|
408
|
+
res.set('Content-Type', 'text/plain');
|
|
409
|
+
return res.send(metrics);
|
|
410
|
+
} catch (error) {
|
|
411
|
+
return next(error);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* GET /
|
|
417
|
+
* Root endpoint with API info
|
|
418
|
+
*/
|
|
419
|
+
app.get('/', (_req: Request, res: Response) => {
|
|
420
|
+
res.json({
|
|
421
|
+
service: 'stepper',
|
|
422
|
+
version: '1.0.0',
|
|
423
|
+
endpoints: {
|
|
424
|
+
'POST /v1/reports': 'Enqueue report generation',
|
|
425
|
+
'POST /v1/reports/immediate': 'Generate report immediately',
|
|
426
|
+
'GET /v1/reports/:jobId': 'Get job status',
|
|
427
|
+
'GET /health': 'Health check',
|
|
428
|
+
'GET /metrics': 'Prometheus metrics',
|
|
429
|
+
},
|
|
430
|
+
security: {
|
|
431
|
+
cors: config.security.cors.enabled,
|
|
432
|
+
rateLimit: config.security.rateLimit.enabled,
|
|
433
|
+
helmet: config.security.helmet.enabled,
|
|
434
|
+
apiKeyRequired: config.security.apiKey.enabled,
|
|
435
|
+
},
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// =============================================================================
|
|
440
|
+
// ERROR HANDLER
|
|
441
|
+
// =============================================================================
|
|
442
|
+
|
|
443
|
+
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
|
444
|
+
logger.error({ err }, 'Unhandled error');
|
|
445
|
+
res.status(500).json({
|
|
446
|
+
error: 'Internal server error',
|
|
447
|
+
message: err.message,
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
export default app;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// packages/stepper/src/server/start.ts
|
|
2
|
+
|
|
3
|
+
import 'dotenv/config';
|
|
4
|
+
import { initStepper } from '../index.js';
|
|
5
|
+
import { logger } from '../logging.js';
|
|
6
|
+
import { startWorker, stopWorker } from '../queue/worker.js';
|
|
7
|
+
import { closeRedis } from '../cache/redisCache.js';
|
|
8
|
+
import { closeQueue } from '../queue/producer.js';
|
|
9
|
+
import type { ProviderConfig, StepperConfig } from '../types.js';
|
|
10
|
+
|
|
11
|
+
export interface StartServerOptions {
|
|
12
|
+
port?: number;
|
|
13
|
+
init?: {
|
|
14
|
+
config?: Partial<StepperConfig>;
|
|
15
|
+
providers?: ProviderConfig[];
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RunningServer {
|
|
20
|
+
server: import('http').Server;
|
|
21
|
+
shutdown: () => Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function startServer(options?: StartServerOptions): Promise<RunningServer> {
|
|
25
|
+
const overrides = options?.init?.config;
|
|
26
|
+
const providers = options?.init?.providers;
|
|
27
|
+
|
|
28
|
+
const appConfig = initStepper({ config: overrides, providers });
|
|
29
|
+
|
|
30
|
+
const { default: app } = await import('./app.js');
|
|
31
|
+
const port = options?.port ?? appConfig.server.port;
|
|
32
|
+
|
|
33
|
+
const server = app.listen(port, () => {
|
|
34
|
+
logger.info({ port }, 'Server started');
|
|
35
|
+
|
|
36
|
+
logger.info({
|
|
37
|
+
cors: appConfig.security.cors.enabled,
|
|
38
|
+
rateLimit: appConfig.security.rateLimit.enabled,
|
|
39
|
+
helmet: appConfig.security.helmet.enabled,
|
|
40
|
+
apiKey: appConfig.security.apiKey.enabled,
|
|
41
|
+
}, 'Security configuration');
|
|
42
|
+
|
|
43
|
+
if (process.env.DISCORD_WEBHOOK_URL) {
|
|
44
|
+
logger.info('Stepper error webhook configured - alerts enabled');
|
|
45
|
+
} else {
|
|
46
|
+
logger.info('Stepper error webhook not configured (set DISCORD_WEBHOOK_URL to enable)');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
startWorker();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const shutdown = async (): Promise<void> => {
|
|
53
|
+
logger.info('Shutting down gracefully');
|
|
54
|
+
await new Promise<void>((resolve) => {
|
|
55
|
+
server.close(async () => {
|
|
56
|
+
await stopWorker();
|
|
57
|
+
await closeQueue();
|
|
58
|
+
await closeRedis();
|
|
59
|
+
logger.info('Shutdown complete');
|
|
60
|
+
resolve();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return { server, shutdown };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default startServer;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
|
|
2
|
+
### packages/stepper/Dockerfile
|
|
3
|
+
|
|
4
|
+
FROM node:18-alpine AS builder
|
|
5
|
+
|
|
6
|
+
WORKDIR /app
|
|
7
|
+
|
|
8
|
+
# Install pnpm
|
|
9
|
+
RUN npm install -g pnpm
|
|
10
|
+
|
|
11
|
+
# Copy package files
|
|
12
|
+
COPY package.json pnpm-lock.yaml ./
|
|
13
|
+
COPY tsconfig.json ./
|
|
14
|
+
|
|
15
|
+
# Install dependencies
|
|
16
|
+
RUN pnpm install --frozen-lockfile
|
|
17
|
+
|
|
18
|
+
# Copy source
|
|
19
|
+
COPY src ./src
|
|
20
|
+
|
|
21
|
+
# Build
|
|
22
|
+
RUN pnpm build
|
|
23
|
+
|
|
24
|
+
# Production image
|
|
25
|
+
FROM node:18-alpine
|
|
26
|
+
|
|
27
|
+
WORKDIR /app
|
|
28
|
+
|
|
29
|
+
RUN npm install -g pnpm
|
|
30
|
+
|
|
31
|
+
# Copy package files
|
|
32
|
+
COPY package.json pnpm-lock.yaml ./
|
|
33
|
+
|
|
34
|
+
# Install production dependencies only
|
|
35
|
+
RUN pnpm install --frozen-lockfile --prod
|
|
36
|
+
|
|
37
|
+
# Copy built files
|
|
38
|
+
COPY --from=builder /app/dist ./dist
|
|
39
|
+
|
|
40
|
+
# Expose port
|
|
41
|
+
EXPOSE 3001
|
|
42
|
+
|
|
43
|
+
# Health check
|
|
44
|
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
45
|
+
CMD node -e "require('http').get('http://localhost:3001/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
|
46
|
+
|
|
47
|
+
# Start server
|
|
48
|
+
CMD ["node", "dist/server/app.js"]
|