Remove dead signup router, unused verification functions, and legacy cleanup query
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 18m37s

- Delete src/routes/signup.ts (dead code, 410 handler in index.ts remains)
- Remove isEmailVerified() and getVerifiedApiKey() from verification.ts (only used by signup)
- Remove stale-key cleanup from cleanupStaleData() that queried legacy verifications table
- Update usage middleware message: 'Free tier limit' → 'Account limit'
- TDD: 8 new tests, removed signup.test.ts (dead), net 556 tests passing
This commit is contained in:
Hoid 2026-03-08 14:07:50 +01:00
parent 921562750f
commit 7206cb518d
11 changed files with 79 additions and 308 deletions

View file

@ -0,0 +1,16 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { join } from "path";
describe("cleanupStaleData should not reference legacy verifications table", () => {
it("should not query verifications table (legacy, no longer written to)", () => {
const dbSrc = readFileSync(join(__dirname, "../services/db.ts"), "utf8");
// Extract just the cleanupStaleData function body
const funcStart = dbSrc.indexOf("async function cleanupStaleData");
const funcEnd = dbSrc.indexOf("export { pool }");
const funcBody = dbSrc.slice(funcStart, funcEnd);
// Should not reference 'verifications' table (only pending_verifications is active)
// The old query checked: email NOT IN (SELECT ... FROM verifications WHERE verified_at IS NOT NULL)
expect(funcBody).not.toContain("FROM verifications WHERE verified_at");
});
});

View file

@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest";
import { readFileSync, existsSync } from "fs";
import { join } from "path";
describe("Dead Signup Router Removal", () => {
describe("Signup router module removed", () => {
it("should not have src/routes/signup.ts file", () => {
const signupPath = join(__dirname, "../routes/signup.ts");
expect(existsSync(signupPath)).toBe(false);
});
});
describe("Dead verification functions removed from source", () => {
it("should not export isEmailVerified from verification.ts source", () => {
const src = readFileSync(join(__dirname, "../services/verification.ts"), "utf8");
expect(src).not.toContain("export async function isEmailVerified");
});
it("should not export getVerifiedApiKey from verification.ts source", () => {
const src = readFileSync(join(__dirname, "../services/verification.ts"), "utf8");
expect(src).not.toContain("export async function getVerifiedApiKey");
});
it("should still export createPendingVerification", () => {
const src = readFileSync(join(__dirname, "../services/verification.ts"), "utf8");
expect(src).toContain("export async function createPendingVerification");
});
it("should still export verifyCode", () => {
const src = readFileSync(join(__dirname, "../services/verification.ts"), "utf8");
expect(src).toContain("export async function verifyCode");
});
});
describe("410 signup handler still works", () => {
it("should still have signup 410 handler working", async () => {
const request = (await import("supertest")).default;
const { app } = await import("../index.js");
const res = await request(app).post("/v1/signup/free");
expect(res.status).toBe(410);
expect(res.body.error).toContain("discontinued");
});
});
});

View file

@ -80,17 +80,7 @@ describe("Dead Token Verification System Removal", () => {
expect(typeof verification.verifyCode).toBe("function"); expect(typeof verification.verifyCode).toBe("function");
}); });
it("should export isEmailVerified", async () => { // isEmailVerified and getVerifiedApiKey removed — only used by dead signup router
const verification = await import("../services/verification.js");
expect(verification).toHaveProperty("isEmailVerified");
expect(typeof verification.isEmailVerified).toBe("function");
});
it("should export getVerifiedApiKey", async () => {
const verification = await import("../services/verification.js");
expect(verification).toHaveProperty("getVerifiedApiKey");
expect(typeof verification.getVerifiedApiKey).toBe("function");
});
it("should export PendingVerification interface", async () => { it("should export PendingVerification interface", async () => {
// TypeScript interface test - if compilation passes, the interface exists // TypeScript interface test - if compilation passes, the interface exists

View file

@ -74,8 +74,7 @@ vi.mock("../services/browser.js", () => ({
vi.mock("../services/verification.js", () => ({ vi.mock("../services/verification.js", () => ({
createPendingVerification: vi.fn().mockResolvedValue({ email: "test@test.com", code: "123456" }), createPendingVerification: vi.fn().mockResolvedValue({ email: "test@test.com", code: "123456" }),
verifyCode: vi.fn().mockResolvedValue({ status: "ok" }), verifyCode: vi.fn().mockResolvedValue({ status: "ok" }),
isEmailVerified: vi.fn().mockResolvedValue(false),
getVerifiedApiKey: vi.fn().mockResolvedValue(null),
})); }));
// Mock email service // Mock email service

View file

@ -1,99 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import express from "express";
import request from "supertest";
let app: express.Express;
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
const { isEmailVerified, createPendingVerification, verifyCode } = await import("../services/verification.js");
const { sendVerificationEmail } = await import("../services/email.js");
const { createFreeKey } = await import("../services/keys.js");
vi.mocked(isEmailVerified).mockResolvedValue(false);
vi.mocked(createPendingVerification).mockResolvedValue({ email: "test@test.com", code: "123456", createdAt: "", expiresAt: "", attempts: 0 });
vi.mocked(verifyCode).mockResolvedValue({ status: "ok" });
vi.mocked(createFreeKey).mockResolvedValue({ key: "free-key-123", tier: "free", email: "test@test.com", createdAt: "" });
vi.mocked(sendVerificationEmail).mockResolvedValue(true);
const { signupRouter } = await import("../routes/signup.js");
app = express();
app.use(express.json());
app.use("/signup", signupRouter);
});
describe("POST /signup/free", () => {
it("returns 400 for missing email", async () => {
const res = await request(app).post("/signup/free").send({});
expect(res.status).toBe(400);
});
it("returns 400 for invalid email format", async () => {
const res = await request(app).post("/signup/free").send({ email: "not-email" });
expect(res.status).toBe(400);
});
it("returns 409 for already verified email", async () => {
const { isEmailVerified } = await import("../services/verification.js");
vi.mocked(isEmailVerified).mockResolvedValue(true);
const res = await request(app).post("/signup/free").send({ email: "dup@test.com" });
expect(res.status).toBe(409);
});
it("returns 200 with verification_required for valid email", async () => {
const res = await request(app).post("/signup/free").send({ email: "new@test.com" });
expect(res.status).toBe(200);
expect(res.body.status).toBe("verification_required");
});
it("sends verification email asynchronously", async () => {
const { sendVerificationEmail } = await import("../services/email.js");
await request(app).post("/signup/free").send({ email: "new@test.com" });
await new Promise(r => setTimeout(r, 50));
expect(sendVerificationEmail).toHaveBeenCalledWith("new@test.com", "123456");
});
});
describe("POST /signup/verify", () => {
it("returns 400 for missing email/code", async () => {
const res = await request(app).post("/signup/verify").send({ email: "a@b.com" });
expect(res.status).toBe(400);
});
it("returns 409 for already verified email", async () => {
const { isEmailVerified } = await import("../services/verification.js");
vi.mocked(isEmailVerified).mockResolvedValue(true);
const res = await request(app).post("/signup/verify").send({ email: "dup@test.com", code: "123456" });
expect(res.status).toBe(409);
});
it("returns 410 for expired code", async () => {
const { verifyCode } = await import("../services/verification.js");
vi.mocked(verifyCode).mockResolvedValue({ status: "expired" });
const res = await request(app).post("/signup/verify").send({ email: "a@b.com", code: "123456" });
expect(res.status).toBe(410);
});
it("returns 429 for max attempts", async () => {
const { verifyCode } = await import("../services/verification.js");
vi.mocked(verifyCode).mockResolvedValue({ status: "max_attempts" });
const res = await request(app).post("/signup/verify").send({ email: "a@b.com", code: "123456" });
expect(res.status).toBe(429);
});
it("returns 400 for invalid code", async () => {
const { verifyCode } = await import("../services/verification.js");
vi.mocked(verifyCode).mockResolvedValue({ status: "invalid" });
const res = await request(app).post("/signup/verify").send({ email: "a@b.com", code: "999999" });
expect(res.status).toBe(400);
});
it("returns 200 with apiKey for valid code", async () => {
const res = await request(app).post("/signup/verify").send({ email: "a@b.com", code: "123456" });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ status: "verified", apiKey: "free-key-123" });
});
});

View file

@ -0,0 +1,11 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { join } from "path";
describe("Usage middleware messaging", () => {
it("should not reference 'Free tier' in limit message", () => {
const usageSrc = readFileSync(join(__dirname, "../middleware/usage.ts"), "utf8");
// The rate limit message should say "Account limit" not "Free tier limit"
expect(usageSrc).not.toContain("Free tier limit");
});
});

View file

@ -132,7 +132,7 @@ describe("usage middleware", () => {
expect(mockStatus).toHaveBeenCalledWith(429); expect(mockStatus).toHaveBeenCalledWith(429);
expect(mockJson).toHaveBeenCalledWith( expect(mockJson).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining("Free tier limit") }) expect.objectContaining({ error: expect.stringContaining("Account limit") })
); );
expect(mockNext).not.toHaveBeenCalled(); expect(mockNext).not.toHaveBeenCalled();
}); });

View file

@ -94,7 +94,7 @@ export function usageMiddleware(req: any, res: any, next: any): void {
const record = usage.get(key); const record = usage.get(key);
if (record && record.monthKey === monthKey && record.count >= FREE_TIER_LIMIT) { if (record && record.monthKey === monthKey && record.count >= FREE_TIER_LIMIT) {
res.status(429).json({ error: "Free tier limit reached (100/month). Upgrade to Pro at https://docfast.dev/#pricing for 5,000 PDFs/month." }); res.status(429).json({ error: "Account limit reached (100/month). Upgrade to Pro at https://docfast.dev/#pricing for 5,000 PDFs/month." });
return; return;
} }

View file

@ -1,164 +0,0 @@
import { Router, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import { createFreeKey } from "../services/keys.js";
import { createPendingVerification, verifyCode, isEmailVerified, getVerifiedApiKey } from "../services/verification.js";
import { sendVerificationEmail } from "../services/email.js";
import logger from "../services/logger.js";
const router = Router();
const signupLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 5,
message: { error: "Too many signup attempts. Please try again in 1 hour." },
standardHeaders: true,
legacyHeaders: false,
});
const verifyLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 15,
message: { error: "Too many verification attempts. Please try again later." },
standardHeaders: true,
legacyHeaders: false,
});
async function rejectDuplicateEmail(req: Request, res: Response, next: Function) {
const { email } = req.body || {};
if (email && typeof email === "string") {
const cleanEmail = email.trim().toLowerCase();
if (await isEmailVerified(cleanEmail)) {
res.status(409).json({ error: "Email already registered" });
return;
}
}
next();
}
// Step 1: Request signup — generates 6-digit code, sends via email
router.post("/free", rejectDuplicateEmail, signupLimiter, async (req: Request, res: Response) => {
const { email } = req.body || {};
if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
res.status(400).json({ error: "A valid email address is required." });
return;
}
const cleanEmail = email.trim().toLowerCase();
if (await isEmailVerified(cleanEmail)) {
res.status(409).json({ error: "This email is already registered. Contact support if you need help." });
return;
}
const pending = await createPendingVerification(cleanEmail);
sendVerificationEmail(cleanEmail, pending.code).catch(err => {
logger.error({ err, email: cleanEmail }, "Failed to send verification email");
});
res.json({
status: "verification_required",
message: "Check your email for the verification code.",
});
});
/**
* @openapi
* /v1/signup/verify:
* post:
* tags: [Account]
* summary: Verify email and get API key (discontinued)
* deprecated: true
* description: |
* **Discontinued.** Free accounts are no longer available. Try the demo at POST /v1/demo/html or upgrade to Pro at https://docfast.dev.
* Rate limited to 15 attempts per 15 minutes.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email, code]
* properties:
* email:
* type: string
* format: email
* description: Email address used during signup
* example: user@example.com
* code:
* type: string
* description: 6-digit verification code from email
* example: "123456"
* responses:
* 200:
* description: Email verified, API key issued
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: verified
* message:
* type: string
* apiKey:
* type: string
* description: The provisioned API key
* tier:
* type: string
* example: free
* 400:
* description: Missing fields or invalid verification code
* 409:
* description: Email already verified
* 410:
* description: Verification code expired
* 429:
* description: Too many failed attempts
*/
// Step 2: Verify code — creates API key
router.post("/verify", verifyLimiter, async (req: Request, res: Response) => {
const { email, code } = req.body || {};
if (!email || !code) {
res.status(400).json({ error: "Email and code are required." });
return;
}
const cleanEmail = email.trim().toLowerCase();
const cleanCode = String(code).trim();
if (await isEmailVerified(cleanEmail)) {
res.status(409).json({ error: "This email is already verified." });
return;
}
const result = await verifyCode(cleanEmail, cleanCode);
switch (result.status) {
case "ok": {
const keyInfo = await createFreeKey(cleanEmail);
res.json({
status: "verified",
message: "Email verified! Here's your API key.",
apiKey: keyInfo.key,
tier: keyInfo.tier,
});
break;
}
case "expired":
res.status(410).json({ error: "Verification code has expired. Please sign up again." });
break;
case "max_attempts":
res.status(429).json({ error: "Too many failed attempts. Please sign up again to get a new code." });
break;
case "invalid":
res.status(400).json({ error: "Invalid verification code." });
break;
}
});
export { router as signupRouter };

View file

@ -172,8 +172,8 @@ export async function initDatabase(): Promise<void> {
* - Unverified free-tier API keys (never completed verification) * - Unverified free-tier API keys (never completed verification)
* - Orphaned usage rows (key no longer exists) * - Orphaned usage rows (key no longer exists)
*/ */
export async function cleanupStaleData(): Promise<{ expiredVerifications: number; staleKeys: number; orphanedUsage: number }> { export async function cleanupStaleData(): Promise<{ expiredVerifications: number; orphanedUsage: number }> {
const results = { expiredVerifications: 0, staleKeys: 0, orphanedUsage: 0 }; const results = { expiredVerifications: 0, orphanedUsage: 0 };
// 1. Delete expired pending verifications // 1. Delete expired pending verifications
const pv = await queryWithRetry( const pv = await queryWithRetry(
@ -181,18 +181,7 @@ export async function cleanupStaleData(): Promise<{ expiredVerifications: number
); );
results.expiredVerifications = pv.rowCount || 0; results.expiredVerifications = pv.rowCount || 0;
// 2. Delete unverified free-tier keys (email not in verified verifications) // 2. Delete orphaned usage rows (key no longer exists in api_keys)
const sk = await queryWithRetry(`
DELETE FROM api_keys
WHERE tier = 'free'
AND email NOT IN (
SELECT DISTINCT email FROM verifications WHERE verified_at IS NOT NULL
)
RETURNING key
`);
results.staleKeys = sk.rowCount || 0;
// 3. Delete orphaned usage rows
const ou = await queryWithRetry(` const ou = await queryWithRetry(`
DELETE FROM usage DELETE FROM usage
WHERE key NOT IN (SELECT key FROM api_keys) WHERE key NOT IN (SELECT key FROM api_keys)
@ -202,7 +191,7 @@ export async function cleanupStaleData(): Promise<{ expiredVerifications: number
logger.info( logger.info(
{ ...results }, { ...results },
`Database cleanup complete: ${results.expiredVerifications} expired verifications, ${results.staleKeys} stale keys, ${results.orphanedUsage} orphaned usage rows removed` `Database cleanup complete: ${results.expiredVerifications} expired verifications, ${results.orphanedUsage} orphaned usage rows removed`
); );
return results; return results;

View file

@ -60,18 +60,3 @@ export async function verifyCode(email: string, code: string): Promise<{ status:
return { status: "ok" }; return { status: "ok" };
} }
export async function isEmailVerified(email: string): Promise<boolean> {
const result = await queryWithRetry(
"SELECT 1 FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1",
[email]
);
return result.rows.length > 0;
}
export async function getVerifiedApiKey(email: string): Promise<string | null> {
const result = await queryWithRetry(
"SELECT api_key FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1",
[email]
);
return result.rows[0]?.api_key ?? null;
}