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
11 changes: 10 additions & 1 deletion backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { createSwaggerSpec } from "./swagger.js";

import createPaymentsRouter from "./routes/payments.js";
import createMerchantsRouter from "./routes/merchants.js";
import metricsRouter from "./routes/metrics.js";
import createMetricsRouter from "./routes/metrics.js";
import webhooksRouter from "./routes/webhooks.js";
import prometheusRouter from "./routes/prometheus.js";
import sep0001Router from "./routes/sep0001.js";
Expand All @@ -35,6 +35,7 @@ import {
createMerchantRegistrationRateLimit,
createSep10ChallengeRateLimit,
createSep10VerifyRateLimit,
createDashboardMetricsRateLimit,
} from "./lib/rate-limit.js";
import { versionDeprecationMiddleware } from "./lib/version-deprecation.js";

Expand Down Expand Up @@ -263,6 +264,14 @@ export async function createApp({ redisClient }) {
sep10VerifyRateLimit: createSep10VerifyRateLimit({ store: sep10RateLimitStore }),
});

const dashboardMetricsRateLimit = createDashboardMetricsRateLimit({
store: redisAvailable
? createRedisRateLimitStore({ client: redisClient, prefix: "rl:dashboard:" })
: undefined,
});

const metricsRouter = createMetricsRouter({ dashboardMetricsRateLimit });

// x402 pay-per-request on payment creation endpoints (custom middleware flow)
const x402Provider = process.env.X402_PROVIDER_PUBLIC_KEY;
const x402Enabled = Boolean(x402Provider && process.env.X402_JWT_SECRET);
Expand Down
52 changes: 52 additions & 0 deletions backend/src/lib/rate-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ export const SEP10_VERIFY_RATE_LIMIT_WINDOW_MS = Number(
export const SEP10_VERIFY_RATE_LIMIT_MAX = Number(
process.env.SEP10_VERIFY_RATE_LIMIT_MAX || 10,
);
export const DASHBOARD_METRICS_RATE_LIMIT_WINDOW_MS = Number(
process.env.DASHBOARD_METRICS_RATE_LIMIT_WINDOW_MS || 60 * 1000,
);
export const DASHBOARD_METRICS_RATE_LIMIT_MAX = Number(
process.env.DASHBOARD_METRICS_RATE_LIMIT_MAX || 30,
);

function setStandardRateLimitHeaders(res, rateLimitState) {
if (!res || !rateLimitState) {
Expand Down Expand Up @@ -198,6 +204,52 @@ export function createSep10VerifyRateLimit({
});
}

export function getDashboardMetricsRateLimitKey(req) {
const merchantId =
typeof req?.merchant?.id === "string" && req.merchant.id.length > 0
? `merchant:${req.merchant.id}`
: null;
const apiKey =
typeof req?.headers?.["x-api-key"] === "string" &&
req.headers["x-api-key"].length > 0
? `api:${createHash("sha256").update(req.headers["x-api-key"]).digest("hex")}`
: null;
const ipKey = ipKeyGenerator(req?.ip ?? req?.socket?.remoteAddress ?? "unknown-ip");

return merchantId ?? apiKey ?? `ip:${ipKey}`;
}

/**
* Per-merchant rate limit for the admin dashboard metrics endpoints
* (summary/revenue/volume). These queries aggregate over the full payments
* table, so unrestricted polling can add significant load (issue #927).
*/
export function createDashboardMetricsRateLimit({
store,
rateLimitFactory = rateLimit,
max = DASHBOARD_METRICS_RATE_LIMIT_MAX,
windowMs = DASHBOARD_METRICS_RATE_LIMIT_WINDOW_MS,
} = {}) {
return rateLimitFactory({
windowMs,
max,
message: {
error: "Too many dashboard requests, please try again later.",
code: "DASHBOARD_METRICS_RATE_LIMITED",
},
standardHeaders: true,
legacyHeaders: false,
validate: { ip: false },
keyGenerator: getDashboardMetricsRateLimitKey,
handler: (req, res, _next, options) => {
setStandardRateLimitHeaders(res, req.rateLimit);
res.status(options.statusCode).json(options.message);
},
store,
passOnStoreError: true,
});
}

export function createMerchantRegistrationRateLimit({
store,
rateLimitFactory = rateLimit,
Expand Down
61 changes: 61 additions & 0 deletions backend/src/lib/rate-limit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ vi.mock("redis", () => ({
}));

import {
createDashboardMetricsRateLimit,
createMerchantSecurityActionRateLimit,
createRedisRateLimitStore,
createSep10ChallengeRateLimit,
createSep10VerifyRateLimit,
createVerifyPaymentRateLimit,
DASHBOARD_METRICS_RATE_LIMIT_MAX,
DASHBOARD_METRICS_RATE_LIMIT_WINDOW_MS,
getDashboardMetricsRateLimitKey,
getMerchantSecurityActionRateLimitKey,
getSep10ChallengeRateLimitKey,
getSep10VerifyRateLimitKey,
Expand Down Expand Up @@ -156,6 +160,63 @@ describe("getMerchantSecurityActionRateLimitKey", () => {
});
});

describe("createDashboardMetricsRateLimit", () => {
it("passes the dashboard metrics config into express-rate-limit", () => {
const store = { kind: "redis-store" };
const middleware = vi.fn();
const rateLimitFactory = vi.fn(() => middleware);

const result = createDashboardMetricsRateLimit({ store, rateLimitFactory });

expect(result).toBe(middleware);
expect(rateLimitFactory).toHaveBeenCalledWith({
windowMs: DASHBOARD_METRICS_RATE_LIMIT_WINDOW_MS,
max: DASHBOARD_METRICS_RATE_LIMIT_MAX,
message: {
error: "Too many dashboard requests, please try again later.",
code: "DASHBOARD_METRICS_RATE_LIMITED",
},
standardHeaders: true,
legacyHeaders: false,
validate: { ip: false },
keyGenerator: expect.any(Function),
handler: expect.any(Function),
store,
passOnStoreError: true,
});
});
});

describe("getDashboardMetricsRateLimitKey", () => {
it("uses merchant ids when available", () => {
expect(
getDashboardMetricsRateLimitKey({
merchant: { id: "merchant-789" },
headers: {},
ip: "203.0.113.10",
}),
).toBe("merchant:merchant-789");
});

it("hashes api keys when merchant context is unavailable", () => {
expect(
getDashboardMetricsRateLimitKey({
headers: { "x-api-key": "dashboard-secret-key" },
ip: "203.0.113.10",
}),
).toMatch(/^api:[a-f0-9]{64}$/);
});

it("falls back to ip-based keys when no merchant or api key is available", () => {
expect(
getDashboardMetricsRateLimitKey({
headers: {},
ip: "203.0.113.10",
}),
).toBe("ip:203.0.113.10");
});
});

describe("SEP-10 rate limiters", () => {
it("builds challenge keys scoped to account and IP", () => {
const key = getSep10ChallengeRateLimitKey({
Expand Down
203 changes: 101 additions & 102 deletions backend/src/routes/metrics.js
Original file line number Diff line number Diff line change
@@ -1,117 +1,116 @@
import express from "express";
import { requireApiKeyAuth } from "../lib/auth.js";
import { withMerchantContext } from "../lib/db-rls.js";
import { validateRequest } from "../lib/validation.js";
import { metricsVolumeQuerySchema } from "../lib/request-schemas.js";
import { metricService } from "../services/metricService.js";
import { createDashboardMetricsRateLimit } from "../lib/rate-limit.js";

const router = express.Router();
const defaultDashboardMetricsRateLimit = createDashboardMetricsRateLimit();

/**
* @swagger
* /api/metrics/summary:
* get:
* summary: Get monthly revenue summary grouped by asset
* tags: [Metrics]
* security:
* - ApiKeyAuth: []
* Admin Dashboard Service routes (revenue summary, revenue by asset, volume
* over time). All routes require a signed, rate-limited API key request:
* - requireApiKeyAuth({ requireSignature: true }) enforces HMAC request
* signing via the existing x-api-signature/x-api-timestamp headers
* (issue #928).
* - dashboardMetricsRateLimit caps how often a merchant can poll these
* aggregate queries (issue #927).
*/
router.get("/metrics/summary", requireApiKeyAuth(), async (req, res, next) => {
try {
const pool = req.app.locals.pool;
const result = await metricService.getMonthlySummary(pool, req.merchant.id);
res.json(result);
} catch (err) {
next(err);
}
});
function createMetricsRouter({
dashboardMetricsRateLimit = defaultDashboardMetricsRateLimit,
} = {}) {
const router = express.Router();

/**
* @swagger
* /api/metrics/revenue:
* get:
* summary: Get aggregate revenue by asset
* tags: [Metrics]
* security:
* - ApiKeyAuth: []
*/
router.get("/metrics/revenue", requireApiKeyAuth(), async (req, res, next) => {
try {
const pool = req.app.locals.pool;
const result = await metricService.getRevenueByAsset(pool, req.merchant.id);
res.json(result);
} catch (err) {
next(err);
}
});

/**
* @swagger
* /api/metrics/volume:
* get:
* summary: Get per-asset daily volume for a time range
* tags: [Metrics]
* security:
* - ApiKeyAuth: []
*/
router.get("/metrics/volume", requireApiKeyAuth(), validateRequest({ query: metricsVolumeQuerySchema }), async (req, res, next) => {
try {
const pool = req.app.locals.pool;
const merchantId = req.merchant.id;
const VALID_RANGES = { "7D": 7, "30D": 30, "1Y": 365 };
const range = req.query.range;
const days = VALID_RANGES[range];

const query = `
SELECT
date_trunc('day', created_at) AS date,
asset,
SUM(amount) AS volume,
COUNT(*) AS count
FROM payments
WHERE merchant_id = $1
AND status = 'completed'
AND created_at >= NOW() - INTERVAL '${days} days'
GROUP BY 1, 2
ORDER BY 1 ASC, 2 ASC
`;

const { rows } = await pool.query(query, [merchantId]);
const assetSet = new Set(rows.map((row) => row.asset));
const assets = Array.from(assetSet);

const byDate = {};
for (const row of rows) {
const dateStr = row.date.toISOString().split("T")[0];
if (!byDate[dateStr]) {
byDate[dateStr] = { date: dateStr, count: 0 };
/**
* @swagger
* /api/metrics/summary:
* get:
* summary: Get monthly revenue summary grouped by asset
* tags: [Metrics]
* security:
* - ApiKeyAuth: []
* responses:
* 429:
* description: Rate limit exceeded
*/
router.get(
"/metrics/summary",
requireApiKeyAuth({ requireSignature: true }),
dashboardMetricsRateLimit,
async (req, res, next) => {
try {
const pool = req.app.locals.pool;
const result = await metricService.getMonthlySummary(pool, req.merchant.id);
res.json(result);
} catch (err) {
next(err);
}
},
);

byDate[dateStr][row.asset] = parseFloat(row.volume) || 0;
byDate[dateStr].count += parseInt(row.count, 10) || 0;
}

const now = new Date();
const result = [];
for (let i = days - 1; i >= 0; i -= 1) {
const day = new Date(now);
day.setDate(day.getDate() - i);
const dateStr = day.toISOString().split("T")[0];
const entry = byDate[dateStr] || { date: dateStr, count: 0 };

for (const asset of assets) {
if (entry[asset] === undefined) entry[asset] = 0;
/**
* @swagger
* /api/metrics/revenue:
* get:
* summary: Get aggregate revenue by asset
* tags: [Metrics]
* security:
* - ApiKeyAuth: []
* responses:
* 429:
* description: Rate limit exceeded
*/
router.get(
"/metrics/revenue",
requireApiKeyAuth({ requireSignature: true }),
dashboardMetricsRateLimit,
async (req, res, next) => {
try {
const pool = req.app.locals.pool;
const result = await metricService.getRevenueByAsset(pool, req.merchant.id);
res.json(result);
} catch (err) {
next(err);
}
},
);

result.push(entry);
}
/**
* @swagger
* /api/metrics/volume:
* get:
* summary: Get per-asset daily volume for a time range
* tags: [Metrics]
* security:
* - ApiKeyAuth: []
* responses:
* 429:
* description: Rate limit exceeded
*/
router.get(
"/metrics/volume",
requireApiKeyAuth({ requireSignature: true }),
dashboardMetricsRateLimit,
validateRequest({ query: metricsVolumeQuerySchema }),
async (req, res, next) => {
try {
const pool = req.app.locals.pool;
const result = await metricService.getVolumeOverTime(
pool,
req.merchant.id,
req.query.range,
);
res.json(result);
} catch (err) {
if (err.message.includes("Invalid range")) {
return res.status(400).json({ error: err.message });
}
next(err);
}
},
);

res.json({ range, assets, data: result });
} catch (err) {
if (err.message.includes("Invalid range")) {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
return router;
}

export default router;
export default createMetricsRouter;
Loading
Loading