type safety: complete catch(err:unknown) migration + extract admin routes
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 17m50s

- All remaining catch(err) and catch(error) blocks now use : unknown
  across keys.ts, email.ts, usage.ts, index.ts (shutdown handlers)
- Extract admin/usage routes from index.ts (459→391 lines) into
  new src/routes/admin.ts with authMiddleware + adminAuth per-route
- Remove unused imports from index.ts (getConcurrencyStats, isProKey,
  getUsageForKey, getUsageStats, NextFunction)
- 10 new TDD tests (7 error helper, 3 admin router)
- 608 total tests, all passing
This commit is contained in:
Hoid 2026-03-09 14:09:12 +01:00
parent 5a7ee79316
commit c52dec2380
7 changed files with 153 additions and 82 deletions

View file

@ -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);
});
});

View file

@ -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();
});
});

View file

@ -1,4 +1,4 @@
import express, { Request, Response, NextFunction } from "express"; import express, { Request, Response } from "express";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { AuthenticatedRequest } from "./types.js"; import { AuthenticatedRequest } from "./types.js";
import "./types.js"; // Augments Express.Request with requestId, acquirePdfSlot, releasePdfSlot 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 { billingRouter } from "./routes/billing.js";
import { authMiddleware } from "./middleware/auth.js"; import { authMiddleware } from "./middleware/auth.js";
import { usageMiddleware, loadUsageData, flushDirtyEntries } from "./middleware/usage.js"; import { usageMiddleware, loadUsageData, flushDirtyEntries } from "./middleware/usage.js";
import { getUsageStats, getUsageForKey } from "./middleware/usage.js"; import { pdfRateLimitMiddleware } from "./middleware/pdfRateLimit.js";
import { pdfRateLimitMiddleware, getConcurrencyStats } from "./middleware/pdfRateLimit.js"; import { adminRouter } from "./routes/admin.js";
import { initBrowser, closeBrowser } from "./services/browser.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 { initDatabase, pool, cleanupStaleData } from "./services/db.js";
import { swaggerSpec } from "./swagger.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/convert", convertBodyLimit, authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter);
app.use("/v1/templates", defaultJsonParser, authMiddleware, usageMiddleware, templatesRouter); app.use("/v1/templates", defaultJsonParser, authMiddleware, usageMiddleware, templatesRouter);
/** // Admin + usage routes (extracted to routes/admin.ts)
* @openapi app.use(adminRouter);
* /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" });
}
});
// Landing page // Landing page
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -384,7 +316,7 @@ async function start() {
try { try {
logger.info("Running scheduled database cleanup..."); logger.info("Running scheduled database cleanup...");
await cleanupStaleData(); await cleanupStaleData();
} catch (err) { } catch (err: unknown) {
logger.error({ err }, "Startup cleanup failed (non-fatal)"); logger.error({ err }, "Startup cleanup failed (non-fatal)");
} }
}, 30_000); }, 30_000);
@ -412,7 +344,7 @@ async function start() {
try { try {
await flushDirtyEntries(); await flushDirtyEntries();
logger.info("Usage data flushed"); logger.info("Usage data flushed");
} catch (err) { } catch (err: unknown) {
logger.error({ err }, "Error flushing usage data during shutdown"); logger.error({ err }, "Error flushing usage data during shutdown");
} }
@ -420,7 +352,7 @@ async function start() {
try { try {
await closeBrowser(); await closeBrowser();
logger.info("Browser pool closed"); logger.info("Browser pool closed");
} catch (err) { } catch (err: unknown) {
logger.error({ err }, "Error closing browser pool"); logger.error({ err }, "Error closing browser pool");
} }
@ -428,7 +360,7 @@ async function start() {
try { try {
await pool.end(); await pool.end();
logger.info("PostgreSQL pool closed"); logger.info("PostgreSQL pool closed");
} catch (err) { } catch (err: unknown) {
logger.error({ err }, "Error closing PostgreSQL pool"); logger.error({ err }, "Error closing PostgreSQL pool");
} }

View file

@ -31,7 +31,7 @@ export async function loadUsageData(): Promise<void> {
usage.set(row.key, { count: row.count, monthKey: row.month_key }); usage.set(row.key, { count: row.count, monthKey: row.month_key });
} }
logger.info(`Loaded usage data for ${usage.size} keys from PostgreSQL`); 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"); logger.info("No existing usage data found, starting fresh");
usage = new Map(); usage = new Map();
} }
@ -55,7 +55,7 @@ export async function flushDirtyEntries(): Promise<void> {
); );
dirtyKeys.delete(key); dirtyKeys.delete(key);
retryCount.delete(key); retryCount.delete(key);
} catch (error) { } catch (error: unknown) {
// Audit #12: retry logic for failed writes // Audit #12: retry logic for failed writes
const retries = (retryCount.get(key) || 0) + 1; const retries = (retryCount.get(key) || 0) + 1;
if (retries >= MAX_RETRIES) { if (retries >= MAX_RETRIES) {

85
src/routes/admin.ts Normal file
View file

@ -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" });
}
});

View file

@ -59,7 +59,7 @@ export async function sendVerificationEmail(email: string, code: string): Promis
}); });
logger.info({ email, messageId: info.messageId }, "Verification email sent"); logger.info({ email, messageId: info.messageId }, "Verification email sent");
return true; return true;
} catch (err) { } catch (err: unknown) {
logger.error({ err, email }, "Failed to send verification email"); logger.error({ err, email }, "Failed to send verification email");
return false; return false;
} }

View file

@ -26,7 +26,7 @@ export async function loadKeys(): Promise<void> {
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at, createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
stripeCustomerId: r.stripe_customer_id || undefined, stripeCustomerId: r.stripe_customer_id || undefined,
})); }));
} catch (err) { } catch (err: unknown) {
logger.error({ err }, "Failed to load keys from PostgreSQL"); logger.error({ err }, "Failed to load keys from PostgreSQL");
keysCache = []; keysCache = [];
} }