Skip to content
Merged
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
71 changes: 21 additions & 50 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-require-imports */
import express from "express";
import { featureFlagsRouter } from './routes/admin/featureFlags';
import helmet from "helmet";
import pinoHttp from "pino-http";
import { v4 as uuidv4 } from "uuid";
Expand All @@ -22,14 +22,12 @@ import { adminFeatureFlagsRouter } from "./routes/admin/feature-flags";
import { adminUsersRouter } from "./routes/adminUsers";
import { leaderboardRouter } from "./routes/leaderboard";
import { createDocsRouter } from "./routes/docs";
import { devicesRouter } from "./routes/devices";
import { sessionsRouter } from "./routes/me/sessions";
import { notificationsRouter } from "./routes/notifications";
import { socialRouter } from "./routes/social";
import { adminAuditRouter } from "./routes/admin/audit";
import { adminMarketsRouter } from "./routes/admin/markets";
import { adminSchemaVersionsRouter } from "./routes/admin/schema-versions";
import { devicesRouter } from "./routes/devices";
import { errorHandler } from "./middleware/errorHandler";
import { requestContextStorage } from "./lib/requestContext";
import { REQUEST_ID_HEADER } from "./lib/http";
Expand All @@ -56,24 +54,9 @@ function sanitizeRequestId(raw: string): string | undefined {
return sanitized.length > 0 ? sanitized : undefined;
}

/** Dependency-injection options for `createApp`. */
export interface CreateAppOptions {
/** Webhook store + dispatcher to inject into admin webhook routes.
* When omitted, those routes are not mounted (production wires them via
* `DrizzleWebhookStore` after `connectWithRetry()` resolves). */
webhooks?: {
store: WebhookStore;
dispatcher: WebhookDispatcher;
};
}

export function createApp(_deps: AppDeps = {}): express.Express {
export function createApp(): express.Express {
const app = express();

// Disable Express's built-in ETag generation β€” we manage strong ETags
// explicitly in src/middleware/etag.ts for the resources that need them.
// Leaving Express's weak ETags enabled would add spurious `W/"..."` headers
// on every JSON response, including error envelopes.
app.set("etag", false);

if (env.TRUST_PROXY) {
Expand Down Expand Up @@ -140,8 +123,9 @@ export function createApp(_deps: AppDeps = {}): express.Express {
app.use("/api/me/sessions", sessionsRouter);
app.use("/api/admin/audit", adminAuditRouter);
app.use("/api/admin/users", adminUsersRouter);
app.use("/api/admin/feature-flags", adminFeatureFlagsRouter);
app.use('/feature-flags', featureFlagsRouter);
app.use("/api/admin/markets", adminMarketsRouter);
app.use("/api/admin/db/explain", adminDbExplainRouter);
app.use("/api/admin/schema-versions", adminSchemaVersionsRouter);
app.use("/api/admin/rate-limit", adminRateLimitInspectRouter);

Expand All @@ -166,10 +150,16 @@ export function createApp(_deps: AppDeps = {}): express.Express {
if (require.main === module) {
const app = createApp();
let webhookWorker: WebhookWorker | null = null;
let probeHandle: ReturnType<typeof setInterval> | null = null;

const stopWorkers = async (): Promise<void> => {
logger.info("Stopping queue workers");
stopSlowQueryAlerter();
stopIndexerHealthProbe();
if (probeHandle) {
clearInterval(probeHandle);
probeHandle = null;
}
await Promise.all([
webhookWorker ? webhookWorker.stop() : Promise.resolve(),
marketResolverWorker.stop(),
Expand All @@ -185,9 +175,9 @@ if (require.main === module) {
marketResolverWorker.start();
backupVerificationWorker.start();
reconciliationWorker.start();
startSlowQueryAlerter();
probeHandle = startIndexerHealthProbe();

const probeHandle = startIndexerHealthProbe();
app.listen(env.PORT, () => {
logger.info({ port: env.PORT, env: env.NODE_ENV }, "predictify-backend listening");
logger.info(`Swagger UI available at http://localhost:${env.PORT}/docs`);
Expand All @@ -200,48 +190,29 @@ if (require.main === module) {
process.exit(1);
}, 5000).unref();

await stopWorkers();
stopScheduler();
await closeDb();
clearTimeout(forceExit);
process.exit(0);
});

process.on("SIGINT", () => {
process.on("SIGINT", async () => {
logger.info("SIGINT received, shutting down gracefully");
const forceExit = setTimeout(() => {
logger.warn("Forced exit after shutdown timeout");
process.exit(1);
}, 5000).unref();

await stopWorkers();
stopScheduler();
await closeDb();
clearTimeout(forceExit);
process.exit(0);
});
})
.catch((err) => {
logger.fatal({ err }, "Failed to start server");
process.exit(1);
});

process.on("SIGTERM", async () => {
logger.info("SIGTERM received, shutting down");
const forceExit = setTimeout(() => {
logger.warn("Forced exit after shutdown timeout");
process.exit(1);
}, 5000).unref();

await stopWorkers();
stopScheduler();
await closeDb();
clearTimeout(forceExit);
process.exit(0);
});

process.on("SIGINT", async () => {
logger.info("SIGINT received, shutting down gracefully");
const forceExit = setTimeout(() => {
logger.warn("Forced exit after shutdown timeout");
process.exit(1);
}, 5000).unref();

await stopWorkers();
stopScheduler();
await closeDb();
clearTimeout(forceExit);
process.exit(0);
});
}
2 changes: 1 addition & 1 deletion src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

import type { NextFunction, Request, Response } from "express";
import { ZodError } from "zod";
import { randomUUID } from "crypto";
Expand Down
3 changes: 2 additions & 1 deletion src/middleware/requireAdmin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@ import { logger } from "../config/logger";

// Augment Express Request so downstream handlers can read the admin identity
// without casting.
/* eslint-disable @typescript-eslint/no-namespace */
declare global {

namespace Express {
interface Request {
adminAddress?: string;
}
}
}
/* eslint-enable @typescript-eslint/no-namespace */

interface AdminTokenPayload {
sub?: string;
Expand Down
77 changes: 77 additions & 0 deletions src/routes/admin/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { z } from "zod";
import { requireAdmin } from "../../middleware/requireAdmin";
import { getAuditLogs } from "../../repositories/auditLogRepo";
import { RouteErrorFactory } from "../../errors";
import { searchAuditLogsHandler } from "./audit/search";

export interface AdminAuditRouterOptions {
rateLimitPerMinute?: number;
Expand All @@ -27,8 +28,81 @@ const auditQuerySchema = z.object({
.optional(),
});

export function createAdminAuditRouter(opts: AdminAuditRouterOptions = {}): Router {
const router = Router();
const limit = opts.rateLimitPerMinute ?? 60;

router.use(
rateLimit({
windowMs: 60_000,
limit,
keyGenerator: (req) =>
(req.headers.authorization as string | undefined) ?? req.ip ?? "unknown",
standardHeaders: "draft-6",
legacyHeaders: false,
message: { error: { code: "rate_limit_exceeded" } },
}),
);

router.use(requireAdmin);

// Mount search handler to clear unused variable lint error
router.get("/search", searchAuditLogsHandler);

router.get("/", async (req, res, next) => {
try {
const parseResult = auditQuerySchema.safeParse(req.query);
if (!parseResult.success) {
throw RouteErrorFactory.validation(
parseResult.error.issues[0]?.message ?? "invalid query parameters",
);
}

const filters = parseResult.data;
const page = await getAuditLogs(filters);

res.json({
data: page.data,
nextCursor: page.nextCursor,
});
} catch (e) {
next(e);
}
});

return router;
}

export const adminAuditRouter = createAdminAuditRouter();import { Router } from "express";
import { rateLimit } from "express-rate-limit";
import { z } from "zod";
import { requireAdmin } from "../../middleware/requireAdmin";
import { getAuditLogs } from "../../repositories/auditLogRepo";
import { RouteErrorFactory } from "../../errors";
import { searchAuditLogsHandler } from "./audit/search";

export interface AdminAuditRouterOptions {
rateLimitPerMinute?: number;
}

const auditQuerySchema = z.object({
action: z.string().optional(),
actor: z.string().optional(),
startDate: z.string()
.datetime({ message: "startDate must be a valid ISO 8601 datetime string" })
.transform((val) => new Date(val))
.optional(),
endDate: z.string()
.datetime({ message: "endDate must be a valid ISO 8601 datetime string" })
.transform((val) => new Date(val))
.optional(),
cursor: z.string().optional(),
limit: z.string()
.regex(/^\d+$/, { message: "limit must be a positive integer" })
.transform((val) => parseInt(val, 10))
.optional(),
});

export function createAdminAuditRouter(opts: AdminAuditRouterOptions = {}): Router {
const router = Router();
const limit = opts.rateLimitPerMinute ?? 60;
Expand All @@ -47,6 +121,9 @@ export function createAdminAuditRouter(opts: AdminAuditRouterOptions = {}): Rout

router.use(requireAdmin);

// Mount search handler to clear unused variable lint error
router.get("/search", searchAuditLogsHandler);

router.get("/", async (req, res, next) => {
try {
const parseResult = auditQuerySchema.safeParse(req.query);
Expand Down
File renamed without changes.
26 changes: 18 additions & 8 deletions src/routes/admin/markets.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Router, type Request } from "express";
import { Router, type Request, type Response, type NextFunction } from "express";
import { rateLimit } from "express-rate-limit";
import { z } from "zod";
import { requireAdmin } from "../../middleware/requireAdmin";
Expand All @@ -11,6 +11,15 @@ import {
} from "../../services/marketFeatureService";
import { RouteErrorFactory } from "../../errors";

// Define custom interface for request with user context
interface AuthenticatedAdminRequest extends Request {
adminAddress?: string;
user?: {
stellarAddress: string;
[key: string]: unknown;
};
}

function extractClientIp(req: Request): string {
const forwarded = req.headers["x-forwarded-for"];
if (typeof forwarded === "string") {
Expand Down Expand Up @@ -56,12 +65,12 @@ export function createAdminMarketsRouter(
router.use(requireAdmin);

const handle = async (
req: import("express").Request,
res: import("express").Response,
req: AuthenticatedAdminRequest,
res: Response,
operation: "feature" | "unfeature",
): Promise<void> => {
const parsed = paramsSchema.safeParse(req.params);
const requestId = requestIdOf({ id: req.id });
const requestId = requestIdOf({ id: (req as Record<string, unknown>).id });

if (!parsed.success) {
throw RouteErrorFactory.validation("Invalid market ID");
Expand Down Expand Up @@ -91,15 +100,15 @@ export function createAdminMarketsRouter(

router.post("/:id/feature", async (req, res, next) => {
try {
await handle(req, res, "feature");
await handle(req as AuthenticatedAdminRequest, res, "feature");
} catch (err) {
next(err);
}
});

router.delete("/:id/feature", async (req, res, next) => {
try {
await handle(req, res, "unfeature");
await handle(req as AuthenticatedAdminRequest, res, "unfeature");
} catch (err) {
next(err);
}
Expand All @@ -112,7 +121,7 @@ export function createAdminMarketsRouter(
})
.strict();

router.post("/disable", async (req: any, res, next) => {
router.post("/disable", async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = disableBodySchema.safeParse(req.body);
if (!parsed.success) {
Expand All @@ -122,7 +131,8 @@ export function createAdminMarketsRouter(
}

const { marketId, reason } = parsed.data;
const adminAddress = req.user!.stellarAddress;
const adminReq = req as AuthenticatedAdminRequest;
const adminAddress = adminReq.user?.stellarAddress ?? adminReq.adminAddress ?? "";

const updated = await disableMarket(marketId, reason, adminAddress);

Expand Down
6 changes: 3 additions & 3 deletions src/routes/predictions/cancel.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { authenticate } from '../../middleware/auth';
import { db } from '../../db';
import { predictions, markets, users } from '../../db/schema';
import { predictions, users } from '../../db/schema';
import { eq, and } from 'drizzle-orm';
import { logger } from '../../logging';

Expand All @@ -20,7 +20,7 @@ router.post('/:id/cancel', authenticate, async (req, res) => {
// 1. Find the prediction
const prediction = await db.query.predictions.findFirst({
where: and(
eq(predictions.id, parseInt(id)),
eq(predictions.id, parseInt(id, 10)),
eq(predictions.userId, userId)
),
with: {
Expand Down Expand Up @@ -120,4 +120,4 @@ router.post('/:id/cancel', authenticate, async (req, res) => {
}
});

export default router;
export default router;
8 changes: 8 additions & 0 deletions src/schemas/feature-flags.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { z } from 'zod';

export const featureFlagsQuerySchema = z.object({
environment: z.enum(['development', 'testnet', 'mainnet']).optional(),
clientVersion: z.string().optional(),
});

export type FeatureFlagsQuery = z.infer<typeof featureFlagsQuerySchema>;
Loading
Loading