diff --git a/src/app.ts b/src/app.ts index d45c6c20..402affbd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,6 +5,7 @@ import adminRouter from './routes/admin.js'; import { createExplainRouter } from './routes/admin/explain.js'; import { createUsageAnomaliesRouter } from './routes/admin/usage/anomalies.js'; import { createAdminUsageByEndpointRouter } from './routes/admin/usage/by-endpoint.js'; +import { createSpikeRouter } from './routes/admin/usage/spike.js'; import { createApiRouter } from './routes/index.js'; import { createApisRouter } from './routes/apis.js'; import { createPluginsRouter } from './routes/marketplace/plugins.js'; @@ -333,6 +334,7 @@ export const createApp = (dependencies?: Partial) => { // shadowed by adminRouter's `/usage/:developerId` route. app.use('/api/admin/usage/anomalies', createUsageAnomaliesRouter({ pool })); app.use('/api/admin/usage/by-endpoint', createAdminUsageByEndpointRouter({ pool })); + app.use('/api/admin/usage/spike', createSpikeRouter({ pool })); app.use('/api/admin', adminRouter); app.use('/api/admin/db/explain', createExplainRouter({ pool })); diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index ae38c6bb..6ea355db 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -17,8 +17,6 @@ export interface ErrorResponseBody { import { errorEnvelope } from '../lib/envelope.js'; import type { ErrorEnvelope } from '../types/ResponseEnvelope.js'; -const isProduction = process.env.NODE_ENV === 'production'; - function extractValidationDetails(err: unknown): ValidationErrorDetail[] | undefined { if (err instanceof ValidationError) { return err.details; @@ -109,14 +107,6 @@ export function errorHandler( const details = extractValidationDetails(err); const body = buildErrorEnvelope(code, finalMessage, requestId, details); - // Build error envelope with optional validation details - const details = extractValidationDetails(err); - const body: ErrorEnvelope = errorEnvelope( - code, - finalMessage, - requestId, - details, - ); if (!res.headersSent) { res.status(statusCode).json(body); diff --git a/src/routes/admin/usage/spike.test.ts b/src/routes/admin/usage/spike.test.ts new file mode 100644 index 00000000..817c1db4 --- /dev/null +++ b/src/routes/admin/usage/spike.test.ts @@ -0,0 +1,238 @@ +import express from 'express'; +import type { Request, Response, NextFunction } from 'express'; +import request from 'supertest'; +import type { Pool, QueryResult } from 'pg'; +import { createSpikeRouter } from './spike.js'; +import { errorHandler } from '../../../middleware/errorHandler.js'; +import { requestIdMiddleware } from '../../../middleware/requestId.js'; + +jest.mock('../../../middleware/adminAuth', () => ({ + adminAuth: jest.fn((_req: Request, _res: Response, next: NextFunction) => { + _res.locals = { ..._res.locals, adminActor: 'test-admin' }; + next(); + }), +})); + +jest.mock('../../../middleware/ipAllowlist', () => ({ + createAdminIpAllowlist: jest.fn(() => (_req: Request, _res: Response, next: NextFunction) => next()), +})); + +jest.mock('../../../logger', () => { + const actual = jest.requireActual('../../../logger'); + return { + ...actual, + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + audit: jest.fn(), + }, + }; +}); + +import { logger } from '../../../logger.js'; + +const mockQuery = jest.fn(); +const mockPool = { query: mockQuery } as unknown as Pool; + +function createTestApp(deps: { pool?: Pool; noPool?: boolean } = {}): express.Express { + const app = express(); + app.use(requestIdMiddleware); + const effectivePool = deps.noPool ? undefined : (deps.pool ?? mockPool); + app.use('/api/admin/usage/spike', createSpikeRouter({ pool: effectivePool as Pool | undefined })); + app.use(errorHandler); + return app; +} + +const asResult = (rows: unknown[]): QueryResult => + ({ rows } as unknown as QueryResult); + +const dayString = (index: number): string => + new Date(Date.UTC(2026, 2, 1 + index)).toISOString().slice(0, 10); + +// Steady 10 calls/day over a 20-day baseline with a clear spike on the final +// day for api-1 (z-score comfortably above the default threshold of 3). +const SPIKE_ROWS = [ + ...Array.from({ length: 20 }, (_, i) => ({ + apiId: 'api-1', + day: dayString(i), + calls: 10, + revenue: '0', + })), + { apiId: 'api-1', day: dayString(20), calls: 200, revenue: '5000' }, +]; +const SPIKE_DAY = dayString(20); + +describe('GET /api/admin/usage/spike', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockQuery.mockReset(); + }); + + it('returns detected spikes with a summary', async () => { + mockQuery.mockResolvedValueOnce(asResult(SPIKE_ROWS)); + const app = createTestApp(); + + const res = await request(app).get('/api/admin/usage/spike'); + + expect(res.status).toBe(200); + expect(res.body.data.spikes).toHaveLength(1); + expect(res.body.data.spikes[0]).toMatchObject({ + apiId: 'api-1', + day: SPIKE_DAY, + calls: 200, + revenue: '5000', + }); + expect(res.body.data.summary).toMatchObject({ + threshold: 3, + minDataPoints: 3, + seriesAnalyzed: 1, + spikeCount: 1, + }); + expect(res.body.data.summary.window.from).toBeDefined(); + expect(res.body.data.summary.window.to).toBeDefined(); + }); + + it('includes percentageChange in the spike data', async () => { + mockQuery.mockResolvedValueOnce(asResult(SPIKE_ROWS)); + const app = createTestApp(); + + const res = await request(app).get('/api/admin/usage/spike'); + + expect(res.body.data.spikes[0].percentageChange).toBeGreaterThan(900); + }); + + it('writes an audit log entry', async () => { + mockQuery.mockResolvedValueOnce(asResult(SPIKE_ROWS)); + const app = createTestApp(); + + await request(app).get('/api/admin/usage/spike'); + + expect(logger.audit).toHaveBeenCalledWith( + 'LIST_USAGE_SPIKES', + 'test-admin', + expect.objectContaining({ spikeCount: 1, seriesAnalyzed: 1 }), + ); + }); + + it('returns an empty list when no spikes are detected', async () => { + mockQuery.mockResolvedValueOnce(asResult([ + { apiId: 'api-1', day: '2026-03-01', calls: 10, revenue: '0' }, + { apiId: 'api-1', day: '2026-03-02', calls: 10, revenue: '0' }, + { apiId: 'api-1', day: '2026-03-03', calls: 10, revenue: '0' }, + ])); + const app = createTestApp(); + + const res = await request(app).get('/api/admin/usage/spike'); + + expect(res.status).toBe(200); + expect(res.body.data.spikes).toEqual([]); + expect(res.body.data.summary.spikeCount).toBe(0); + }); + + it('applies a custom threshold', async () => { + mockQuery.mockResolvedValueOnce(asResult(SPIKE_ROWS)); + const app = createTestApp(); + + const res = await request(app).get('/api/admin/usage/spike').query({ threshold: '10' }); + + expect(res.status).toBe(200); + // The spike's z-score (~2) is below a threshold of 10. + expect(res.body.data.spikes).toEqual([]); + expect(res.body.data.summary.threshold).toBe(10); + }); + + it('passes an apiId filter through to the query', async () => { + mockQuery.mockResolvedValueOnce(asResult(SPIKE_ROWS)); + const app = createTestApp(); + + await request(app).get('/api/admin/usage/spike').query({ apiId: 'api-1' }); + + const [sql, params] = mockQuery.mock.calls[0]; + expect(sql).toContain('AND api_id = $3'); + expect(params).toEqual([expect.any(Date), expect.any(Date), 'api-1']); + }); + + it('passes the date window through to the query', async () => { + mockQuery.mockResolvedValueOnce(asResult([])); + const app = createTestApp(); + + await request(app) + .get('/api/admin/usage/spike') + .query({ from: '2026-03-01T00:00:00.000Z', to: '2026-03-31T00:00:00.000Z' }); + + const [, params] = mockQuery.mock.calls[0]; + expect((params[0] as Date).toISOString()).toBe('2026-03-01T00:00:00.000Z'); + expect((params[1] as Date).toISOString()).toBe('2026-03-31T00:00:00.000Z'); + }); + + describe('input validation', () => { + it('returns 400 for an invalid "from" date', async () => { + const res = await request(createTestApp()).get('/api/admin/usage/spike').query({ from: 'nope' }); + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('returns 400 when "from" is supplied as multiple values', async () => { + const res = await request(createTestApp()).get('/api/admin/usage/spike?from=2026-01-01&from=2026-02-01'); + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('returns 400 for an invalid "to" date', async () => { + const res = await request(createTestApp()).get('/api/admin/usage/spike').query({ to: 'nope' }); + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('returns 400 when from is after to', async () => { + const res = await request(createTestApp()) + .get('/api/admin/usage/spike') + .query({ from: '2026-03-31T00:00:00.000Z', to: '2026-03-01T00:00:00.000Z' }); + expect(res.status).toBe(400); + expect(res.body.error.message).toBe('from must be before or equal to to'); + }); + + it('returns 400 for an out-of-range threshold', async () => { + const res = await request(createTestApp()).get('/api/admin/usage/spike').query({ threshold: '99' }); + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('returns 400 for a non-numeric threshold', async () => { + const res = await request(createTestApp()).get('/api/admin/usage/spike').query({ threshold: 'abc' }); + expect(res.status).toBe(400); + }); + + it('returns 400 for a non-integer limit', async () => { + const res = await request(createTestApp()).get('/api/admin/usage/spike').query({ limit: '1.5' }); + expect(res.status).toBe(400); + }); + + it('returns 400 for an out-of-range limit', async () => { + const res = await request(createTestApp()).get('/api/admin/usage/spike').query({ limit: '0' }); + expect(res.status).toBe(400); + }); + + it('returns 400 when apiId is supplied as multiple values', async () => { + const res = await request(createTestApp()).get('/api/admin/usage/spike?apiId=a&apiId=b'); + expect(res.status).toBe(400); + }); + }); + + it('returns 500 when the database pool is unavailable', async () => { + const app = createTestApp({ noPool: true }); + const res = await request(app).get('/api/admin/usage/spike'); + expect(res.status).toBe(500); + expect(res.body.error.code).toBe('INTERNAL_SERVER_ERROR'); + }); + + it('returns 500 when the aggregation query fails', async () => { + mockQuery.mockRejectedValueOnce(new Error('db down')); + const app = createTestApp(); + const res = await request(app).get('/api/admin/usage/spike'); + expect(res.status).toBe(500); + expect(res.body.error.code).toBe('INTERNAL_SERVER_ERROR'); + expect(logger.error).toHaveBeenCalled(); + }); +}); diff --git a/src/routes/admin/usage/spike.ts b/src/routes/admin/usage/spike.ts new file mode 100644 index 00000000..6e44e8f2 --- /dev/null +++ b/src/routes/admin/usage/spike.ts @@ -0,0 +1,157 @@ +import { Router } from 'express'; +import type { Pool } from 'pg'; +import { adminAuth } from '../../../middleware/adminAuth.js'; +import { createAdminIpAllowlist } from '../../../middleware/ipAllowlist.js'; +import { BadRequestError, InternalServerError } from '../../../errors/index.js'; +import { logger } from '../../../logger.js'; +import { getClientIp } from '../../../lib/clientIp.js'; +import { validate } from '../../../middleware/validate.js'; +import { spikeQuerySchema } from '../../../validators/admin.js'; +import { + detectSpikes, + type DailyUsagePoint, +} from '../../../services/spikeDetector.js'; + +const TRUST_PROXY = process.env.TRUST_PROXY_HEADERS === 'true'; + +const DEFAULT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; +const DEFAULT_THRESHOLD = 3; +const DEFAULT_LIMIT = 100; +/** Minimum days of history an API needs before its baseline is trustworthy. */ +const MIN_DATA_POINTS = 3; + +interface DailyUsageRow { + apiId: string; + day: string; + calls: number; + revenue: string; +} + +export interface SpikeRouterDeps { + pool?: Pool; +} + +/** + * Router exposing `GET /api/admin/usage/spike` — detected per-API daily + * usage spikes for admin review. + * + * Admin-only: gated behind the admin IP allowlist and admin authentication. + * Usage is aggregated to per-API daily counts in a single grouped SQL scan, + * then scored in-process by {@link detectSpikes}, so the work stays bounded + * by the number of (API, day) buckets rather than raw event volume. + * + * All query-parameter validation is performed by {@link spikeQuerySchema} + * via the {@link validate} middleware, which returns a structured + * `{ code, message, details }` 400 response for any invalid input. + */ +export function createSpikeRouter(deps: SpikeRouterDeps = {}): Router { + const router = Router(); + + router.use(createAdminIpAllowlist()); + router.use(adminAuth); + + router.get( + '/', + validate({ query: spikeQuerySchema }), + async (req, res, next) => { + try { + const parsed = spikeQuerySchema.safeParse(req.query); + if (!parsed.success) { + next(new BadRequestError('Invalid query parameters')); + return; + } + + const { from, to, threshold, limit, apiId } = parsed.data; + + const now = new Date(); + const queryFrom = from ?? new Date(now.getTime() - DEFAULT_WINDOW_MS); + const queryTo = to ?? now; + const resolvedThreshold = threshold ?? DEFAULT_THRESHOLD; + const resolvedLimit = limit ?? DEFAULT_LIMIT; + + if (queryFrom > queryTo) { + next(new BadRequestError('from must be before or equal to to')); + return; + } + + const { pool } = deps; + if (!pool) { + next(new InternalServerError('Database pool not available')); + return; + } + + const params: unknown[] = [queryFrom, queryTo]; + let apiFilter = ''; + if (apiId !== undefined) { + params.push(apiId); + apiFilter = `AND api_id = $${params.length}`; + } + + const sql = ` + SELECT + api_id AS "apiId", + to_char(date_trunc('day', created_at), 'YYYY-MM-DD') AS day, + COUNT(*)::int AS calls, + COALESCE(SUM(amount_usdc), 0)::text AS revenue + FROM usage_events + WHERE created_at >= $1 AND created_at <= $2 + ${apiFilter} + GROUP BY api_id, date_trunc('day', created_at) + ORDER BY api_id, day + `; + + let rows: DailyUsageRow[]; + try { + const result = await pool.query(sql, params); + rows = result.rows; + } catch (dbError) { + logger.error('[usage.spike] aggregation query failed', { error: dbError }); + next(new InternalServerError()); + return; + } + + const series: DailyUsagePoint[] = rows.map((row) => ({ + apiId: row.apiId, + day: row.day, + calls: Number(row.calls), + revenue: row.revenue, + })); + + const { spikes, seriesAnalyzed } = detectSpikes(series, { + threshold: resolvedThreshold, + minDataPoints: MIN_DATA_POINTS, + limit: resolvedLimit, + }); + + logger.audit('LIST_USAGE_SPIKES', res.locals.adminActor, { + clientIp: getClientIp(req, TRUST_PROXY), + userAgent: req.get('User-Agent'), + window: { from: queryFrom.toISOString(), to: queryTo.toISOString() }, + threshold: resolvedThreshold, + apiId, + seriesAnalyzed, + spikeCount: spikes.length, + }); + + res.json({ + data: { + spikes, + summary: { + window: { from: queryFrom.toISOString(), to: queryTo.toISOString() }, + threshold: resolvedThreshold, + minDataPoints: MIN_DATA_POINTS, + seriesAnalyzed, + spikeCount: spikes.length, + }, + }, + }); + } catch (error) { + next(error); + } + }, + ); + + return router; +} + +export default createSpikeRouter; diff --git a/src/validators/admin.ts b/src/validators/admin.ts index 1fbd4367..6def0d8e 100644 --- a/src/validators/admin.ts +++ b/src/validators/admin.ts @@ -184,6 +184,56 @@ export const usageByEndpointQuerySchema = z.object({ export type UsageByEndpointQuery = z.infer; +// --------------------------------------------------------------------------- +// GET /api/admin/usage/spike +// --------------------------------------------------------------------------- + +/** + * Query params for GET /api/admin/usage/spike. + * + * Identical in shape to {@link usageAnomaliesQuerySchema} — both endpoints + * accept the same filtering parameters (window, threshold, limit, apiId). + * The difference is in the detection algorithm: anomalies flags both positive + * (spike) and negative (drop) z-score deviations, while spike only flags + * positive z-score deviations and includes a `percentageChange` field. + */ +export const spikeQuerySchema = z.object({ + /** ISO-8601 start of the reporting window (optional). */ + from: isoDateString.optional(), + /** ISO-8601 end of the reporting window (optional). */ + to: isoDateString.optional(), + /** + * Z-score threshold that classifies a data point as a spike. + * Accepts a decimal string between 1 and 10. + */ + threshold: z + .string() + .optional() + .refine((v) => { + if (v === undefined) return true; + const n = Number(v); + return Number.isFinite(n) && n >= 1 && n <= 10; + }, { message: 'threshold must be a number between 1 and 10' }) + .transform((v) => (v !== undefined ? Number(v) : undefined)), + /** + * Maximum number of spikes to return. + * Must be an integer between 1 and 1000. + */ + limit: z + .string() + .optional() + .refine((v) => { + if (v === undefined) return true; + const n = Number(v); + return Number.isFinite(n) && Number.isInteger(n) && n >= 1 && n <= 1000; + }, { message: 'limit must be an integer between 1 and 1000' }) + .transform((v) => (v !== undefined ? Number(v) : undefined)), + /** Filter to a specific API (optional). */ + apiId: singleStringParam.optional(), +}); + +export type SpikeQuery = z.infer; + // --------------------------------------------------------------------------- // POST /api/admin/db/explain // ---------------------------------------------------------------------------