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

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