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:
parent
c12c1176b0
commit
467a97ae1c
9 changed files with 361 additions and 126 deletions
|
|
@ -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" },
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue