Business agent: Hetzner DNS API works with same token, unblocked
This commit is contained in:
parent
a0a03e337c
commit
46967f730a
9 changed files with 247 additions and 19 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
5
projects/business/src/pdf-api/dist/index.js
vendored
5
projects/business/src/pdf-api/dist/index.js
vendored
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
20
projects/business/src/pdf-api/package-lock.json
generated
20
projects/business/src/pdf-api/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ footer { padding: 40px 0; text-align: center; color: var(--muted); font-size: 0.
|
|||
<li>All templates</li>
|
||||
<li>Community support</li>
|
||||
</ul>
|
||||
<a href="#" class="btn btn-secondary" style="width:100%">Start Free</a>
|
||||
<a href="mailto:hello@docfast.dev?subject=Free API Key" class="btn btn-secondary" style="width:100%">Get Free Key</a>
|
||||
</div>
|
||||
<div class="price-card featured">
|
||||
<h3>Pro</h3>
|
||||
|
|
@ -183,7 +183,7 @@ footer { padding: 40px 0; text-align: center; color: var(--muted); font-size: 0.
|
|||
<li>Priority support</li>
|
||||
<li>Custom templates</li>
|
||||
</ul>
|
||||
<a href="#" class="btn btn-primary" style="width:100%">Get Started</a>
|
||||
<a href="#" class="btn btn-primary" style="width:100%" onclick="checkout(event)">Get Started</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -195,5 +195,18 @@ footer { padding: 40px 0; text-align: center; color: var(--muted); font-size: 0.
|
|||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
async function checkout(e) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await fetch('/v1/billing/checkout', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.url) window.location.href = data.url;
|
||||
else alert('Something went wrong. Please try again.');
|
||||
} catch (err) {
|
||||
alert('Something went wrong. Please try again.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ interface UsageRecord {
|
|||
// In-memory usage tracking (replace with Redis/DB for production)
|
||||
const usage = new Map<string, UsageRecord>();
|
||||
|
||||
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;
|
||||
|
|
|
|||
182
projects/business/src/pdf-api/src/routes/billing.ts
Normal file
182
projects/business/src/pdf-api/src/routes/billing.ts
Normal file
|
|
@ -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<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`,
|
||||
});
|
||||
|
||||
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<string>();
|
||||
|
||||
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<string> {
|
||||
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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue