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
24 changes: 8 additions & 16 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion src/__tests__/routes/leaderboard.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, jest } from "@jest/globals";
import request from "supertest";
import express from "express";
import { leaderboardRouter, LeaderboardPeriod } from "../../routes/leaderboard";
import { leaderboardRouter } from "../../routes/leaderboard";
import * as leaderboardService from "../../services/leaderboardService";

// Mock the service
Expand Down
10 changes: 8 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { env } from "./config/env";
import { logger } from "./config/logger";
import { metricsMiddleware } from "./metrics/httpMetrics";
import { idempotency } from "./middleware/idempotency";
import { apiVersionMiddleware } from "./middleware/apiVersion";
import { defaultBodyLimitMiddleware, webhookBodyLimitMiddleware } from "./middleware/bodyLimit";
import { healthRouter } from "./routes/health";
import dependenciesRouter from "./routes/healthz/dependencies";
Expand All @@ -20,7 +21,9 @@ import { notificationsRouter } from "./routes/notifications";
import { socialRouter } from "./routes/social";
import { adminAuditRouter } from "./routes/admin/audit";
import { adminMarketsRouter } from "./routes/admin/markets";
import { devicesRouter } from "./routes/devices";
import { errorHandler } from "./middleware/errorHandler";
import { startIndexerHealthProbe, stopIndexerHealthProbe } from "./jobs/indexerHealthProbe";
import { requestContextStorage } from "./lib/requestContext";
import { REQUEST_ID_HEADER } from "./lib/http";
import { register } from "./metrics/registry";
Expand Down Expand Up @@ -55,6 +58,7 @@ export function createApp(_options?: unknown): express.Express {

app.use(helmet());
app.use("/api/admin/webhooks", webhookBodyLimitMiddleware);
app.use(apiVersionMiddleware);
app.use(defaultBodyLimitMiddleware);

app.use(
Expand Down Expand Up @@ -127,6 +131,7 @@ export function createApp(_options?: unknown): express.Express {
if (require.main === module) {
const app = createApp();
let webhookWorker: WebhookWorker | null = null;
let probeHandle: NodeJS.Timeout | null = null;

const stopWorkers = async (): Promise<void> => {
logger.info("Stopping queue workers");
Expand All @@ -145,6 +150,7 @@ if (require.main === module) {
marketResolverWorker.start();
backupVerificationWorker.start();
reconciliationWorker.start();
probeHandle = startIndexerHealthProbe();

app.listen(env.PORT, () => {
logger.info({ port: env.PORT, env: env.NODE_ENV }, "predictify-backend listening");
Expand All @@ -158,7 +164,7 @@ if (require.main === module) {
process.exit(1);
}, 5000).unref();

stopIndexerHealthProbe(probeHandle);
if (probeHandle) stopIndexerHealthProbe(probeHandle);
stopScheduler();
await closeDb();
clearTimeout(forceExit);
Expand All @@ -167,7 +173,7 @@ if (require.main === module) {

process.on("SIGINT", () => {
logger.info("SIGINT received, shutting down gracefully");
stopIndexerHealthProbe(probeHandle);
if (probeHandle) stopIndexerHealthProbe(probeHandle);
stopScheduler();
process.exit(0);
});
Expand Down
6 changes: 6 additions & 0 deletions src/metrics/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,9 @@ export const settleConfirmerFailedTotal = new Counter({
help: "Total number of claims permanently marked as failed by the settle-confirmer",
registers: [register],
});

export const indexerLagLedgers = new Gauge({
name: "indexer_lag_ledgers",
help: "Current indexer lag in number of ledgers",
registers: [register],
});
70 changes: 70 additions & 0 deletions src/middleware/apiVersion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* X-Api-Version middleware
*
* Flow:
* 1. Read X-Api-Version header (defaults to v1 if not provided).
* 2. Normalize version string: strip "v" prefix if present.
* 3. Validate against supported versions (v1, v2).
* 4. Reject unsupported versions with 400 BadRequest.
* 5. Attach normalized version to req.apiVersion for downstream handlers.
* 6. Echo the normalized version in response header.
*/

import type { NextFunction, Request, Response } from "express";

export const API_VERSION_HEADER = "x-api-version";
export const DEFAULT_API_VERSION = "v1";
export const SUPPORTED_VERSIONS = ["v1", "v2"] as const;

type SupportedVersion = (typeof SUPPORTED_VERSIONS)[number];

/**
* Normalize a version string to canonical form (e.g., "2" -> "v2", "v1" -> "v1").
* Returns undefined if invalid format.
*/
function normalizeVersion(raw: string): SupportedVersion | undefined {
const trimmed = raw.trim().toLowerCase();
// Match "v1", "v2", "1", "2" etc.
const match = trimmed.match(/^v?(\d+)$/);
if (!match) return undefined;
const normalized = `v${match[1]}`;
if (SUPPORTED_VERSIONS.includes(normalized as SupportedVersion)) {
return normalized as SupportedVersion;
}
return undefined;
}

export function apiVersionMiddleware(
req: Request,
res: Response,
next: NextFunction,
): void {
// Get header (case-insensitive)
const raw = req.headers[API_VERSION_HEADER];
const headerValue = Array.isArray(raw) ? raw[0] : raw;

// Default to v1 if not provided
const versionString = headerValue ?? DEFAULT_API_VERSION;

// Normalize and validate
const resolvedVersion = normalizeVersion(versionString);

if (!resolvedVersion) {
// Unsupported version
res.status(400).json({
error: {
code: "BadRequest",
message: `Unsupported API version: "${versionString}". Supported versions: ${SUPPORTED_VERSIONS.join(", ")}`,
},
});
return;
}

// Attach to request for downstream handlers
(req as Request & { apiVersion?: string }).apiVersion = resolvedVersion;

// Echo in response header
res.setHeader(API_VERSION_HEADER, resolvedVersion);

next();
}
2 changes: 0 additions & 2 deletions src/middleware/requireAdmin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@

/* eslint-disable @typescript-eslint/no-namespace */
/**
* requireAdmin — Express middleware that enforces admin-only access.
*
Expand Down
4 changes: 2 additions & 2 deletions src/routes/adminWebhooks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Router } from "express";
import { requireAdmin } from "../middleware/requireAdmin";
import type { WebhookDispatcher } from "../services/webhookDispatcher";
import type { IWebhookDispatcher } from "../services/webhookDispatcher";
import type { DlqRow, WebhookStore } from "../services/webhookStore";

/**
Expand All @@ -14,7 +14,7 @@ import type { DlqRow, WebhookStore } from "../services/webhookStore";
*/
export interface AdminWebhookDeps {
store: WebhookStore;
dispatcher: WebhookDispatcher;
dispatcher: IWebhookDispatcher;
}

/** Shape the DLQ row for the API: payload bytes are exposed as base64, never raw. */
Expand Down
1 change: 0 additions & 1 deletion src/routes/markets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { rateLimitAnon } from "../middleware/rateLimitAnon";
import { listFeaturedMarkets } from "../services/marketFeatureService";
import { z } from "zod";
import { logger } from "../../config/logger";
import { recommendationsRouter } from "./recommendations";

export const marketsRouter = Router();

Expand Down
2 changes: 0 additions & 2 deletions src/routes/users.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@

/* eslint-disable @typescript-eslint/no-unused-vars */
import { Router, Request, Response, NextFunction } from "express";
import { z } from "zod";
import { getUserByAddress, getUserPredictions, getCurrentUserProfile, getUserProfile } from "../services/userService";
Expand Down
1 change: 0 additions & 1 deletion src/services/adminHealthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
* real database or network connection.
*/

import type { Pool } from "pg";
import { env } from "../config/env";
import { logger } from "../config/logger";

Expand Down
4 changes: 2 additions & 2 deletions src/services/fraudService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,9 +463,9 @@ export async function runFraudScan(
// ──────────────────────────────────────────────────────────────────────────────

export class DrizzleFraudRepo implements FraudRepo {
// Use `any` to remain compatible with the codebase's drizzle helper typing
// Use `unknown` to remain compatible with the codebase's drizzle helper typing
// (other services here do the same — see DrizzleMarketResolutionRepo).
constructor(private readonly db: any = defaultDb) {}
constructor(private readonly db: unknown = defaultDb) {}

async loadRecentPredictions(opts: {
since: Date;
Expand Down
12 changes: 8 additions & 4 deletions src/services/leaderboardService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,19 @@ export type LeaderboardEntry = AddressAggregate;
*/
function getMaterializationViewName(period: LeaderboardPeriod): string {
switch (period) {
case LeaderboardPeriod.ALL_TIME:
case LeaderboardPeriod.ALL_TIME: {
return "leaderboard_mv";
case LeaderboardPeriod.MONTHLY:
}
case LeaderboardPeriod.MONTHLY: {
return "leaderboard_monthly_mv";
case LeaderboardPeriod.WEEKLY:
}
case LeaderboardPeriod.WEEKLY: {
return "leaderboard_weekly_mv";
default:
}
default: {
const _exhaustive: never = period;
throw new Error(`Unknown period: ${_exhaustive}`);
}
}
}

Expand Down
Loading
Loading