diff --git a/src/__tests__/admin-routes.test.ts b/src/__tests__/admin-routes.test.ts new file mode 100644 index 0000000..a405b80 --- /dev/null +++ b/src/__tests__/admin-routes.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { adminRouter } from "../routes/admin.js"; + +describe("admin router extraction", () => { + it("exports adminRouter", () => { + expect(adminRouter).toBeDefined(); + }); + + it("adminRouter is an Express Router", () => { + // Express routers have a stack property + expect((adminRouter as any).stack).toBeDefined(); + expect(Array.isArray((adminRouter as any).stack)).toBe(true); + }); + + it("has routes registered", () => { + const stack = (adminRouter as any).stack; + expect(stack.length).toBeGreaterThan(0); + }); +}); diff --git a/src/__tests__/catch-type-safety.test.ts b/src/__tests__/catch-type-safety.test.ts new file mode 100644 index 0000000..aa99b6d --- /dev/null +++ b/src/__tests__/catch-type-safety.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { errorMessage, errorCode } from "../utils/errors.js"; + +describe("catch type safety helpers", () => { + it("errorMessage handles Error instances", () => { + expect(errorMessage(new Error("test error"))).toBe("test error"); + }); + + it("errorMessage handles string errors", () => { + expect(errorMessage("raw string error")).toBe("raw string error"); + }); + + it("errorMessage handles non-Error objects", () => { + expect(errorMessage({ code: "ENOENT" })).toBe("[object Object]"); + }); + + it("errorMessage handles null/undefined", () => { + expect(errorMessage(null)).toBe("null"); + expect(errorMessage(undefined)).toBe("undefined"); + }); + + it("errorCode extracts code from Error with code", () => { + const err = Object.assign(new Error("fail"), { code: "ECONNREFUSED" }); + expect(errorCode(err)).toBe("ECONNREFUSED"); + }); + + it("errorCode returns undefined for plain Error", () => { + expect(errorCode(new Error("no code"))).toBeUndefined(); + }); + + it("errorCode returns undefined for non-Error", () => { + expect(errorCode("string error")).toBeUndefined(); + expect(errorCode(42)).toBeUndefined(); + }); +}); diff --git a/src/index.ts b/src/index.ts index 134100b..e322131 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import express, { Request, Response, NextFunction } from "express"; +import express, { Request, Response } from "express"; import { randomUUID } from "crypto"; import { AuthenticatedRequest } from "./types.js"; import "./types.js"; // Augments Express.Request with requestId, acquirePdfSlot, releasePdfSlot @@ -21,10 +21,10 @@ import { emailChangeRouter } from "./routes/email-change.js"; import { billingRouter } from "./routes/billing.js"; import { authMiddleware } from "./middleware/auth.js"; import { usageMiddleware, loadUsageData, flushDirtyEntries } from "./middleware/usage.js"; -import { getUsageStats, getUsageForKey } from "./middleware/usage.js"; -import { pdfRateLimitMiddleware, getConcurrencyStats } from "./middleware/pdfRateLimit.js"; +import { pdfRateLimitMiddleware } from "./middleware/pdfRateLimit.js"; +import { adminRouter } from "./routes/admin.js"; import { initBrowser, closeBrowser } from "./services/browser.js"; -import { loadKeys, getAllKeys, isProKey } from "./services/keys.js"; +import { loadKeys, getAllKeys } from "./services/keys.js"; import { initDatabase, pool, cleanupStaleData } from "./services/db.js"; import { swaggerSpec } from "./swagger.js"; @@ -160,76 +160,8 @@ const convertBodyLimit = express.json({ limit: "500kb" }); app.use("/v1/convert", convertBodyLimit, authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter); app.use("/v1/templates", defaultJsonParser, authMiddleware, usageMiddleware, templatesRouter); -/** - * @openapi - * /v1/usage/me: - * get: - * summary: Get your current month's usage - * description: Returns the authenticated user's PDF generation usage for the current billing month. - * security: - * - ApiKeyAuth: [] - * responses: - * 200: - * description: Current usage statistics - * content: - * application/json: - * schema: - * type: object - * properties: - * used: - * type: integer - * description: Number of PDFs generated this month - * limit: - * type: integer - * description: Monthly PDF limit for your plan - * plan: - * type: string - * enum: [pro, demo] - * description: Your current plan - * month: - * type: string - * description: Current billing month (YYYY-MM) - * 401: - * description: Missing or invalid API key - */ -app.get("/v1/usage/me", authMiddleware, (req: Request, res: Response) => { - const key = (req as AuthenticatedRequest).apiKeyInfo.key; - const { count, monthKey } = getUsageForKey(key); - const pro = isProKey(key); - res.json({ - used: count, - limit: pro ? 5000 : 100, - plan: pro ? "pro" : "demo", - month: monthKey, - }); -}); - -// Admin: usage stats (admin key required) -const adminAuth = (req: Request, res: Response, next: NextFunction) => { - const adminKey = process.env.ADMIN_API_KEY; - if (!adminKey) { res.status(503).json({ error: "Admin access not configured" }); return; } - if ((req as AuthenticatedRequest).apiKeyInfo?.key !== adminKey) { res.status(403).json({ error: "Admin access required" }); return; } - next(); -}; -app.get("/v1/usage", authMiddleware, adminAuth, (req: Request, res: Response) => { - res.json(getUsageStats((req as AuthenticatedRequest).apiKeyInfo?.key)); -}); - -// Admin: concurrency stats (admin key required) -app.get("/v1/concurrency", authMiddleware, adminAuth, (_req: Request, res: Response) => { - res.json(getConcurrencyStats()); -}); - -// Admin: database cleanup (admin key required) -app.post("/admin/cleanup", authMiddleware, adminAuth, async (_req: Request, res: Response) => { - try { - const results = await cleanupStaleData(); - res.json({ status: "ok", cleaned: results }); - } catch (err: unknown) { - logger.error({ err }, "Admin cleanup failed"); - res.status(500).json({ error: "Cleanup failed" }); - } -}); +// Admin + usage routes (extracted to routes/admin.ts) +app.use(adminRouter); // Landing page const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -384,7 +316,7 @@ async function start() { try { logger.info("Running scheduled database cleanup..."); await cleanupStaleData(); - } catch (err) { + } catch (err: unknown) { logger.error({ err }, "Startup cleanup failed (non-fatal)"); } }, 30_000); @@ -412,7 +344,7 @@ async function start() { try { await flushDirtyEntries(); logger.info("Usage data flushed"); - } catch (err) { + } catch (err: unknown) { logger.error({ err }, "Error flushing usage data during shutdown"); } @@ -420,7 +352,7 @@ async function start() { try { await closeBrowser(); logger.info("Browser pool closed"); - } catch (err) { + } catch (err: unknown) { logger.error({ err }, "Error closing browser pool"); } @@ -428,7 +360,7 @@ async function start() { try { await pool.end(); logger.info("PostgreSQL pool closed"); - } catch (err) { + } catch (err: unknown) { logger.error({ err }, "Error closing PostgreSQL pool"); } diff --git a/src/middleware/usage.ts b/src/middleware/usage.ts index f3893af..205f0af 100644 --- a/src/middleware/usage.ts +++ b/src/middleware/usage.ts @@ -31,7 +31,7 @@ export async function loadUsageData(): Promise { usage.set(row.key, { count: row.count, monthKey: row.month_key }); } logger.info(`Loaded usage data for ${usage.size} keys from PostgreSQL`); - } catch (error) { + } catch (error: unknown) { logger.info("No existing usage data found, starting fresh"); usage = new Map(); } @@ -55,7 +55,7 @@ export async function flushDirtyEntries(): Promise { ); dirtyKeys.delete(key); retryCount.delete(key); - } catch (error) { + } catch (error: unknown) { // Audit #12: retry logic for failed writes const retries = (retryCount.get(key) || 0) + 1; if (retries >= MAX_RETRIES) { diff --git a/src/routes/admin.ts b/src/routes/admin.ts new file mode 100644 index 0000000..c14380e --- /dev/null +++ b/src/routes/admin.ts @@ -0,0 +1,85 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { AuthenticatedRequest } from "../types.js"; +import { authMiddleware } from "../middleware/auth.js"; +import { getUsageStats, getUsageForKey } from "../middleware/usage.js"; +import { getConcurrencyStats } from "../middleware/pdfRateLimit.js"; +import { isProKey } from "../services/keys.js"; +import { cleanupStaleData } from "../services/db.js"; +import logger from "../services/logger.js"; +import { errorMessage } from "../utils/errors.js"; + +export const adminRouter = Router(); + +// Admin auth middleware — requires ADMIN_API_KEY env var +const adminAuth = (req: Request, res: Response, next: NextFunction): void => { + const adminKey = process.env.ADMIN_API_KEY; + if (!adminKey) { res.status(503).json({ error: "Admin access not configured" }); return; } + if ((req as AuthenticatedRequest).apiKeyInfo?.key !== adminKey) { res.status(403).json({ error: "Admin access required" }); return; } + next(); +}; + +/** + * @openapi + * /v1/usage/me: + * get: + * summary: Get your usage stats + * tags: [Account] + * security: + * - ApiKeyHeader: [] + * - BearerAuth: [] + * responses: + * 200: + * description: Usage statistics for the authenticated user + * content: + * application/json: + * schema: + * type: object + * properties: + * used: + * type: integer + * description: PDFs generated this month + * limit: + * type: integer + * description: Monthly PDF limit for your plan + * plan: + * type: string + * enum: [demo, pro] + * description: Current plan + * month: + * type: string + * description: Current billing month (YYYY-MM) + * 401: + * description: Missing or invalid API key + */ +adminRouter.get("/v1/usage/me", authMiddleware, (req: Request, res: Response) => { + const key = (req as AuthenticatedRequest).apiKeyInfo.key; + const { count, monthKey } = getUsageForKey(key); + const pro = isProKey(key); + res.json({ + used: count, + limit: pro ? 5000 : 100, + plan: pro ? "pro" : "demo", + month: monthKey, + }); +}); + +// Admin: usage stats (admin key required) +adminRouter.get("/v1/usage", authMiddleware, adminAuth, (req: Request, res: Response) => { + res.json(getUsageStats((req as AuthenticatedRequest).apiKeyInfo?.key)); +}); + +// Admin: concurrency stats (admin key required) +adminRouter.get("/v1/concurrency", authMiddleware, adminAuth, (_req: Request, res: Response) => { + res.json(getConcurrencyStats()); +}); + +// Admin: database cleanup (admin key required) +adminRouter.post("/admin/cleanup", authMiddleware, adminAuth, async (_req: Request, res: Response) => { + try { + const results = await cleanupStaleData(); + res.json({ status: "ok", cleaned: results }); + } catch (err: unknown) { + logger.error({ err: errorMessage(err) }, "Admin cleanup failed"); + res.status(500).json({ error: "Cleanup failed" }); + } +}); diff --git a/src/services/email.ts b/src/services/email.ts index a426553..6f872c0 100644 --- a/src/services/email.ts +++ b/src/services/email.ts @@ -59,7 +59,7 @@ export async function sendVerificationEmail(email: string, code: string): Promis }); logger.info({ email, messageId: info.messageId }, "Verification email sent"); return true; - } catch (err) { + } catch (err: unknown) { logger.error({ err, email }, "Failed to send verification email"); return false; } diff --git a/src/services/keys.ts b/src/services/keys.ts index 9de3063..123bc7b 100644 --- a/src/services/keys.ts +++ b/src/services/keys.ts @@ -26,7 +26,7 @@ export async function loadKeys(): Promise { createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at, stripeCustomerId: r.stripe_customer_id || undefined, })); - } catch (err) { + } catch (err: unknown) { logger.error({ err }, "Failed to load keys from PostgreSQL"); keysCache = []; }