Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ coverage/

!.env.example
.aider*
branch_structure.json
temp_auto_push.bat
temp_interactive_push.bat
5 changes: 4 additions & 1 deletion eslint.config.js

Large diffs are not rendered by default.

11 changes: 5 additions & 6 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import express from 'express';
import healthRouter from './routes/health.js';
import cors from 'cors';
import helmet from 'helmet';
import adminRouter from './routes/admin.js';
Expand Down Expand Up @@ -387,6 +388,10 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
createDependenciesRouter(dependencies?.healthCheckConfig),
);

// Single source of truth for GET /api/health — the health router owns the
// response shape and headers so every entrypoint returns the same envelope.
app.use("/api/health", healthRouter);

// Rate-limit routes with X-Correlation-Id propagation — every sub-route
// inherits correlation-id middleware for structured logging and outbound
// call correlation.
Expand All @@ -399,12 +404,6 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
}),
);

app.get("/api/health", (req, res) => {
const requestId = getRequestId(req);
const data = { status: "ok", service: "callora-backend" };
res.json(successEnvelope(data, requestId));
});

// Public maintenance status — readable by external monitoring without admin auth.
// Mounted in front of the admin routers so it cannot be shadowed by their catch-alls.
app.use("/api/maintenance", publicMaintenanceRouter);
Expand Down
6 changes: 4 additions & 2 deletions src/config/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ function getDbPool(): Pool {
}

export function buildHealthCheckConfig(): HealthCheckConfig | undefined {
// Only enable detailed health checks if database is explicitly configured
if (env.DB_HOST === 'localhost' && env.DB_NAME === 'callora') {
// Only enable detailed health checks when explicitly opted in, so that
// every entrypoint (createApp and the index app) resolves the same
// /api/health handler configuration.
if (!env.HEALTH_CHECKS_ENABLED) {
return undefined;
}

Expand Down
205 changes: 7 additions & 198 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,29 +58,12 @@ import {
import { createSloAlertJob } from "./workers/sloAlertJob.js";
import { createMonthlyInvoiceJob } from "./workers/monthlyInvoiceJob.js";
import { createSettlementReconWorker } from "./workers/settlementRecon.js";
import { createDeveloperRouter } from './routes/developerRoutes.js';
import { createGatewayRouter } from './routes/gatewayRoutes.js';
import { createProxyRouter } from './routes/proxyRoutes.js';
import { createRefreshTokenRouter } from './routes/refresh-token.js';
import { AuthController } from './controllers/authController.js';
import { RefreshTokenService } from './services/refreshTokenService.js';
import { DatabaseRefreshTokenRepository } from './repositories/refreshTokenRepository.js';
import { defaultDeveloperRepository } from './repositories/developerRepository.js';
import { createBillingService } from './services/billingService.js';
import { createRateLimiter } from './services/rateLimiter.js';
import { PgUsageEventsRepository } from './repositories/usageEventsRepository.pg.js';
import { createRevenueLedgerIndexerJob } from './services/revenueLedgerIndexer.js';
import { RevenueSettlementService } from './services/revenueSettlementService.js';
import { createSettlementStatusSyncJob } from './services/settlementStatusSyncJob.js';
import { createSettlementReconciliationJob } from './services/settlementReconciliationJob.js';
import { createIdempotencySweeperJob } from './services/idempotencySweeper.js';
import { createPostgresUsageStore } from './services/usageStore.js';
import { createPostgresSettlementStore } from './services/settlementStore.js';
import { createApiRegistry } from './data/apiRegistry.js';
import { ApiKey } from './types/gateway.js';
import { listingsCache } from './lib/listingsCache.js';
import { createSlowQueryAlerterJob } from './workers/slowQueryAlerter.js';
import { createAnomalyDetectorJob } from './workers/anomalyDetector.js';

// Helper for Jest/CommonJS compat
const isDirectExecution =
Expand Down Expand Up @@ -110,7 +93,7 @@ app.use(
// configured route so that the first request samples land in the right
// window. The recorder is cheap for unconfigured routes (a Map miss) so
// it is mounted unconditionally; only the worker is gated on the webhook URL.
initSloRecorder({
initSloRecorder( {
configs: config.sloAlert.configs,
observationWindowMs: config.sloAlert.observationWindowMs,
});
Expand All @@ -128,10 +111,9 @@ app.use((req, res, next) => {
}
});

// Health check endpoint
app.get("/api/health", (_req, res) => {
res.json({ status: "ok", service: "callora-backend" });
});
// Health check endpoint is served by the shared health router (app.js
// mounts the same router at /api/health). Do not register a duplicate
// handler here — see src/routes/health.ts for the single implementation.

// Metrics endpoint
app.get("/api/metrics", metricsEndpoint);
Expand Down Expand Up @@ -163,7 +145,7 @@ if (isDirectExecution) {
};

const billing = createBillingService(MOCK_DEVELOPER_BALANCES);
// Per-API-key token-bucket rate limit shared by /api/gateway and /v1/call.
// Per-API-hey token-bucket rate limit shared by /api/gateway and /v1/call.
// Backed by Postgres (RATE_LIMIT_STORE=postgres) so the bucket state is
// consistent across multiple gateway instances; defaults to an in-memory
// store otherwise. See RATE_LIMIT_* in src/config/env.ts.
Expand Down Expand Up @@ -248,16 +230,7 @@ if (isDirectExecution) {
})
: null;

const apiKeys = new Map<string, ApiKey>([
[
"test-key-1",
{ key: "test-key-1", developerId: "dev_001", apiId: "api_001" },
],
[
"test-key-2",
{ key: "test-key-2", developerId: "dev_002", apiId: "api_002" },
],
]);
const apiKeys = new Map<string, ApiKey>([]);

// 1. Developer Dashboard Routes (Auth required)
const developerRouter = createDeveloperRouter({
Expand All @@ -273,9 +246,6 @@ if (isDirectExecution) {
app.use("/api/admin", adminRouter);
app.use("/api/refunds", refundsRouter);
app.use("/api/logs", logsRouter);
app.use('/api/admin/usage/anomalies', createUsageAnomaliesRouter({ pool }));

app.use('/api/admin', adminRouter);

// Legacy gateway route (existing)
const gatewayRouter = createGatewayRouter({
Expand Down Expand Up @@ -303,168 +273,7 @@ if (isDirectExecution) {
apiKeys,
proxyConfig: {
timeoutMs: config.proxy.timeoutMs,
allowedHosts: config.proxy.allowedHosts,
allowedHo
},
// Pass the drain state so the router can reject new requests with 503
// during the graceful shutdown window.
drainState: { isDraining: proxyDrainTracker.isDraining },
});
const keysDrainTracker = createInFlightDrainTracker("api-keys");
const apiKeyRouter = createApiKeyRouter({
apiRepository: defaultApiRepository,
developerRepository: defaultDeveloperRepository,
});

// --- Refresh-token drain tracker ---
// Tracks in-flight POST /api/refresh-token requests so that a SIGTERM during
// a token refresh will wait for the response to be sent before the process
// exits. The subsystem is registered below alongside the other shutdown
// subsystems.
const refreshTokenDrainTracker = createInFlightDrainTracker('refresh-token');

const refreshTokenService = new RefreshTokenService({
jwtSecret: config.jwt.secret,
accessTokenExpiry: process.env.ACCESS_TOKEN_EXPIRY ?? '15m',
refreshTokenExpiry: process.env.REFRESH_TOKEN_EXPIRY ?? '7d',
});
const refreshTokenRepository = new DatabaseRefreshTokenRepository(pool);
const authController = new AuthController({
refreshTokenService,
refreshTokenRepository,
});

const shutdownSubsystems: DrainableSubsystem[] = [
proxyDrainTracker.subsystem,
// Drain in-flight refresh-token requests before the process exits.
refreshTokenDrainTracker.subsystem,
{
name: "revenue-ledger-indexer",
beginShutdown: () => revenueLedgerIndexerJob.beginShutdown(),
awaitIdle: () => revenueLedgerIndexerJob.awaitIdle(),
},
{
name: "idempotency-sweeper",
beginShutdown: () => idempotencySweeperJob.beginShutdown(),
awaitIdle: () => idempotencySweeperJob.awaitIdle(),
},
{
name: "webhook-dispatcher",
beginShutdown: stopWebhookDispatching,
awaitIdle: awaitWebhookDispatcherIdle,
},
{
name: "settlement-reconciliation",
beginShutdown: () => settlementReconJob.beginShutdown(),
awaitIdle: () => settlementReconJob.awaitIdle(),
},
];

// Mount the refresh-token router. The drain middleware is applied inside the
// router so every request through this path is counted in the drain tracker.
app.use(
'/api/refresh-token',
createRefreshTokenRouter({
authController,
drainMiddleware: refreshTokenDrainTracker.middleware,
}),
);


app.use(express.json());

// Global error handler (must be after all routes)
app.use(errorHandler);

const PORT = config.port;

const closeAllDataResources = async () => {
revenueLedgerIndexerJob.stop();
settlementStatusSyncJob.stop();
settlementReconJob.stop();
idempotencySweeperJob.stop();
slowQueryAlerterJob?.stop();
anomalyDetectorJob?.stop();
monthlyInvoiceJob.stop();
sloAlertJob?.stop();
await closeDb();
await Promise.allSettled([
closePgPool(),
disconnectPrisma(),
closeDbPool(),
]);
};

// Initialize database and start server
async function startServer() {
try {
await initializeDb();

// Warm the listings cache before accepting traffic so the first
// request after a deploy is served from cache, not from a cold DB hit.
const { warmupListingsCache } = await import("./lib/listingsCache.js");
const { defaultApiRepository } =
await import("./repositories/apiRepository.js");
await warmupListingsCache(
listingsCache,
(params) =>
defaultApiRepository.listPublic({
limit: params.limit,
offset: params.offset,
category: params.category,
search: params.search,
}),
{ timeoutMs: config.listingsCache.warmupTimeoutMs },
);

// Warm the refunds cache before accepting traffic to avoid cold-cache spikes on startup.
const { warmupRefundsCache } = await import("./services/refundsCacheWarm.js");
await warmupRefundsCache({ timeoutMs: config.refundsCache.warmupTimeoutMs });

revenueLedgerIndexerJob.start();
settlementStatusSyncJob.start();
settlementReconJob.start();
idempotencySweeperJob.start();
slowQueryAlerterJob?.start();
anomalyDetectorJob?.start();
monthlyInvoiceJob.start();
sloAlertJob?.start();

const server = app.listen(PORT, () => {
console.log(`Callora backend listening on http://localhost:${PORT}`);
});

// Track active connections so we can wait for them to finish
const activeConnections = new Set<Socket>();

server.on("connection", (socket: Socket) => {
activeConnections.add(socket);
socket.once("close", () => activeConnections.delete(socket));
});

const gracefulShutdown = createGracefulShutdownHandler({
server,
activeConnections,
closeDatabase: closeAllDataResources,
subsystems: shutdownSubsystems,
timeoutMs: 30_000, // 30 seconds as per requirement
});

const onSignal = (signal: NodeJS.Signals) => {
void gracefulShutdown(signal).then((exitCode: number) => {
process.exit(exitCode);
});
};

// Register shutdown signals
process.once("SIGTERM", () => onSignal("SIGTERM"));
process.once("SIGINT", () => onSignal("SIGINT"));
} catch (error) {
console.error("Failed to start server:", error);
process.exit(1);
}
}

startServer();
}

export default app;
Loading