feat: email verification for free tier signup

- Signup now requires email verification before API key is revealed
- Verification token sent via email (Resend) with console fallback
- GET /verify?token=xxx shows API key in styled HTML page
- Handles expired (24h), invalid, and already-verified tokens
- Frontend modal shows 'check your email' instead of key
- Keeps existing rate limiting
This commit is contained in:
OpenClaw 2026-02-14 18:12:25 +00:00
parent 890b82e5ec
commit 1b20665b0d
7 changed files with 252 additions and 29 deletions

View file

@ -13,6 +13,7 @@ import { usageMiddleware } from "./middleware/usage.js";
import { getUsageStats } from "./middleware/usage.js";
import { initBrowser, closeBrowser } from "./services/browser.js";
import { loadKeys, getAllKeys } from "./services/keys.js";
import { verifyToken } from "./services/verification.js";
const app = express();
const PORT = parseInt(process.env.PORT || "3100", 10);
@ -77,6 +78,63 @@ app.get("/v1/usage", authMiddleware, (_req, res) => {
res.json(getUsageStats());
});
// Email verification endpoint
app.get("/verify", (req, res) => {
const token = req.query.token as string;
if (!token) {
res.status(400).send(verifyPage("Invalid Link", "No verification token provided.", null));
return;
}
const result = verifyToken(token);
switch (result.status) {
case "ok":
res.send(verifyPage("Email Verified! 🚀", "Your DocFast API key is ready:", result.verification!.apiKey));
break;
case "already_verified":
res.send(verifyPage("Already Verified", "This email was already verified. Here's your API key:", result.verification!.apiKey));
break;
case "expired":
res.status(410).send(verifyPage("Link Expired", "This verification link has expired (24h). Please sign up again.", null));
break;
case "invalid":
res.status(404).send(verifyPage("Invalid Link", "This verification link is not valid.", null));
break;
}
});
function verifyPage(title: string, message: string, apiKey: string | null): string {
return `<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${title} DocFast</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter',sans-serif;background:#0b0d11;color:#e4e7ed;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.card{background:#151922;border:1px solid #1e2433;border-radius:16px;padding:48px;max-width:520px;width:100%;text-align:center}
h1{font-size:1.8rem;margin-bottom:12px;font-weight:800}
p{color:#7a8194;margin-bottom:24px;line-height:1.6}
.key-box{background:#0b0d11;border:1px solid #34d399;border-radius:8px;padding:16px;font-family:monospace;font-size:0.82rem;word-break:break-all;margin:16px 0;cursor:pointer;transition:background 0.2s;position:relative}
.key-box:hover{background:#12151c}
.key-box::after{content:'Click to copy';position:absolute;top:-24px;right:0;font-size:0.7rem;color:#7a8194;font-family:'Inter',sans-serif}
.warning{background:rgba(251,191,36,0.06);border:1px solid rgba(251,191,36,0.15);border-radius:8px;padding:12px 16px;font-size:0.85rem;color:#fbbf24;margin-bottom:16px;text-align:left}
.links{margin-top:24px;color:#7a8194;font-size:0.9rem}
.links a{color:#34d399;text-decoration:none}
.links a:hover{color:#5eead4}
</style></head><body>
<div class="card">
<h1>${title}</h1>
<p>${message}</p>
${apiKey ? `
<div class="warning"> Save your API key now we can't recover it later.</div>
<div class="key-box" onclick="navigator.clipboard.writeText('${apiKey}');this.style.borderColor='#5eead4';setTimeout(()=>this.style.borderColor='#34d399',1500)">${apiKey}</div>
<div class="links">100 free PDFs/month · <a href="/docs">Read the docs </a></div>
` : `<div class="links"><a href="/"> Back to DocFast</a></div>`}
</div></body></html>`;
}
// Landing page
const __dirname = path.dirname(fileURLToPath(import.meta.url));
app.use(express.static(path.join(__dirname, "../public")));

View file

@ -1,13 +1,15 @@
import { Router, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import { createFreeKey } from "../services/keys.js";
import { createVerification, verifyToken, isEmailVerified, getVerifiedApiKey } from "../services/verification.js";
import { sendVerificationEmail } from "../services/email.js";
const router = Router();
// Rate limiting for signup - 5 signups per IP per hour
const signupLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5, // 5 signups per IP per hour
windowMs: 60 * 60 * 1000,
max: 5,
message: {
error: "Too many signup attempts. Please try again in 1 hour.",
retryAfter: "1 hour"
@ -18,29 +20,36 @@ const signupLimiter = rateLimit({
skipFailedRequests: false,
});
// Self-service free API key signup
router.post("/free", signupLimiter, (req: Request, res: Response) => {
// Self-service free API key signup — now requires email verification
router.post("/free", signupLimiter, async (req: Request, res: Response) => {
const { email } = req.body || {};
// Email is optional — validate only if provided
let cleanEmail: string | undefined;
if (email && typeof email === "string") {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
res.status(400).json({ error: "Invalid email address" });
return;
}
cleanEmail = email.trim().toLowerCase();
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 already verified, tell them
if (isEmailVerified(cleanEmail)) {
res.status(409).json({ error: "This email is already registered. Check your inbox for the original verification email, or contact support." });
return;
}
// Create the API key (but don't reveal it yet)
const keyInfo = createFreeKey(cleanEmail);
// Create verification record
const verification = createVerification(cleanEmail, keyInfo.key);
// Send verification email
await sendVerificationEmail(cleanEmail, verification.token);
res.json({
message: "Welcome to DocFast! 🚀",
apiKey: keyInfo.key,
tier: "free",
limit: "100 PDFs/month",
docs: "https://docfast.dev/docs",
note: "Save this API key — it won't be shown again.",
message: "Check your email for a verification link to get your API key.",
email: cleanEmail,
verified: false,
});
});

52
src/services/email.ts Normal file
View file

@ -0,0 +1,52 @@
const RESEND_API_KEY = process.env.RESEND_API_KEY;
const BASE_URL = process.env.BASE_URL || "https://docfast.dev";
export async function sendVerificationEmail(email: string, token: string): Promise<boolean> {
const verifyUrl = `${BASE_URL}/verify?token=${token}`;
if (!RESEND_API_KEY || RESEND_API_KEY === "FILL_IN") {
console.log(`\n📧 VERIFICATION EMAIL (no Resend configured)`);
console.log(` To: ${email}`);
console.log(` URL: ${verifyUrl}\n`);
return true;
}
try {
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "DocFast <noreply@docfast.dev>",
to: [email],
subject: "Verify your DocFast account",
html: `
<div style="font-family: -apple-system, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
<h1 style="font-size: 24px; margin-bottom: 16px;"> Welcome to DocFast</h1>
<p style="color: #555; line-height: 1.6;">Click the button below to verify your email and get your API key:</p>
<a href="${verifyUrl}" style="display: inline-block; background: #34d399; color: #0b0d11; padding: 14px 28px; border-radius: 8px; font-weight: 600; text-decoration: none; margin: 24px 0;">Verify Email </a>
<p style="color: #999; font-size: 13px; margin-top: 24px;">This link expires in 24 hours. If you didn't sign up for DocFast, ignore this email.</p>
</div>
`,
}),
});
if (!res.ok) {
const err = await res.text();
console.error(`Resend API error: ${res.status} ${err}`);
// Fallback to console
console.log(`\n📧 VERIFICATION EMAIL (Resend failed, logging)`);
console.log(` To: ${email}`);
console.log(` URL: ${verifyUrl}\n`);
}
return true;
} catch (err) {
console.error("Email send error:", err);
console.log(`\n📧 VERIFICATION EMAIL (fallback)`);
console.log(` To: ${email}`);
console.log(` URL: ${verifyUrl}\n`);
return true;
}
}

View file

@ -0,0 +1,98 @@
import { randomBytes } from "crypto";
import { existsSync, mkdirSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.join(__dirname, "../../data");
const DB_PATH = path.join(DATA_DIR, "verifications.json");
// Simple JSON-based store (no SQLite dependency needed)
import { readFileSync, writeFileSync } from "fs";
export interface Verification {
email: string;
token: string;
apiKey: string;
createdAt: string;
verifiedAt: string | null;
}
let verifications: Verification[] = [];
function ensureDataDir(): void {
if (!existsSync(DATA_DIR)) {
mkdirSync(DATA_DIR, { recursive: true });
}
}
function load(): void {
ensureDataDir();
if (existsSync(DB_PATH)) {
try {
verifications = JSON.parse(readFileSync(DB_PATH, "utf-8"));
} catch {
verifications = [];
}
}
}
function save(): void {
ensureDataDir();
writeFileSync(DB_PATH, JSON.stringify(verifications, null, 2));
}
// Initialize on import
load();
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
export function createVerification(email: string, apiKey: string): Verification {
// Check for existing pending verification for this email
const existing = verifications.find(
(v) => v.email === email && !v.verifiedAt
);
if (existing) {
// Check if expired
const age = Date.now() - new Date(existing.createdAt).getTime();
if (age < TOKEN_EXPIRY_MS) {
return existing; // Return existing pending verification
}
// Expired — remove it
verifications = verifications.filter((v) => v !== existing);
}
const verification: Verification = {
email,
token: randomBytes(32).toString("hex"),
apiKey,
createdAt: new Date().toISOString(),
verifiedAt: null,
};
verifications.push(verification);
save();
return verification;
}
export function verifyToken(token: string): { status: "ok"; verification: Verification } | { status: "invalid" | "expired" | "already_verified"; verification?: Verification } {
const v = verifications.find((v) => v.token === token);
if (!v) return { status: "invalid" };
if (v.verifiedAt) return { status: "already_verified", verification: v };
const age = Date.now() - new Date(v.createdAt).getTime();
if (age > TOKEN_EXPIRY_MS) return { status: "expired" };
v.verifiedAt = new Date().toISOString();
save();
return { status: "ok", verification: v };
}
export function isEmailVerified(email: string): boolean {
return verifications.some((v) => v.email === email && v.verifiedAt !== null);
}
export function getVerifiedApiKey(email: string): string | null {
const v = verifications.find((v) => v.email === email && v.verifiedAt !== null);
return v?.apiKey ?? null;
}