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.
Files changed (160) hide show
  1. package/.env.example +169 -0
  2. package/.eslintrc.cjs +23 -0
  3. package/.github/workflows/ci.yml +51 -0
  4. package/.github/workflows/keep-alive.yml +22 -0
  5. package/.github/workflows/publish.yml +34 -0
  6. package/ARCHITECTURE.md +594 -0
  7. package/Dockerfile +16 -0
  8. package/LICENSE +28 -0
  9. package/README.md +261 -0
  10. package/dist/alerts/discord.d.ts +19 -0
  11. package/dist/alerts/discord.d.ts.map +1 -0
  12. package/dist/alerts/discord.js +70 -0
  13. package/dist/alerts/discord.js.map +1 -0
  14. package/dist/cache/redisCache.d.ts +45 -0
  15. package/dist/cache/redisCache.d.ts.map +1 -0
  16. package/dist/cache/redisCache.js +171 -0
  17. package/dist/cache/redisCache.js.map +1 -0
  18. package/dist/cli.d.ts +3 -0
  19. package/dist/cli.d.ts.map +1 -0
  20. package/dist/cli.js +8 -0
  21. package/dist/cli.js.map +1 -0
  22. package/dist/config.d.ts +6 -0
  23. package/dist/config.d.ts.map +1 -0
  24. package/dist/config.js +251 -0
  25. package/dist/config.js.map +1 -0
  26. package/dist/fallback/templateFallback.d.ts +7 -0
  27. package/dist/fallback/templateFallback.d.ts.map +1 -0
  28. package/dist/fallback/templateFallback.js +29 -0
  29. package/dist/fallback/templateFallback.js.map +1 -0
  30. package/dist/index.d.ts +121 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +198 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/logging.d.ts +10 -0
  35. package/dist/logging.d.ts.map +1 -0
  36. package/dist/logging.js +44 -0
  37. package/dist/logging.js.map +1 -0
  38. package/dist/metrics/metrics.d.ts +22 -0
  39. package/dist/metrics/metrics.d.ts.map +1 -0
  40. package/dist/metrics/metrics.js +78 -0
  41. package/dist/metrics/metrics.js.map +1 -0
  42. package/dist/providers/factory.d.ts +11 -0
  43. package/dist/providers/factory.d.ts.map +1 -0
  44. package/dist/providers/factory.js +52 -0
  45. package/dist/providers/factory.js.map +1 -0
  46. package/dist/providers/hfSpace.adapter.d.ts +21 -0
  47. package/dist/providers/hfSpace.adapter.d.ts.map +1 -0
  48. package/dist/providers/hfSpace.adapter.js +110 -0
  49. package/dist/providers/hfSpace.adapter.js.map +1 -0
  50. package/dist/providers/httpTemplate.adapter.d.ts +42 -0
  51. package/dist/providers/httpTemplate.adapter.d.ts.map +1 -0
  52. package/dist/providers/httpTemplate.adapter.js +98 -0
  53. package/dist/providers/httpTemplate.adapter.js.map +1 -0
  54. package/dist/providers/promptBuilder.d.ts +34 -0
  55. package/dist/providers/promptBuilder.d.ts.map +1 -0
  56. package/dist/providers/promptBuilder.js +315 -0
  57. package/dist/providers/promptBuilder.js.map +1 -0
  58. package/dist/providers/provider.interface.d.ts +45 -0
  59. package/dist/providers/provider.interface.d.ts.map +1 -0
  60. package/dist/providers/provider.interface.js +47 -0
  61. package/dist/providers/provider.interface.js.map +1 -0
  62. package/dist/providers/specs.d.ts +18 -0
  63. package/dist/providers/specs.d.ts.map +1 -0
  64. package/dist/providers/specs.js +326 -0
  65. package/dist/providers/specs.js.map +1 -0
  66. package/dist/providers/unified.adapter.d.ts +37 -0
  67. package/dist/providers/unified.adapter.d.ts.map +1 -0
  68. package/dist/providers/unified.adapter.js +141 -0
  69. package/dist/providers/unified.adapter.js.map +1 -0
  70. package/dist/queue/producer.d.ts +30 -0
  71. package/dist/queue/producer.d.ts.map +1 -0
  72. package/dist/queue/producer.js +87 -0
  73. package/dist/queue/producer.js.map +1 -0
  74. package/dist/queue/worker.d.ts +9 -0
  75. package/dist/queue/worker.d.ts.map +1 -0
  76. package/dist/queue/worker.js +137 -0
  77. package/dist/queue/worker.js.map +1 -0
  78. package/dist/server/app.d.ts +4 -0
  79. package/dist/server/app.d.ts.map +1 -0
  80. package/dist/server/app.js +394 -0
  81. package/dist/server/app.js.map +1 -0
  82. package/dist/server/start.d.ts +16 -0
  83. package/dist/server/start.d.ts.map +1 -0
  84. package/dist/server/start.js +45 -0
  85. package/dist/server/start.js.map +1 -0
  86. package/dist/stepper/orchestrator.d.ts +22 -0
  87. package/dist/stepper/orchestrator.d.ts.map +1 -0
  88. package/dist/stepper/orchestrator.js +333 -0
  89. package/dist/stepper/orchestrator.js.map +1 -0
  90. package/dist/types.d.ts +216 -0
  91. package/dist/types.d.ts.map +1 -0
  92. package/dist/types.js +14 -0
  93. package/dist/types.js.map +1 -0
  94. package/dist/utils/redaction.d.ts +9 -0
  95. package/dist/utils/redaction.d.ts.map +1 -0
  96. package/dist/utils/redaction.js +41 -0
  97. package/dist/utils/redaction.js.map +1 -0
  98. package/dist/utils/safeRequest.d.ts +38 -0
  99. package/dist/utils/safeRequest.d.ts.map +1 -0
  100. package/dist/utils/safeRequest.js +104 -0
  101. package/dist/utils/safeRequest.js.map +1 -0
  102. package/dist/validation/report.schema.d.ts +48 -0
  103. package/dist/validation/report.schema.d.ts.map +1 -0
  104. package/dist/validation/report.schema.js +72 -0
  105. package/dist/validation/report.schema.js.map +1 -0
  106. package/dist/webhooks/delivery.d.ts +31 -0
  107. package/dist/webhooks/delivery.d.ts.map +1 -0
  108. package/dist/webhooks/delivery.js +102 -0
  109. package/dist/webhooks/delivery.js.map +1 -0
  110. package/docs/assets/architecture.png +0 -0
  111. package/package.json +75 -0
  112. package/render.yaml +25 -0
  113. package/src/alerts/README.md +25 -0
  114. package/src/alerts/discord.ts +86 -0
  115. package/src/cache/How redis caching works in package stepper.md +971 -0
  116. package/src/cache/README.md +51 -0
  117. package/src/cache/redisCache.ts +194 -0
  118. package/src/ci/deploy.sh +36 -0
  119. package/src/cli.ts +9 -0
  120. package/src/config.ts +265 -0
  121. package/src/fallback/templateFallback.ts +32 -0
  122. package/src/index.ts +246 -0
  123. package/src/logging.ts +46 -0
  124. package/src/metrics/README.md +24 -0
  125. package/src/metrics/metrics.ts +84 -0
  126. package/src/providers/How the providers interact.md +121 -0
  127. package/src/providers/README.md +121 -0
  128. package/src/providers/factory.ts +57 -0
  129. package/src/providers/hfSpace.adapter.ts +119 -0
  130. package/src/providers/httpTemplate.adapter.ts +138 -0
  131. package/src/providers/promptBuilder.ts +330 -0
  132. package/src/providers/provider.interface.ts +73 -0
  133. package/src/providers/specs.ts +366 -0
  134. package/src/providers/unified.adapter.ts +172 -0
  135. package/src/queue/How queue works in package stepper.md +149 -0
  136. package/src/queue/README.md +41 -0
  137. package/src/queue/producer.ts +108 -0
  138. package/src/queue/worker.ts +170 -0
  139. package/src/server/app.ts +451 -0
  140. package/src/server/start.ts +68 -0
  141. package/src/stepper/Dockerfile +48 -0
  142. package/src/stepper/How orchestrator works in package stepper.md +746 -0
  143. package/src/stepper/README.md +43 -0
  144. package/src/stepper/orchestrator.ts +437 -0
  145. package/src/types.ts +238 -0
  146. package/src/utils/redaction.ts +50 -0
  147. package/src/utils/safeRequest.ts +140 -0
  148. package/src/validation/README.md +25 -0
  149. package/src/validation/report.schema.ts +96 -0
  150. package/src/webhooks/delivery.ts +162 -0
  151. package/tests/integration/full-flow.test.ts +192 -0
  152. package/tests/unit/alerts/discord.test.ts +119 -0
  153. package/tests/unit/cache.test.ts +87 -0
  154. package/tests/unit/orchestrator-fallback.test.ts +92 -0
  155. package/tests/unit/orchestrator.test.ts +105 -0
  156. package/tests/unit/providers/factory.test.ts +161 -0
  157. package/tests/unit/providers/unified.adapter.test.ts +206 -0
  158. package/tests/unit/utils/redaction.test.ts +140 -0
  159. package/tests/unit/utils/safeRequest.test.ts +164 -0
  160. 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"]