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
27 changes: 22 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 1 addition & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
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 @@ -19,7 +18,7 @@ import { usersRouter } from "./routes/users";
import { usersHealthRouter } from "./routes/users/health";
import { userPortfolioRouter } from "./routes/users/portfolio";
import { devicesRouter } from "./routes/devices";
import { adminFeatureFlagsRouter } from "./routes/admin/feature-flags";
import { adminFeatureFlagsRouter } from "./routes/admin/featureFlags";
import { adminUsersRouter } from "./routes/adminUsers";
import { leaderboardRouter } from "./routes/leaderboard";
import { createDocsRouter } from "./routes/docs";
Expand Down Expand Up @@ -126,7 +125,6 @@ export function createApp(): express.Express {
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/schema-versions", adminSchemaVersionsRouter);
app.use("/api/admin/rate-limit", adminRateLimitInspectRouter);
Expand Down
12 changes: 9 additions & 3 deletions src/middleware/accessLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,7 @@ function resolveIp(req: Request): string {
* Express middleware — structured access logger with correlation IDs.
*
* Stamps `res.locals.correlationId` and hooks `res.on("finish")` to emit
* a `users_access_log` log entry once the response has been flushed.
*
* a `users_access_log` or `auth_access_log` log entry once the response has been flushed.
* Always calls `next()` so it is safe to mount as the first middleware on
* any router without affecting the handler chain.
*/
Expand All @@ -111,6 +110,13 @@ export function accessLog(req: Request, res: Response, next: NextFunction): void
// Emit the access-log entry after the response has been flushed.
// Using "finish" (not "close") ensures the statusCode is already set.
res.on("finish", () => {
let logName = "access_log";
if (req.originalUrl.startsWith("/api/users")) {
logName = "users_access_log";
} else if (req.originalUrl.startsWith("/api/auth")) {
logName = "auth_access_log";
}

const durationMs = Date.now() - startMs;
logger.info(
{
Expand All @@ -121,7 +127,7 @@ export function accessLog(req: Request, res: Response, next: NextFunction): void
durationMs,
ip,
},
"users_access_log",
logName,
);
});

Expand Down
58 changes: 58 additions & 0 deletions src/middleware/timeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Request, Response, NextFunction } from "express";
import { RouteErrorFactory } from "../errors";

/**
* Creates an Express middleware that enforces a maximum request duration.
* If the route handler does not finish before `timeoutMs`, the request is
* aborted gracefully with a 408 Request Timeout error envelope.
*
* @param timeoutMs - Time in milliseconds before the request times out
*/
export function requestTimeout(timeoutMs: number) {
return (req: Request, res: Response, next: NextFunction) => {
// We attach an abort controller to the response locals so that
// downstream services can optionally listen to res.locals.abortSignal
const controller = new AbortController();
res.locals.abortSignal = controller.signal;

// Handle client disconnects to cancel downstream work
req.on("close", () => {
controller.abort();
});

const timer = setTimeout(() => {
controller.abort();

if (!res.headersSent) {
// Use RouteErrorFactory to throw a standardized error envelope.
// The global error handler or this block will send it out.
// To be safe and avoid double-calling next(), we can just send the error here,
// but it's cleaner to let the global error handler format it.
// Wait, if we call next() from a timeout, we have to ensure we don't
// clash with the active route handler.
// We will just directly respond to guarantee the 408 is sent.
const error = RouteErrorFactory.internal("Request timeout exceeded");
// We change kind to RequestTimeout manually or just use 408 status.
// Since RouteErrorFactory doesn't have timeout, we can build a raw envelope.
const correlationId = (res.locals.correlationId as string) || "unknown";
return res.status(408).json({
error: {
code: "timeout",
message: "Request timeout exceeded",
requestId: correlationId,
}
});
}
}, timeoutMs);

res.on("finish", () => {
clearTimeout(timer);
});

res.on("close", () => {
clearTimeout(timer);
});

next();
};
}
75 changes: 0 additions & 75 deletions src/routes/admin/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,79 +73,4 @@ export function createAdminAuditRouter(opts: AdminAuditRouterOptions = {}): Rout
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;

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();
Loading