diff --git a/src/__tests__/cleanup-no-verifications-table.test.ts b/src/__tests__/cleanup-no-verifications-table.test.ts new file mode 100644 index 0000000..9007e55 --- /dev/null +++ b/src/__tests__/cleanup-no-verifications-table.test.ts @@ -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"); + }); +}); diff --git a/src/__tests__/dead-signup-removal.test.ts b/src/__tests__/dead-signup-removal.test.ts new file mode 100644 index 0000000..f2464cb --- /dev/null +++ b/src/__tests__/dead-signup-removal.test.ts @@ -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"); + }); + }); +}); diff --git a/src/__tests__/dead-token-verification-removal.test.ts b/src/__tests__/dead-token-verification-removal.test.ts index dfd0510..485055d 100644 --- a/src/__tests__/dead-token-verification-removal.test.ts +++ b/src/__tests__/dead-token-verification-removal.test.ts @@ -80,17 +80,7 @@ describe("Dead Token Verification System Removal", () => { expect(typeof verification.verifyCode).toBe("function"); }); - it("should export isEmailVerified", async () => { - 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"); - }); + // isEmailVerified and getVerifiedApiKey removed — only used by dead signup router it("should export PendingVerification interface", async () => { // TypeScript interface test - if compilation passes, the interface exists diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 19fff85..543b715 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -74,8 +74,7 @@ vi.mock("../services/browser.js", () => ({ vi.mock("../services/verification.js", () => ({ createPendingVerification: vi.fn().mockResolvedValue({ email: "test@test.com", code: "123456" }), verifyCode: vi.fn().mockResolvedValue({ status: "ok" }), - isEmailVerified: vi.fn().mockResolvedValue(false), - getVerifiedApiKey: vi.fn().mockResolvedValue(null), + })); // Mock email service diff --git a/src/__tests__/signup.test.ts b/src/__tests__/signup.test.ts deleted file mode 100644 index 1621e05..0000000 --- a/src/__tests__/signup.test.ts +++ /dev/null @@ -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" }); - }); -}); diff --git a/src/__tests__/usage-free-tier-message.test.ts b/src/__tests__/usage-free-tier-message.test.ts new file mode 100644 index 0000000..232f05e --- /dev/null +++ b/src/__tests__/usage-free-tier-message.test.ts @@ -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"); + }); +}); diff --git a/src/__tests__/usage.test.ts b/src/__tests__/usage.test.ts index 2a1efbc..014e93c 100644 --- a/src/__tests__/usage.test.ts +++ b/src/__tests__/usage.test.ts @@ -132,7 +132,7 @@ describe("usage middleware", () => { expect(mockStatus).toHaveBeenCalledWith(429); expect(mockJson).toHaveBeenCalledWith( - expect.objectContaining({ error: expect.stringContaining("Free tier limit") }) + expect.objectContaining({ error: expect.stringContaining("Account limit") }) ); expect(mockNext).not.toHaveBeenCalled(); }); diff --git a/src/middleware/usage.ts b/src/middleware/usage.ts index c41083f..dda1428 100644 --- a/src/middleware/usage.ts +++ b/src/middleware/usage.ts @@ -94,7 +94,7 @@ export function usageMiddleware(req: any, res: any, next: any): void { const record = usage.get(key); 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; } diff --git a/src/routes/signup.ts b/src/routes/signup.ts deleted file mode 100644 index e9f21a1..0000000 --- a/src/routes/signup.ts +++ /dev/null @@ -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 }; diff --git a/src/services/db.ts b/src/services/db.ts index 947f204..6906e5e 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -172,8 +172,8 @@ export async function initDatabase(): Promise { * - Unverified free-tier API keys (never completed verification) * - Orphaned usage rows (key no longer exists) */ -export async function cleanupStaleData(): Promise<{ expiredVerifications: number; staleKeys: number; orphanedUsage: number }> { - const results = { expiredVerifications: 0, staleKeys: 0, orphanedUsage: 0 }; +export async function cleanupStaleData(): Promise<{ expiredVerifications: number; orphanedUsage: number }> { + const results = { expiredVerifications: 0, orphanedUsage: 0 }; // 1. Delete expired pending verifications const pv = await queryWithRetry( @@ -181,18 +181,7 @@ export async function cleanupStaleData(): Promise<{ expiredVerifications: number ); results.expiredVerifications = pv.rowCount || 0; - // 2. Delete unverified free-tier keys (email not in verified verifications) - 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 + // 2. Delete orphaned usage rows (key no longer exists in api_keys) const ou = await queryWithRetry(` DELETE FROM usage WHERE key NOT IN (SELECT key FROM api_keys) @@ -202,7 +191,7 @@ export async function cleanupStaleData(): Promise<{ expiredVerifications: number logger.info( { ...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; diff --git a/src/services/verification.ts b/src/services/verification.ts index 42687b6..d225dff 100644 --- a/src/services/verification.ts +++ b/src/services/verification.ts @@ -60,18 +60,3 @@ export async function verifyCode(email: string, code: string): Promise<{ status: return { status: "ok" }; } -export async function isEmailVerified(email: string): Promise { - 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 { - 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; -} \ No newline at end of file