From 46967f730a7301497c7749b91f8c1d3569fa4f7f Mon Sep 17 00:00:00 2001 From: Hoid Date: Sat, 14 Feb 2026 13:55:32 +0000 Subject: [PATCH] Business agent: Hetzner DNS API works with same token, unblocked --- projects/business/memory/state.json | 11 +- projects/business/src/pdf-api/dist/index.js | 5 + .../business/src/pdf-api/docker-compose.yml | 4 + .../business/src/pdf-api/package-lock.json | 20 +- projects/business/src/pdf-api/package.json | 13 +- .../business/src/pdf-api/public/index.html | 17 +- projects/business/src/pdf-api/src/index.ts | 6 + .../src/pdf-api/src/middleware/usage.ts | 8 +- .../src/pdf-api/src/routes/billing.ts | 182 ++++++++++++++++++ 9 files changed, 247 insertions(+), 19 deletions(-) create mode 100644 projects/business/src/pdf-api/src/routes/billing.ts diff --git a/projects/business/memory/state.json b/projects/business/memory/state.json index c54da6d..9a720ba 100644 --- a/projects/business/memory/state.json +++ b/projects/business/memory/state.json @@ -3,26 +3,23 @@ "phaseLabel": "Build MVP — DNS + SSL remaining", "status": "deployed-stripe-live-needs-dns-ssl", "product": "DocFast — HTML/Markdown to PDF API", - "currentPriority": "DNS setup for docfast.dev → 167.235.156.214. Hetzner DNS API needs separate token (Cloud API token doesn't work for DNS). Options: (1) Human creates Hetzner DNS API token, or (2) Human adds A record at INWX. Then certbot for SSL.", + "currentPriority": "Set up DNS via Hetzner DNS API. The domain IS in Hetzner DNS and the same Cloud API token works. API docs: https://dns.hetzner.com/api-docs — use endpoints like GET /api/v1/zones to find the zone, then POST /api/v1/records to create A records for docfast.dev and www.docfast.dev pointing to 167.235.156.214. Auth header: Auth-API-Token. Then certbot for SSL.", "infrastructure": { "domain": "docfast.dev", - "dns": "Needs setup — Hetzner DNS API requires separate token from Cloud API", + "dns": "Hetzner DNS API (https://dns.hetzner.com/api-docs) — same HETZNER_API_TOKEN works", "hosting": "Hetzner Cloud", "server": "docfast-1 (CAX11, nbg1)", "serverIP": "167.235.156.214", "sshKey": "/home/openclaw/.ssh/docfast", "apiKey": "df_live_9760e44a3e732be0f8628a44e0cdbc040107499f6e8f457a", - "stripeCheckout": "working — creates live checkout sessions" + "stripeCheckout": "working" }, "credentials": { "file": "/home/openclaw/.openclaw/workspace/.credentials/docfast.env", "keys": ["HETZNER_API_TOKEN", "STRIPE_SECRET_KEY"], "NEVER_READ_DIRECTLY": true }, - "blockers": [ - "DNS: docfast.dev has no A record. Need human to add A record at INWX pointing to 167.235.156.214", - "SSL: Blocked on DNS (certbot needs domain to resolve)" - ], + "blockers": [], "startDate": "2026-02-14", "sessionCount": 8 } diff --git a/projects/business/src/pdf-api/dist/index.js b/projects/business/src/pdf-api/dist/index.js index b76c407..48ff1f4 100644 --- a/projects/business/src/pdf-api/dist/index.js +++ b/projects/business/src/pdf-api/dist/index.js @@ -10,9 +10,12 @@ 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"; const app = express(); const PORT = parseInt(process.env.PORT || "3100", 10); 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 @@ -28,6 +31,8 @@ app.use("/health", healthRouter); // Authenticated 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) app.get("/v1/usage", authMiddleware, (_req, res) => { res.json(getUsageStats()); diff --git a/projects/business/src/pdf-api/docker-compose.yml b/projects/business/src/pdf-api/docker-compose.yml index 319fa8c..a1ec12f 100644 --- a/projects/business/src/pdf-api/docker-compose.yml +++ b/projects/business/src/pdf-api/docker-compose.yml @@ -9,5 +9,9 @@ services: - API_KEYS=${API_KEYS} - PORT=3100 - NODE_ENV=production + - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY} + - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET} + - BASE_URL=${BASE_URL:-https://docfast.dev} + - PRO_KEYS=${PRO_KEYS} mem_limit: 512m cpus: 1.0 diff --git a/projects/business/src/pdf-api/package-lock.json b/projects/business/src/pdf-api/package-lock.json index 2335a3d..5f01fb9 100644 --- a/projects/business/src/pdf-api/package-lock.json +++ b/projects/business/src/pdf-api/package-lock.json @@ -13,7 +13,8 @@ "helmet": "^8.0.0", "marked": "^15.0.0", "nanoid": "^5.0.0", - "puppeteer": "^24.0.0" + "puppeteer": "^24.0.0", + "stripe": "^20.3.1" }, "devDependencies": { "@types/express": "^5.0.0", @@ -3377,6 +3378,23 @@ "dev": true, "license": "MIT" }, + "node_modules/stripe": { + "version": "20.3.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.3.1.tgz", + "integrity": "sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/tar-fs": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", diff --git a/projects/business/src/pdf-api/package.json b/projects/business/src/pdf-api/package.json index 96edde8..2d6260a 100644 --- a/projects/business/src/pdf-api/package.json +++ b/projects/business/src/pdf-api/package.json @@ -11,17 +11,18 @@ }, "dependencies": { "express": "^4.21.0", - "marked": "^15.0.0", - "puppeteer": "^24.0.0", - "nanoid": "^5.0.0", + "express-rate-limit": "^7.5.0", "helmet": "^8.0.0", - "express-rate-limit": "^7.5.0" + "marked": "^15.0.0", + "nanoid": "^5.0.0", + "puppeteer": "^24.0.0", + "stripe": "^20.3.1" }, "devDependencies": { - "typescript": "^5.7.0", - "tsx": "^4.19.0", "@types/express": "^5.0.0", "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0", "vitest": "^3.0.0" }, "type": "module" diff --git a/projects/business/src/pdf-api/public/index.html b/projects/business/src/pdf-api/public/index.html index b990008..4d58cd4 100644 --- a/projects/business/src/pdf-api/public/index.html +++ b/projects/business/src/pdf-api/public/index.html @@ -171,7 +171,7 @@ footer { padding: 40px 0; text-align: center; color: var(--muted); font-size: 0.
  • All templates
  • Community support
  • - Start Free + Get Free Key @@ -195,5 +195,18 @@ footer { padding: 40px 0; text-align: center; color: var(--muted); font-size: 0. + diff --git a/projects/business/src/pdf-api/src/index.ts b/projects/business/src/pdf-api/src/index.ts index 98ea04c..492e2d1 100644 --- a/projects/business/src/pdf-api/src/index.ts +++ b/projects/business/src/pdf-api/src/index.ts @@ -10,11 +10,14 @@ 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"; const app = express(); const PORT = parseInt(process.env.PORT || "3100", 10); 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/*" })); @@ -34,6 +37,9 @@ app.use("/health", healthRouter); 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) app.get("/v1/usage", authMiddleware, (_req, res) => { res.json(getUsageStats()); diff --git a/projects/business/src/pdf-api/src/middleware/usage.ts b/projects/business/src/pdf-api/src/middleware/usage.ts index 576c88c..d2e8076 100644 --- a/projects/business/src/pdf-api/src/middleware/usage.ts +++ b/projects/business/src/pdf-api/src/middleware/usage.ts @@ -8,7 +8,9 @@ interface UsageRecord { // In-memory usage tracking (replace with Redis/DB for production) const usage = new Map(); -const FREE_TIER_LIMIT = 50; // 50 PDFs/month for free tier +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) ); @@ -26,8 +28,8 @@ export function usageMiddleware( const key = req.headers.authorization?.slice(7) || "unknown"; const monthKey = getMonthKey(); - // Pro keys have no limit - if (PRO_KEYS.has(key)) { + // Pro keys have no limit (env-configured or runtime-provisioned via Stripe) + if (PRO_KEYS.has(key) || isRuntimeProKey(key)) { trackUsage(key, monthKey); next(); return; diff --git a/projects/business/src/pdf-api/src/routes/billing.ts b/projects/business/src/pdf-api/src/routes/billing.ts new file mode 100644 index 0000000..78cedf9 --- /dev/null +++ b/projects/business/src/pdf-api/src/routes/billing.ts @@ -0,0 +1,182 @@ +import { Router, Request, Response } from "express"; +import Stripe from "stripe"; +import { nanoid } from "nanoid"; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", { + apiVersion: "2025-01-27.acacia" as any, +}); + +const router = Router(); + +// In-memory store of customer → API key mappings +// In production, this would be a database +const customerKeys = new Map(); + +// 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`, + }); + + res.json({ url: session.url }); + } catch (err: any) { + console.error("Checkout error:", err.message); + res.status(500).json({ error: "Failed to create checkout session" }); + } +}); + +// Success page — retrieve API key after checkout +router.get("/success", async (req: Request, res: Response) => { + const sessionId = req.query.session_id as string; + if (!sessionId) { + res.status(400).json({ error: "Missing session_id" }); + return; + } + + try { + const session = await stripe.checkout.sessions.retrieve(sessionId); + const customerId = session.customer as string; + + 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); + } + + res.json({ + message: "Welcome to DocFast Pro! 🎉", + apiKey, + docs: "/api", + note: "Save this API key — it won't be shown again.", + }); + } catch (err: any) { + console.error("Success page error:", err.message); + res.status(500).json({ error: "Failed to retrieve session" }); + } +}); + +// 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; + + 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; + } + + 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 }); + } +); + +// --- Pro key management --- +// These integrate with the usage middleware's PRO_KEYS set +const runtimeProKeys = new Set(); + +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); +} + +// --- Price management --- +let cachedPriceId: string | null = null; + +async function getOrCreateProPrice(): Promise { + if (cachedPriceId) return cachedPriceId; + + // Search for existing product + 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, + }); + if (prices.data.length > 0) { + cachedPriceId = prices.data[0].id; + return cachedPriceId; + } + } else { + const product = await stripe.products.create({ + name: "DocFast Pro", + description: "Unlimited PDF conversions via API. HTML, Markdown, and URL to PDF.", + }); + productId = product.id; + } + + const price = await stripe.prices.create({ + product: productId, + unit_amount: 900, // $9.00 + currency: "usd", + recurring: { interval: "month" }, + }); + + cachedPriceId = price.id; + return cachedPriceId; +} + +export { router as billingRouter };