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.

+ +
+ // Convert markdown to PDF in one call
+ 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 +
+ +
+
+
+ + + + + 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",