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
2 changes: 1 addition & 1 deletion docs/metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Collected by `prom-client`'s `collectDefaultMetrics`:
|---|---|---|---|
| `http_request_duration_seconds` | Histogram | `route`, `status` | HTTP request duration in seconds, bucketed |
| `stats_request_duration_seconds` | Histogram | `route`, `status` | `/api/stats` request duration in seconds, bucketed. Observed for every request including ones rejected by rate limiting |
| `markets_request_duration_seconds` | Histogram | `endpoint`, `method`, `status` | `/api/markets` request duration in seconds, bucketed per endpoint (`list`, `search`, `featured`, `upcoming`, `get`, `patch`) |
| `markets_request_duration_seconds` | Histogram | `route`, `method`, `status` | `/api/markets` request duration in seconds, bucketed per route template, method, and status code |
| `markets_requests_total` | Counter | `endpoint`, `method`, `status` | Total `/api/markets` requests, segmented per endpoint (`list`, `search`, `featured`, `upcoming`, `get`, `patch`) |
| `indexer_polls_total` | Counter | — | Total indexer poll cycles completed |
| `webhook_deliveries_total` | Counter | `status` | Webhook deliveries by outcome |
Expand Down
8 changes: 8 additions & 0 deletions drizzle/migrations/0026_audit_logs_entity_columns.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- Migration: add entity_type and entity_id columns to audit_logs
-- These columns store the type and primary key of the entity being
-- mutated, enabling filtered audit queries by entity without
-- parsing the before/after JSONB state snapshots.

ALTER TABLE "audit_logs"
ADD COLUMN IF NOT EXISTS "entity_type" text,
ADD COLUMN IF NOT EXISTS "entity_id" text;
Binary file added lint-output.txt
Binary file not shown.
192 changes: 152 additions & 40 deletions src/__tests__/routes/subscriptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
};

// Public-facing serialisation omits the secret field
const publicSubscription = (() => {

Check failure on line 75 in src/__tests__/routes/subscriptions.test.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

'publicSubscription' is assigned a value but never used
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { secret: _s, ...pub } = mockSubscription;
return pub;
Expand Down Expand Up @@ -471,51 +471,163 @@
});
});

describe("Mutations", () => {
it("POST /api/subscriptions creates and audits", async () => {
const newRow = { id: "sub-2", url: "https://x", events: [], active: true };
(db.insert as jest.Mock).mockReturnThis();
(db.values as jest.Mock).mockReturnThis();
(db.returning as jest.Mock).mockResolvedValueOnce([newRow]);
describe("Mutations", () => {
it("POST /api/subscriptions creates and audits with entity type and id", async () => {
const newRow = { id: "sub-2", url: "https://x", events: [], active: true };
(db.insert as jest.Mock).mockReturnThis();
(db.values as jest.Mock).mockReturnThis();
(db.returning as jest.Mock).mockResolvedValueOnce([newRow]);

const response = await request(app).post("/api/subscriptions").send({ url: newRow.url, events: newRow.events });

expect(response.status).toBe(201);
expect(response.body.data).toEqual(JSON.parse(JSON.stringify(newRow)));
const { createAuditLog } = require("../../services/auditService");

Check failure on line 485 in src/__tests__/routes/subscriptions.test.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

A `require()` style import is forbidden
expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({
action: "admin.subscription.create",
entityType: "Subscription",
entityId: newRow.id,
afterState: newRow,
beforeState: null,
}));
});

it("PATCH /api/subscriptions/:id updates and audits with entity type and id", async () => {
const existing = { id: "sub-3", url: "https://old", events: [], active: true };
const updated = { ...existing, url: "https://new" };

(db.from as jest.Mock).mockResolvedValueOnce([existing]);
(db.update as jest.Mock).mockReturnThis();
(db.set as jest.Mock).mockReturnThis();
(db.returning as jest.Mock).mockResolvedValueOnce([updated]);

const response = await request(app).patch(`/api/subscriptions/${existing.id}`).send({ url: updated.url });

expect(response.status).toBe(200);
expect(response.body.data).toEqual(JSON.parse(JSON.stringify(updated)));
const { createAuditLog } = require("../../services/auditService");

Check failure on line 508 in src/__tests__/routes/subscriptions.test.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

A `require()` style import is forbidden
expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({
action: "admin.subscription.update",
entityType: "Subscription",
entityId: existing.id,
beforeState: existing,
afterState: updated,
}));
});

it("DELETE /api/subscriptions/:id deletes and audits with entity type and id", async () => {
const existing = { id: "sub-4", url: "https://del", events: [], active: true };

(db.from as jest.Mock).mockResolvedValueOnce([existing]);
(db.delete as jest.Mock).mockResolvedValueOnce({ rowCount: 1 });

const response = await request(app).delete(`/api/subscriptions/${existing.id}`);

expect(response.status).toBe(204);
const { createAuditLog } = require("../../services/auditService");

Check failure on line 527 in src/__tests__/routes/subscriptions.test.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

A `require()` style import is forbidden
expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({
action: "admin.subscription.delete",
entityType: "Subscription",
entityId: existing.id,
beforeState: existing,
afterState: null,
}));
});

it("POST /api/subscriptions includes correlationId in audit log", async () => {
const newRow = { id: "sub-5", url: "https://x", events: [], active: true };
(db.insert as jest.Mock).mockReturnThis();
(db.values as jest.Mock).mockReturnThis();
(db.returning as jest.Mock).mockResolvedValueOnce([newRow]);

await request(app).post("/api/subscriptions").send({ url: newRow.url, events: newRow.events });

const { createAuditLog } = require("../../services/auditService");

Check failure on line 545 in src/__tests__/routes/subscriptions.test.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

A `require()` style import is forbidden
expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({
correlationId: expect.any(String),
}));
});

it("POST /api/subscriptions includes endpoint metadata in audit log", async () => {
const newRow = { id: "sub-6", url: "https://x", events: [], active: true };
(db.insert as jest.Mock).mockReturnThis();
(db.values as jest.Mock).mockReturnThis();
(db.returning as jest.Mock).mockResolvedValueOnce([newRow]);

await request(app).post("/api/subscriptions").send({ url: newRow.url, events: newRow.events });

const { createAuditLog } = require("../../services/auditService");

Check failure on line 559 in src/__tests__/routes/subscriptions.test.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

A `require()` style import is forbidden
expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({
metadata: expect.objectContaining({ endpoint: "/api/subscriptions" }),
}));
});

it("PATCH /api/subscriptions/:id redacts secret from beforeState in audit log", async () => {
const existing = { id: "sub-7", url: "https://old", events: [], active: true, secret: "super-secret" };
const updated = { ...existing, url: "https://new" };

(db.from as jest.Mock).mockResolvedValueOnce([existing]);
(db.update as jest.Mock).mockReturnThis();
(db.set as jest.Mock).mockReturnThis();
(db.returning as jest.Mock).mockResolvedValueOnce([updated]);

await request(app).patch(`/api/subscriptions/${existing.id}`).send({ url: updated.url });

const { createAuditLog } = require("../../services/auditService");

Check failure on line 576 in src/__tests__/routes/subscriptions.test.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

A `require()` style import is forbidden
const callArgs = createAuditLog.mock.calls[0][0];
expect(callArgs.beforeState.secret).toBe("[REDACTED]");
});

it("PATCH /api/subscriptions/:id redacts secret from afterState in audit log", async () => {
const existing = { id: "sub-8", url: "https://old", events: [], active: true };
const updated = { ...existing, url: "https://new", secret: "super-secret" };

(db.from as jest.Mock).mockResolvedValueOnce([existing]);
(db.update as jest.Mock).mockReturnThis();
(db.set as jest.Mock).mockReturnThis();
(db.returning as jest.Mock).mockResolvedValueOnce([updated]);

await request(app).patch(`/api/subscriptions/${existing.id}`).send({ url: updated.url });

const { createAuditLog } = require("../../services/auditService");

Check failure on line 592 in src/__tests__/routes/subscriptions.test.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

A `require()` style import is forbidden
const callArgs = createAuditLog.mock.calls[0][0];
expect(callArgs.afterState.secret).toBe("[REDACTED]");
});

it("GET /api/subscriptions does not create an audit record", async () => {
(db.from as jest.Mock).mockResolvedValueOnce([]);

await request(app).get("/api/subscriptions");

const { createAuditLog } = require("../../services/auditService");

Check failure on line 602 in src/__tests__/routes/subscriptions.test.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

A `require()` style import is forbidden
expect(createAuditLog).not.toHaveBeenCalled();
});

it("POST /api/subscriptions does not audit on validation failure", async () => {
await request(app).post("/api/subscriptions").send({ url: "http://not-https.com", events: [] });

const { createAuditLog } = require("../../services/auditService");

Check failure on line 609 in src/__tests__/routes/subscriptions.test.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

A `require()` style import is forbidden
expect(createAuditLog).not.toHaveBeenCalled();
});

const response = await request(app).post("/api/subscriptions").send({ url: newRow.url, events: newRow.events });
it("PATCH /api/subscriptions/:id does not audit when subscription not found", async () => {
(db.from as jest.Mock).mockResolvedValueOnce([]);

expect(response.status).toBe(201);
expect(response.body.data).toEqual(JSON.parse(JSON.stringify(newRow)));
const { createAuditLog } = require("../../services/auditService");
expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "admin.subscription.create", afterState: newRow }));
});

it("PATCH /api/subscriptions/:id updates and audits", async () => {
const existing = { id: "sub-3", url: "https://old", events: [], active: true };
const updated = { ...existing, url: "https://new" };

(db.from as jest.Mock).mockResolvedValueOnce([existing]);
(db.update as jest.Mock).mockReturnThis();
(db.set as jest.Mock).mockReturnThis();
(db.returning as jest.Mock).mockResolvedValueOnce([updated]);
await request(app).patch("/api/subscriptions/123e4567-e89b-12d3-a456-426614174000").send({ url: "https://new.example.com" });

const response = await request(app).patch(`/api/subscriptions/${existing.id}`).send({ url: updated.url });

expect(response.status).toBe(200);
expect(response.body.data).toEqual(JSON.parse(JSON.stringify(updated)));
const { createAuditLog } = require("../../services/auditService");
expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "admin.subscription.update", beforeState: existing, afterState: updated }));
});
const { createAuditLog } = require("../../services/auditService");
expect(createAuditLog).not.toHaveBeenCalled();
});

it("DELETE /api/subscriptions/:id deletes and audits", async () => {
const existing = { id: "sub-4", url: "https://del", events: [], active: true };
it("DELETE /api/subscriptions/:id does not audit when subscription not found", async () => {
(db.from as jest.Mock).mockResolvedValueOnce([]);

(db.from as jest.Mock).mockResolvedValueOnce([existing]);
(db.delete as jest.Mock).mockResolvedValueOnce({ rowCount: 1 });

const response = await request(app).delete(`/api/subscriptions/${existing.id}`);

expect(response.status).toBe(204);
const { createAuditLog } = require("../../services/auditService");
expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "admin.subscription.delete", beforeState: existing, afterState: null }));
});
});
await request(app).delete("/api/subscriptions/123e4567-e89b-12d3-a456-426614174000");

const { createAuditLog } = require("../../services/auditService");
expect(createAuditLog).not.toHaveBeenCalled();
});
});
});

// ============================================================================
Expand Down
2 changes: 2 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,8 @@ export const auditLogs = pgTable(
walletAddress: text("wallet_address"),
ip: text("ip").notNull(),
correlationId: text("correlation_id").notNull(),
entityType: text("entity_type"),
entityId: text("entity_id"),
beforeState: jsonb("before_state"),
afterState: jsonb("after_state"),
rateLimitContext: jsonb("rate_limit_context"),
Expand Down
42 changes: 42 additions & 0 deletions src/metrics/marketsMetrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { Request, Response, NextFunction } from "express";
import { marketsRequestDuration } from "./registry";

function sanitizeRoute(route: string): string {
return route
.replace(/\/[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}/gi, "/:id")
.replace(/\/\d+/g, "/:id");
}

/**
* Records request latency for /api/markets into the
* `markets_request_duration_seconds` histogram (see metrics/registry.ts),
* segmented by route template, method, and status code.
*
* Registered ahead of rate limiting / auth on the router so that latency
* for rejected requests (e.g. 429 from rateLimitAnon) is captured as well,
* not just successful 200s.
*/
export function marketsMetricsMiddleware(
req: Request,
res: Response,
next: NextFunction,
): void {
const start = process.hrtime.bigint();

res.on("finish", () => {
const durationNs = Number(process.hrtime.bigint() - start);
const durationSec = durationNs / 1e9;

const routeTemplate: string = req.route?.path
? (req.baseUrl || "") + req.route.path
: req.path;

const route = sanitizeRoute(routeTemplate);
const method = req.method;
const status = String(res.statusCode);

marketsRequestDuration.observe({ route, method, status }, durationSec);
});

next();
}
4 changes: 2 additions & 2 deletions src/metrics/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ export const signupAnomalyTopScore = new Gauge({

export const marketsRequestDuration = new Histogram({
name: "markets_request_duration_seconds",
help: "Duration of /api/markets requests in seconds, segmented by endpoint, method, and status code",
labelNames: ["endpoint", "method", "status"] as const,
help: "Duration of /api/markets requests in seconds, segmented by route, method, and status code",
labelNames: ["route", "method", "status"] as const,
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10],
registers: [register],
});
Expand Down
18 changes: 10 additions & 8 deletions src/repositories/auditLogRepo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ export interface AuditLogFilters {
}

export interface AuditLogItem {
id: string;
action: string;
walletAddress: string | null;
ip: string;
correlationId: string;
rateLimitContext: unknown;
createdAt: Date;
}
id: string;
action: string;
walletAddress: string | null;
ip: string;
correlationId: string;
entityType: string | null;
entityId: string | null;
rateLimitContext: unknown;
createdAt: Date;
}

/**
* Retrieve a paginated list of audit logs matching the given filter criteria.
Expand Down
24 changes: 8 additions & 16 deletions src/routes/markets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import { accessLog } from "../../middleware/accessLog";
import { marketsCors } from "../../middleware/cors";
import { listFeaturedMarkets } from "../../services/marketFeatureService";
import { logger } from "../../config/logger";
import type { RequestHandler } from "express";
import { RouteErrorFactory } from "../../errors";
import { conditionalGet } from "../../middleware/etag";
import { marketsMetricsMiddleware } from "../../metrics/marketsMetrics";
import { recommendationsRouter } from "./recommendations";
import { trendingRouter } from "./trending";
import { tagsRouter } from "./tags";
Expand All @@ -39,19 +39,11 @@ import {

export const marketsRouter = Router();

/**
* Simple pass-through wrapper for market route metrics tracking.
* Wraps a named route handler so metrics middleware can distinguish
* list/search/featured/get/patch operations.
*/
function trackMarketsMetrics(_op: string): RequestHandler {
return (_req, _res, next) => next();
}

// Enforce CORS allowlist early so unapproved origins are rejected
// before any processing (preflight responses cached via Access-Control-Max-Age).
marketsRouter.use(marketsCors());
marketsRouter.use(accessLog);
marketsRouter.use(marketsMetricsMiddleware);
marketsRouter.use(rateLimitAnon);
marketsRouter.use(requestTimeout(10000));
marketsRouter.use("/tags", tagsRouter);
Expand All @@ -64,7 +56,7 @@ marketsRouter.use("/:id/audit", marketAuditRouter);
marketsRouter.use("/:id/disputes", disputesRouter);
marketsRouter.use("/", predictionsRouter);

marketsRouter.get("/search", trackMarketsMetrics("search"), async (req, res, next) => {
marketsRouter.get("/search", async (req, res, next) => {
const reqId = String((req as { id?: unknown }).id ?? "anon");
try {
const parsed = searchMarketsQuerySchema.safeParse(req.query);
Expand Down Expand Up @@ -123,7 +115,7 @@ marketsRouter.get("/search", trackMarketsMetrics("search"), async (req, res, nex
}
});

marketsRouter.get("/", trackMarketsMetrics("list"), async (req, res, next) => {
marketsRouter.get("/", async (req, res, next) => {
const reqId = String((req as { id?: unknown }).id ?? "anon");
try {
const parsed = listMarketsQuerySchema.safeParse(req.query);
Expand Down Expand Up @@ -160,7 +152,7 @@ marketsRouter.get("/", trackMarketsMetrics("list"), async (req, res, next) => {
}
});

marketsRouter.get("/featured", trackMarketsMetrics("featured"), async (req, res, next) => {
marketsRouter.get("/featured", async (req, res, next) => {
const reqId = String((req as { id?: unknown }).id ?? "anon");
try {
const parsed = featuredMarketsQuerySchema.safeParse(req.query);
Expand All @@ -183,7 +175,7 @@ marketsRouter.get("/featured", trackMarketsMetrics("featured"), async (req, res,
}
});

marketsRouter.get("/upcoming", trackMarketsMetrics("upcoming"), async (req, res, next) => {
marketsRouter.get("/upcoming", async (req, res, next) => {
const reqId = String((req as { id?: unknown }).id ?? "anon");
try {
const parsed = upcomingMarketsQuerySchema.safeParse(req.query);
Expand Down Expand Up @@ -213,7 +205,7 @@ marketsRouter.get("/upcoming", trackMarketsMetrics("upcoming"), async (req, res,
}
});

marketsRouter.get("/:id", trackMarketsMetrics("get"), async (req, res, next) => {
marketsRouter.get("/:id", async (req, res, next) => {
const reqId = String((req as { id?: unknown }).id ?? "anon");

try {
Expand Down Expand Up @@ -254,7 +246,7 @@ marketsRouter.get("/:id", trackMarketsMetrics("get"), async (req, res, next) =>
}
});

marketsRouter.patch("/:id", trackMarketsMetrics("patch"), requireAdmin, async (req: AuthenticatedRequest, res, next) => {
marketsRouter.patch("/:id", requireAdmin, async (req: AuthenticatedRequest, res, next) => {
const reqId = String((req as { id?: unknown }).id ?? "anon");
const adminAddress = req.user?.stellarAddress;

Expand Down
Loading
Loading