fix: self-service signup, unified key store, persistent data volume

- Added /v1/signup/free endpoint for instant API key provisioning
- Built unified key store (services/keys.ts) with file-based persistence
- Refactored auth middleware to use key store (no more hardcoded env keys)
- Refactored usage middleware to check key tier from store
- Updated billing to use key store for Pro key provisioning
- Landing page: replaced mailto: link with signup modal
- Landing page: Pro checkout button now properly calls /v1/billing/checkout
- Added Docker volume for persistent key storage
- Success page now renders HTML instead of raw JSON
- Tested: signup → key → PDF generation works end-to-end
This commit is contained in:
DocFast Bot 2026-02-14 14:20:05 +00:00
parent c12c1176b0
commit 467a97ae1c
9 changed files with 361 additions and 126 deletions

View file

@ -6,22 +6,27 @@ import rateLimit from "express-rate-limit";
import { convertRouter } from "./routes/convert.js";
import { templatesRouter } from "./routes/templates.js";
import { healthRouter } from "./routes/health.js";
import { signupRouter } from "./routes/signup.js";
import { billingRouter } from "./routes/billing.js";
import { authMiddleware } from "./middleware/auth.js";
import { usageMiddleware } from "./middleware/usage.js";
import { getUsageStats } from "./middleware/usage.js";
import { initBrowser, closeBrowser } from "./services/browser.js";
import { billingRouter } from "./routes/billing.js";
import { loadKeys, getAllKeys } from "./services/keys.js";
const app = express();
const PORT = parseInt(process.env.PORT || "3100", 10);
// Load API keys from persistent store
loadKeys();
app.use(helmet());
// Raw body for Stripe webhook signature verification
app.use("/v1/billing/webhook", express.raw({ type: "application/json" }));
app.use(express.json({ limit: "2mb" }));
app.use(express.text({ limit: "2mb", type: "text/*" }));
// Rate limiting: 100 req/min for free tier
// Rate limiting
const limiter = rateLimit({
windowMs: 60_000,
max: 100,
@ -30,17 +35,16 @@ const limiter = rateLimit({
});
app.use(limiter);
// Public
// Public routes
app.use("/health", healthRouter);
app.use("/v1/signup", signupRouter);
app.use("/v1/billing", billingRouter);
// Authenticated
// Authenticated routes
app.use("/v1/convert", authMiddleware, usageMiddleware, convertRouter);
app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter);
// Billing (public — Stripe handles auth)
app.use("/v1/billing", billingRouter);
// Admin: usage stats (protected by auth)
// Admin: usage stats
app.get("/v1/usage", authMiddleware, (_req, res) => {
res.json(getUsageStats());
});
@ -49,24 +53,26 @@ app.get("/v1/usage", authMiddleware, (_req, res) => {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
app.use(express.static(path.join(__dirname, "../public")));
// API root (for programmatic discovery)
// API root
app.get("/api", (_req, res) => {
res.json({
name: "DocFast API",
version: "0.1.0",
docs: "/health",
version: "0.2.0",
endpoints: [
"POST /v1/signup/free — Get a free API key",
"POST /v1/convert/html",
"POST /v1/convert/markdown",
"POST /v1/convert/url",
"POST /v1/templates/:id/render",
"GET /v1/templates",
"POST /v1/billing/checkout — Start Pro subscription",
],
});
});
async function start() {
await initBrowser();
console.log(`Loaded ${getAllKeys().length} API keys`);
app.listen(PORT, () => console.log(`DocFast API running on :${PORT}`));
const shutdown = async () => {

View file

@ -1,8 +1,5 @@
import { Request, Response, NextFunction } from "express";
const API_KEYS = new Set(
(process.env.API_KEYS || "test-key-123").split(",").map((k) => k.trim())
);
import { isValidKey, getKeyInfo } from "../services/keys.js";
export function authMiddleware(
req: Request,
@ -15,9 +12,11 @@ export function authMiddleware(
return;
}
const key = header.slice(7);
if (!API_KEYS.has(key)) {
if (!isValidKey(key)) {
res.status(403).json({ error: "Invalid API key" });
return;
}
// Attach key info to request for downstream use
(req as any).apiKeyInfo = getKeyInfo(key);
next();
}

View file

@ -1,19 +1,13 @@
import { Request, Response, NextFunction } from "express";
import { isProKey } from "../services/keys.js";
interface UsageRecord {
count: number;
monthKey: string;
}
// In-memory usage tracking (replace with Redis/DB for production)
const usage = new Map<string, UsageRecord>();
const FREE_TIER_LIMIT = 100; // 100 PDFs/month for free tier
import { isProKey as isRuntimeProKey } from "../routes/billing.js";
const PRO_KEYS = new Set(
(process.env.PRO_KEYS || "").split(",").map((k) => k.trim()).filter(Boolean)
);
const FREE_TIER_LIMIT = 100;
function getMonthKey(): string {
const d = new Date();
@ -28,8 +22,8 @@ export function usageMiddleware(
const key = req.headers.authorization?.slice(7) || "unknown";
const monthKey = getMonthKey();
// Pro keys have no limit (env-configured or runtime-provisioned via Stripe)
if (PRO_KEYS.has(key) || isRuntimeProKey(key)) {
// Pro keys have no limit
if (isProKey(key)) {
trackUsage(key, monthKey);
next();
return;

View file

@ -1,6 +1,6 @@
import { Router, Request, Response } from "express";
import Stripe from "stripe";
import { nanoid } from "nanoid";
import { createProKey, revokeByCustomer } from "../services/keys.js";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", {
apiVersion: "2025-01-27.acacia" as any,
@ -8,22 +8,17 @@ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", {
const router = Router();
// In-memory store of customer → API key mappings
// In production, this would be a database
const customerKeys = new Map<string, string>();
// Create a Stripe Checkout session for Pro subscription
router.post("/checkout", async (_req: Request, res: Response) => {
try {
// Find or create the Pro plan product+price
const priceId = await getOrCreateProPrice();
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.BASE_URL || "https://docfast.dev"}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.BASE_URL || "https://docfast.dev"}/pricing`,
success_url: `${process.env.BASE_URL || "https://docfast.dev"}/v1/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.BASE_URL || "https://docfast.dev"}/#pricing`,
});
res.json({ url: session.url });
@ -33,7 +28,7 @@ router.post("/checkout", async (_req: Request, res: Response) => {
}
});
// Success page — retrieve API key after checkout
// Success page — provision Pro API key after checkout
router.get("/success", async (req: Request, res: Response) => {
const sessionId = req.query.session_id as string;
if (!sessionId) {
@ -44,27 +39,35 @@ router.get("/success", async (req: Request, res: Response) => {
try {
const session = await stripe.checkout.sessions.retrieve(sessionId);
const customerId = session.customer as string;
const email = session.customer_details?.email || "unknown@docfast.dev";
if (!customerId) {
res.status(400).json({ error: "No customer found" });
return;
}
// Generate or retrieve API key for this customer
let apiKey = customerKeys.get(customerId);
if (!apiKey) {
apiKey = `df_pro_${nanoid(32)}`;
customerKeys.set(customerId, apiKey);
// Add to PRO_KEYS runtime set
addProKey(apiKey);
}
const keyInfo = createProKey(email, customerId);
res.json({
message: "Welcome to DocFast Pro! 🎉",
apiKey,
docs: "/api",
note: "Save this API key — it won't be shown again.",
});
// Return a nice HTML page instead of raw JSON
res.send(`<!DOCTYPE html>
<html><head><title>Welcome to DocFast Pro!</title>
<style>
body { font-family: system-ui; background: #0a0a0a; color: #e8e8e8; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
.card { background: #141414; border: 1px solid #222; border-radius: 16px; padding: 48px; max-width: 500px; text-align: center; }
h1 { color: #4f9; margin-bottom: 8px; }
.key { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 16px; margin: 24px 0; font-family: monospace; font-size: 0.9rem; word-break: break-all; cursor: pointer; }
.key:hover { border-color: #4f9; }
p { color: #888; line-height: 1.6; }
a { color: #4f9; }
</style></head><body>
<div class="card">
<h1>🎉 Welcome to Pro!</h1>
<p>Your API key:</p>
<div class="key" onclick="navigator.clipboard.writeText('${keyInfo.key}')" title="Click to copy">${keyInfo.key}</div>
<p><strong>Save this key!</strong> It won't be shown again.</p>
<p>10,000 PDFs/month All endpoints Priority support</p>
<p><a href="/#endpoints">View API docs </a></p>
</div></body></html>`);
} catch (err: any) {
console.error("Success page error:", err.message);
res.status(500).json({ error: "Failed to retrieve session" });
@ -72,68 +75,38 @@ router.get("/success", async (req: Request, res: Response) => {
});
// Stripe webhook for subscription lifecycle events
router.post(
"/webhook",
// Raw body needed for signature verification
async (req: Request, res: Response) => {
const sig = req.headers["stripe-signature"] as string;
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
router.post("/webhook", async (req: Request, res: Response) => {
const sig = req.headers["stripe-signature"] as string;
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event: Stripe.Event;
let event: Stripe.Event;
if (webhookSecret && sig) {
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
webhookSecret
);
} catch (err: any) {
console.error("Webhook signature verification failed:", err.message);
res.status(400).json({ error: "Invalid signature" });
return;
}
} else {
// No webhook secret configured — accept all events (dev mode)
event = req.body as Stripe.Event;
if (webhookSecret && sig) {
try {
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
} catch (err: any) {
console.error("Webhook signature verification failed:", err.message);
res.status(400).json({ error: "Invalid signature" });
return;
}
switch (event.type) {
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
const customerId = sub.customer as string;
const key = customerKeys.get(customerId);
if (key) {
removeProKey(key);
customerKeys.delete(customerId);
console.log(`Subscription cancelled for ${customerId}, key revoked`);
}
break;
}
default:
// Ignore other events
break;
}
res.json({ received: true });
} else {
event = req.body as Stripe.Event;
}
);
// --- Pro key management ---
// These integrate with the usage middleware's PRO_KEYS set
const runtimeProKeys = new Set<string>();
switch (event.type) {
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
const customerId = sub.customer as string;
revokeByCustomer(customerId);
console.log(`Subscription cancelled for ${customerId}, key revoked`);
break;
}
default:
break;
}
export function addProKey(key: string): void {
runtimeProKeys.add(key);
}
export function removeProKey(key: string): void {
runtimeProKeys.delete(key);
}
export function isProKey(key: string): boolean {
return runtimeProKeys.has(key);
}
res.json({ received: true });
});
// --- Price management ---
let cachedPriceId: string | null = null;
@ -141,21 +114,12 @@ let cachedPriceId: string | null = null;
async function getOrCreateProPrice(): Promise<string> {
if (cachedPriceId) return cachedPriceId;
// Search for existing product
const products = await stripe.products.search({
query: "name:'DocFast Pro'",
});
const products = await stripe.products.search({ query: "name:'DocFast Pro'" });
let productId: string;
if (products.data.length > 0) {
productId = products.data[0].id;
// Find active price
const prices = await stripe.prices.list({
product: productId,
active: true,
limit: 1,
});
const prices = await stripe.prices.list({ product: productId, active: true, limit: 1 });
if (prices.data.length > 0) {
cachedPriceId = prices.data[0].id;
return cachedPriceId;
@ -170,7 +134,7 @@ async function getOrCreateProPrice(): Promise<string> {
const price = await stripe.prices.create({
product: productId,
unit_amount: 900, // $9.00
unit_amount: 900,
currency: "usd",
recurring: { interval: "month" },
});

33
src/routes/signup.ts Normal file
View file

@ -0,0 +1,33 @@
import { Router, Request, Response } from "express";
import { createFreeKey } from "../services/keys.js";
const router = Router();
// Self-service free API key signup
router.post("/free", (req: Request, res: Response) => {
const { email } = req.body;
if (!email || typeof email !== "string") {
res.status(400).json({ error: "Email is required" });
return;
}
// Basic email validation
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
res.status(400).json({ error: "Invalid email address" });
return;
}
const keyInfo = createFreeKey(email.trim().toLowerCase());
res.json({
message: "Welcome to DocFast! 🚀",
apiKey: keyInfo.key,
tier: "free",
limit: "100 PDFs/month",
docs: "https://docfast.dev/#endpoints",
note: "Save this API key — it won't be shown again.",
});
});
export { router as signupRouter };

118
src/services/keys.ts Normal file
View file

@ -0,0 +1,118 @@
import { randomBytes } from "crypto";
import { readFileSync, writeFileSync, 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 KEYS_FILE = path.join(DATA_DIR, "keys.json");
export interface ApiKey {
key: string;
tier: "free" | "pro";
email: string;
createdAt: string;
stripeCustomerId?: string;
}
interface KeyStore {
keys: ApiKey[];
}
let store: KeyStore = { keys: [] };
function ensureDataDir(): void {
if (!existsSync(DATA_DIR)) {
mkdirSync(DATA_DIR, { recursive: true });
}
}
export function loadKeys(): void {
ensureDataDir();
if (existsSync(KEYS_FILE)) {
try {
store = JSON.parse(readFileSync(KEYS_FILE, "utf-8"));
} catch {
store = { keys: [] };
}
}
// Also load seed keys from env
const envKeys = process.env.API_KEYS?.split(",").map((k) => k.trim()).filter(Boolean) || [];
for (const k of envKeys) {
if (!store.keys.find((e) => e.key === k)) {
store.keys.push({ key: k, tier: "pro", email: "seed@docfast.dev", createdAt: new Date().toISOString() });
}
}
}
function save(): void {
ensureDataDir();
writeFileSync(KEYS_FILE, JSON.stringify(store, null, 2));
}
export function isValidKey(key: string): boolean {
return store.keys.some((k) => k.key === key);
}
export function getKeyInfo(key: string): ApiKey | undefined {
return store.keys.find((k) => k.key === key);
}
export function isProKey(key: string): boolean {
const info = getKeyInfo(key);
return info?.tier === "pro";
}
function generateKey(prefix: string): string {
return `${prefix}_${randomBytes(24).toString("hex")}`;
}
export function createFreeKey(email: string): ApiKey {
// Check if email already has a free key
const existing = store.keys.find((k) => k.email === email && k.tier === "free");
if (existing) return existing;
const entry: ApiKey = {
key: generateKey("df_free"),
tier: "free",
email,
createdAt: new Date().toISOString(),
};
store.keys.push(entry);
save();
return entry;
}
export function createProKey(email: string, stripeCustomerId: string): ApiKey {
const existing = store.keys.find((k) => k.stripeCustomerId === stripeCustomerId);
if (existing) {
existing.tier = "pro";
save();
return existing;
}
const entry: ApiKey = {
key: generateKey("df_pro"),
tier: "pro",
email,
createdAt: new Date().toISOString(),
stripeCustomerId,
};
store.keys.push(entry);
save();
return entry;
}
export function revokeByCustomer(stripeCustomerId: string): boolean {
const idx = store.keys.findIndex((k) => k.stripeCustomerId === stripeCustomerId);
if (idx >= 0) {
store.keys.splice(idx, 1);
save();
return true;
}
return false;
}
export function getAllKeys(): ApiKey[] {
return [...store.keys];
}