diff --git a/projects/business/memory/sessions.md b/projects/business/memory/sessions.md
index d3b0d93..dcedf33 100644
--- a/projects/business/memory/sessions.md
+++ b/projects/business/memory/sessions.md
@@ -35,3 +35,14 @@
- **Status:** Core MVP functional, needs deployment
- **Next:** Ask human to create Forgejo repo, decide on hosting, add tests, build landing page
- **Blockers:** Need git repo + hosting
+
+## Session 4 — 2026-02-14 12:37 UTC (Morning Session 1)
+- Attempted to push code to Forgejo repo — **403 Forbidden** (token likely read-only)
+- Built **landing page** (public/index.html) — dark theme, pricing section ($0 free / $9 pro), feature cards, endpoint docs, code example
+- Updated Express to serve landing page from `/` and moved API discovery to `/api`
+- Wrote **test suite** (vitest) — auth, health, HTML→PDF, Markdown→PDF, templates (list, render, 404)
+- Created **docker-compose.yml** for deployment
+- Created **nginx reverse proxy config** with SSL
+- **Status:** Code complete, deployment-ready, blocked on Forgejo push + domain + Stripe
+- **Next:** Fix Forgejo push access, deploy to server, get domain, set up Stripe
+- **Blockers:** Forgejo token lacks write access; need domain + Stripe from human
diff --git a/projects/business/memory/state.json b/projects/business/memory/state.json
index 5bcddb3..5b353df 100644
--- a/projects/business/memory/state.json
+++ b/projects/business/memory/state.json
@@ -1,11 +1,15 @@
{
"phase": 1,
"phaseLabel": "Build MVP",
- "status": "mvp-built",
+ "status": "deployment-ready",
"product": "DocFast — HTML/Markdown to PDF API",
- "currentPriority": "Repo created at Forgejo (openclawd/docfast). Push code there. Hosting will be on user's existing server — prepare deployment instructions. Then: landing page, Stripe integration, tests.",
+ "currentPriority": "Fix Forgejo push (token may be read-only). Deploy to server. Buy domain. Set up Stripe for payments.",
"humanFeedback": "User created the Forgejo repo. Will host on existing server.",
- "blockers": [],
+ "blockers": [
+ "Forgejo push returns 403 — token may lack write permission",
+ "Need domain (docfast.dev or similar)",
+ "Need Stripe account for payments"
+ ],
"startDate": "2026-02-14",
- "sessionCount": 3
+ "sessionCount": 4
}
diff --git a/projects/business/src/pdf-api/.gitignore b/projects/business/src/pdf-api/.gitignore
new file mode 100644
index 0000000..aa0926a
--- /dev/null
+++ b/projects/business/src/pdf-api/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+dist/
+.env
+*.log
diff --git a/projects/business/src/pdf-api/deploy/nginx.conf b/projects/business/src/pdf-api/deploy/nginx.conf
new file mode 100644
index 0000000..42c5ba6
--- /dev/null
+++ b/projects/business/src/pdf-api/deploy/nginx.conf
@@ -0,0 +1,38 @@
+# Nginx reverse proxy config for DocFast
+# Place in /etc/nginx/sites-available/docfast.conf and symlink to sites-enabled
+# Requires: certbot for SSL (or existing wildcard cert)
+
+server {
+ listen 80;
+ server_name api.docfast.dev;
+ return 301 https://$host$request_uri;
+}
+
+server {
+ listen 443 ssl http2;
+ server_name api.docfast.dev;
+
+ # SSL certs (adjust paths)
+ ssl_certificate /etc/letsencrypt/live/api.docfast.dev/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/api.docfast.dev/privkey.pem;
+
+ # Security headers
+ add_header X-Frame-Options DENY;
+ add_header X-Content-Type-Options nosniff;
+ add_header X-XSS-Protection "1; mode=block";
+
+ # Max body size (HTML/Markdown input)
+ client_max_body_size 2m;
+
+ location / {
+ proxy_pass http://127.0.0.1:3100;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # PDF responses can be large
+ proxy_read_timeout 30s;
+ proxy_buffering off;
+ }
+}
diff --git a/projects/business/src/pdf-api/docker-compose.yml b/projects/business/src/pdf-api/docker-compose.yml
new file mode 100644
index 0000000..319fa8c
--- /dev/null
+++ b/projects/business/src/pdf-api/docker-compose.yml
@@ -0,0 +1,13 @@
+version: "3.8"
+services:
+ docfast:
+ build: .
+ restart: unless-stopped
+ ports:
+ - "127.0.0.1:3100:3100"
+ environment:
+ - API_KEYS=${API_KEYS}
+ - PORT=3100
+ - NODE_ENV=production
+ mem_limit: 512m
+ cpus: 1.0
diff --git a/projects/business/src/pdf-api/public/index.html b/projects/business/src/pdf-api/public/index.html
new file mode 100644
index 0000000..b990008
--- /dev/null
+++ b/projects/business/src/pdf-api/public/index.html
@@ -0,0 +1,199 @@
+
+
+
+
+
+DocFast — HTML & Markdown to PDF API
+
+
+
+
+
+
+
+
HTML & Markdown to PDF
+
One API call. Beautiful PDFs. Built-in invoice templates. No headless browser setup, no dependencies, no hassle.
+
+
+
+ curl -X POST https://api.docfast.dev/v1/convert/markdown \
+ -H "Authorization: Bearer YOUR_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"markdown": "# Invoice\\n\\nAmount: $500"}' \
+ -o invoice.pdf
+
+
+
+
+
+
+
Why DocFast?
+
+
+
⚡
+
Fast
+
Sub-second PDF generation. Persistent browser pool means no cold starts.
+
+
+
🎨
+
Beautiful Output
+
Full CSS support. Custom fonts, colors, layouts. Your PDFs, your brand.
+
+
+
📄
+
Templates
+
Built-in invoice and receipt templates. Pass data, get PDF. No HTML needed.
+
+
+
🔧
+
Simple API
+
REST API with JSON in, PDF out. Works with any language. No SDKs required.
+
+
+
📐
+
Flexible
+
A4, Letter, custom sizes. Portrait or landscape. Configurable margins.
+
+
+
🔒
+
Secure
+
Your data is never stored. PDFs are generated and streamed — nothing hits disk.
+
+
+
+
+
+
+
+
API Endpoints
+
+
POST
+
/v1/convert/html
+
Convert raw HTML (with optional CSS) to PDF.
+
+
+
POST
+
/v1/convert/markdown
+
Convert Markdown to styled PDF with syntax highlighting.
+
+
+
GET
+
/v1/templates
+
List available document templates with field definitions.
+
+
+
POST
+
/v1/templates/:id/render
+
Render a template (invoice, receipt) with your data to PDF.
+
+
+
GET
+
/health
+
Health check — verify the API is running.
+
+
+
+
+
+
+
Simple Pricing
+
No per-page fees. No hidden limits. Pay for what you use.
+
+
+
Free
+
$0/mo
+
+ - 100 PDFs / month
+ - All endpoints
+ - All templates
+ - Community support
+
+
Start Free
+
+
+
Pro
+
$9/mo
+
+ - 10,000 PDFs / month
+ - All endpoints
+ - All templates
+ - Priority support
+ - Custom templates
+
+
Get Started
+
+
+
+
+
+
+
+
+
diff --git a/projects/business/src/pdf-api/src/__tests__/api.test.ts b/projects/business/src/pdf-api/src/__tests__/api.test.ts
new file mode 100644
index 0000000..fd23638
--- /dev/null
+++ b/projects/business/src/pdf-api/src/__tests__/api.test.ts
@@ -0,0 +1,136 @@
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+import express from "express";
+import { app } from "../index.js";
+
+// Note: These tests require Puppeteer/Chrome to be available
+// For CI, use the Dockerfile which includes Chrome
+
+const BASE = "http://localhost:3199";
+let server: any;
+
+beforeAll(async () => {
+ process.env.API_KEYS = "test-key";
+ process.env.PORT = "3199";
+ // Import fresh to pick up env
+ server = app.listen(3199);
+ // Wait for browser init
+ await new Promise((r) => setTimeout(r, 2000));
+});
+
+afterAll(async () => {
+ server?.close();
+});
+
+describe("Auth", () => {
+ it("rejects requests without API key", async () => {
+ const res = await fetch(`${BASE}/v1/convert/html`, { method: "POST" });
+ expect(res.status).toBe(401);
+ });
+
+ it("rejects invalid API key", async () => {
+ const res = await fetch(`${BASE}/v1/convert/html`, {
+ method: "POST",
+ headers: { Authorization: "Bearer wrong-key" },
+ });
+ expect(res.status).toBe(403);
+ });
+});
+
+describe("Health", () => {
+ it("returns ok", async () => {
+ const res = await fetch(`${BASE}/health`);
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.status).toBe("ok");
+ });
+});
+
+describe("HTML to PDF", () => {
+ it("converts simple HTML", async () => {
+ const res = await fetch(`${BASE}/v1/convert/html`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ html: "Test
" }),
+ });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toBe("application/pdf");
+ const buf = await res.arrayBuffer();
+ expect(buf.byteLength).toBeGreaterThan(100);
+ // PDF magic bytes
+ const header = new Uint8Array(buf.slice(0, 5));
+ expect(String.fromCharCode(...header)).toBe("%PDF-");
+ });
+
+ it("rejects missing html field", async () => {
+ const res = await fetch(`${BASE}/v1/convert/html`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ });
+});
+
+describe("Markdown to PDF", () => {
+ it("converts markdown", async () => {
+ const res = await fetch(`${BASE}/v1/convert/markdown`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ markdown: "# Hello\n\nWorld" }),
+ });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toBe("application/pdf");
+ });
+});
+
+describe("Templates", () => {
+ it("lists templates", async () => {
+ const res = await fetch(`${BASE}/v1/templates`, {
+ headers: { Authorization: "Bearer test-key" },
+ });
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.templates).toBeInstanceOf(Array);
+ expect(data.templates.length).toBeGreaterThan(0);
+ });
+
+ it("renders invoice template", async () => {
+ const res = await fetch(`${BASE}/v1/templates/invoice/render`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ invoiceNumber: "TEST-001",
+ date: "2026-02-14",
+ from: { name: "Seller", email: "s@test.com" },
+ to: { name: "Buyer", email: "b@test.com" },
+ items: [{ description: "Widget", quantity: 2, unitPrice: 50, taxRate: 20 }],
+ }),
+ });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toBe("application/pdf");
+ });
+
+ it("returns 404 for unknown template", async () => {
+ const res = await fetch(`${BASE}/v1/templates/nonexistent/render`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(404);
+ });
+});
diff --git a/projects/business/src/pdf-api/src/index.ts b/projects/business/src/pdf-api/src/index.ts
index fcd5caf..a144da3 100644
--- a/projects/business/src/pdf-api/src/index.ts
+++ b/projects/business/src/pdf-api/src/index.ts
@@ -1,5 +1,7 @@
import express from "express";
import helmet from "helmet";
+import path from "path";
+import { fileURLToPath } from "url";
import rateLimit from "express-rate-limit";
import { convertRouter } from "./routes/convert.js";
import { templatesRouter } from "./routes/templates.js";
@@ -30,8 +32,12 @@ app.use("/health", healthRouter);
app.use("/v1/convert", authMiddleware, convertRouter);
app.use("/v1/templates", authMiddleware, templatesRouter);
-// Root
-app.get("/", (_req, res) => {
+// Landing page
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+app.use(express.static(path.join(__dirname, "../public")));
+
+// API root (for programmatic discovery)
+app.get("/api", (_req, res) => {
res.json({
name: "DocFast API",
version: "0.1.0",