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 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:
parent
5a7ee79316
commit
c52dec2380
7 changed files with 153 additions and 82 deletions
19
src/__tests__/admin-routes.test.ts
Normal file
19
src/__tests__/admin-routes.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
35
src/__tests__/catch-type-safety.test.ts
Normal file
35
src/__tests__/catch-type-safety.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
88
src/index.ts
88
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 { 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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
85
src/routes/admin.ts
Normal 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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue