Skip to content

About

Mini-Lovable is an AI app builder: you describe an idea, an LLM generates a small web app, you preview and deploy it, and Stripe-bought tokens meter generation and deploy.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

mini-lovable

A take-home test: an AI-powered app builder where a user types an idea, an LLM generates a complete single-file HTML app, the app is sandbox-validated, previewed, and deployed — all metered by a token billing system backed by Stripe payments and invoices.

Demo login is one click (no signup). Generation, preview, deploy, buying tokens, and invoice history all work end-to-end with a real free LLM model.


Feature walkthrough

  1. Generate — type a prompt like "a stopwatch with start/pause/reset and a lap counter", hit Generate. A real LLM call (OpenRouter, free model) returns a self-contained index.html, billed at the actual token cost.
  2. Preview — the result renders in an iframe with sandbox="allow-scripts" before you commit tokens.
  3. Deploy — pay a flat 1,000-token fee; the app gets a *.pages.dev URL (Cloudflare Pages; mock URL when no credentials are configured).
  4. Buy tokens — $5 / $10 / $20 packs through Stripe Checkout. In mock mode (no Stripe key) checkout completes instantly via a dev endpoint so the full loop is testable.
  5. History — tabs for your generated apps, the token ledger (every credit/debit with running balance), and HTML invoices (INV-YYYY-000001).

Quick start

Requires Node ≥ 20 (tested on 22).

npm install
npm run prisma:generate   # Prisma client
npm run prisma:migrate    # creates apps/api/prisma/dev.db
npm run dev               # API on :4000 + web on :5173

Open http://localhost:5173 and click Continue as demo. The demo user gets a starting token balance (50,000).

The mock Stripe flow is on by default. To activate real Stripe and a real OpenRouter model, see Environment variables.

Scripts

Command What it does
npm run dev API (:4000, tsx watch) + web (:5173, Vite)
npm run typecheck TypeScript across all workspaces
npm run lint oxlint across all workspaces
npm run build Production build of shared + api + web
npm run dev:api / npm run dev:web Run one workspace

CI runs install → prisma generate → typecheck → lint → build on main and PRs (.github/workflows/ci.yml).

Deploy on Render

This is a single Node web service (not npm run dev). The API serves /api/* and the built React app from the same $PORT.

  1. New Web Service, repo root, Node 22.
  2. Build command: npm ci --include=dev && npm run build:render (installs Chromium into node_modules so the sandbox works at runtime)
  3. Start command: npm start
  4. Environment:
Key Value
NODE_ENV production
DATABASE_URL file:./prod.db
DEMO_USER_TOKEN any long random string
APP_BASE_URL https://<your-service>.onrender.com (optional — Render's RENDER_EXTERNAL_URL is used if unset)
WEB_BASE_URL same as APP_BASE_URL
OPENROUTER_API_KEY optional; omit for mock generation
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET optional; omit for mock checkout
PLAYWRIGHT_BROWSERS_PATH 0 (installs Chromium inside node_modules)

Do not set the start command to npm run dev. That boots Vite on 5173 and the API on 4000; Render only proxies $PORT, so the public URL will not serve the app.

SQLite on the free plan is ephemeral (data resets when the instance moves). That is fine for a demo.


Environment variables

Copy .env.example to .env (root) and apps/api/.env. The API reads its own apps/api/.env — the root .env is for documentation/reference and future tooling.

Variable Default Purpose
PORT 4000 API port
DATABASE_URL file:./dev.db SQLite database (Prisma)
DEMO_USER_TOKEN demo-token Static bearer token for the demo user
OPENROUTER_API_KEY — Real LLM calls. Unset → mock mode (generates a static HTML preview).
OPENROUTER_MODEL openrouter/free Model id. openrouter/free auto-routes to an available free model (resilient to rate limits/rotation).
STRIPE_SECRET_KEY — Real Stripe Checkout. Unset → mock checkout that completes instantly via /api/dev/complete-checkout.
STRIPE_WEBHOOK_SECRET — Verifies /api/webhooks/stripe signatures (production).
APP_BASE_URL http://localhost:4000 API origin (mock checkout URLs, OpenRouter referer).
WEB_BASE_URL http://localhost:5173 Web app origin — Stripe success/cancel redirects land here.
CLOUDFLARE_API_TOKEN — Real Cloudflare Pages deploy. Unset → mock *.pages.dev URL.
CLOUDFLARE_ACCOUNT_ID — Cloudflare account for Pages.
CLOUDFLARE_PROJECT_PREFIX mini-lovable-app Pages project name prefix.
TOKENS_PER_USD 10000 Tokens credited per $1 purchased.
BILLING_MARKUP 1.2 Multiplier on LLM USD cost when charging generation.
DEPLOY_FEE_TOKENS 1000 Flat token fee to deploy (0 = free).
MIN_GENERATION_COST_USD 0.001 Floor billed for a generation so free models still meter.
DEMO_STARTING_TOKENS 50000 Balance for a new demo session.
TOKEN_PACK_USD 5,10,20 Checkout pack sizes in USD.

Stubbed vs real (honest list)

Feature Real (with keys) Stubbed (no keys)
LLM generation OpenRouter API, billed at cost Mock HTML, flat $0.001 charge
Payments Stripe Checkout + webhook Instant mock checkout (cs_test_mock_…)
Deploy Cloudflare Pages Direct Upload Deterministic https://mini-lovable-app-<id>.pages.dev URL (real:false)
Auth — (no user accounts) Single demo user, static token
Sandbox Real (vm syntax check + headless Chromium execution gate) — (always on, no stubs)

Architecture

npm workspaces monorepo:

apps/
  api/     Express + Prisma (SQLite) + OpenRouter + Stripe + Playwright sandbox
  web/     React (Vite) SPA — generate, preview, deploy, buy tokens, history
packages/
  shared/  Shared types (LedgerEntryType, etc.)

Billing model

Configured via env (see table above). Defaults:

  • 1 USD = TOKENS_PER_USD tokens on purchase (default 10,000). Generation uses tokens = round(usd × TOKENS_PER_USD × BILLING_MARKUP).
  • Generation is billed at the LLM's actual cost, floored at MIN_GENERATION_COST_USD (default $0.001 → 12 tokens).
  • Deploy is a flat DEPLOY_FEE_TOKENS (default 1,000). Set to 0 to make deploy free.
  • New demo sessions start with DEMO_STARTING_TOKENS (default 50,000).
  • GET /api/billing exposes these values so the UI stays in sync. Restart the API after changing env.
  • Every credit/debit writes an atomic LedgerEntry with a runningBalance inside a Prisma transaction (server-side enforced — the API never trusts the client).
  • Generation is refused with 402 before the LLM is called if the balance is below the generation floor.
  • Deploy is charged before the Cloudflare upload; if deploy fails, the deploy fee is refunded on the ledger.
  • Only the Stripe webhook credits the balance in real mode; checkout completion is idempotent by stripeSessionId (duplicate webhooks can't double-credit). Mock checkout (/api/dev/complete-checkout) is auth-gated, mock-session-only, and 404 in production.

Key flows

POST /api/auth/demo                     → { token, userId }                (demo auth)
POST /api/generate { prompt }           → LLM → sandbox → one repair if INVALID_JS/output → debit → 201
POST /api/apps/:id/deploy               → debit DEPLOY_FEE_TOKENS → Pages upload → { url } (refund on failure)
GET  /api/billing                       → live pricing (fees, packs, markup)
POST /api/tokens/checkout { usd }       → Stripe session or mock URL
POST /api/webhooks/stripe               → verifies & credits balance       (raw body, before express.json)
POST /api/dev/complete-checkout         → dev-only mock completion          (404 in production)
GET  /api/apps · /api/ledger · /api/invoices · /api/invoices/:id (HTML)

Sandbox & security

Two-stage validation of every generated app, both before any tokens are debited (the LLM is also skipped when the balance is below the generation floor):

  1. Static gates — prompt-injection markers rejected; output must be a complete HTML doc with no external <script src>/<link>, no fetch/XMLHttpRequest/WebSocket; inline JS must pass a vm.Script syntax check.
  2. Execution gate — the HTML is run in headless Chromium (Playwright) with all network requests aborted and a 15s timeout; runtime errors reject the app with a 422.

If validation fails with INVALID_JS, INVALID_OUTPUT, or SANDBOX_REJECTED, the API sends the error plus the broken HTML back to the model for one repair attempt. Tokens are still charged only after a passing artifact (both attempts' LLM cost, if any). A second failure is returned to the user as 422.

Prompts are also checked for injection markers (ignore previous instructions, <|im_start|>, etc.) and for non-app intent (trivia questions, chat, essays) before the LLM is called. Ambiguous leftovers that still aren't an app come back from the model as NOT_AN_APP and are rejected with no debit.


API error convention

All errors return { error, code } with a stable code:

Code HTTP Meaning
INVALID_PROMPT 400 Bad request body
PROMPT_REJECTED 400 Prompt failed injection checks
NOT_AN_APP 400 / 422 Prompt is a question/chat, not an app to build
UNAUTHORIZED 401 Missing/invalid bearer token
INSUFFICIENT_BALANCE 402 Not enough tokens
NOT_FOUND 404 Unknown resource
INVALID_OUTPUT / SANDBOX_* 422 Generated app failed validation
DEPLOY_FAILED 422 Cloudflare Pages upload failed
CHECKOUT_NOT_PAID etc. 422 Payment-related rejection

Demo / test notes

  • Auth — still a stub (no signup). POST /api/auth/demo creates a new User and returns Bearer <DEMO_USER_TOKEN>:<userId>. That token is stored in localStorage, so refresh keeps the same wallet. New session in the header mints another user (50,000 tokens, empty history). Old tokens without a user id still map to the first row in the DB. This is not real auth — anyone with the demo token can mint sessions.
  • Mock buy — click a pack on /buy; in mock mode the SPA POSTs /api/dev/complete-checkout with your session token and credits instantly. In real mode Stripe Checkout returns to /buy?success=1 and the webhook applies the credit (stripe listen --forward-to localhost:4000/api/webhooks/stripe).
  • Invoices — generated on every purchase; render as HTML at /api/invoices/:id and are listed on the History tab.

Production secrets

Locally, secrets live in apps/api/.env (gitignored). In production I would not copy .env onto a server. Keys would sit in the host secret store — Cloudflare Workers/Pages secrets, Fly.io secrets, or a manager like Doppler / Infisical / GCP Secret Manager — injected at process start, rotated, and scoped per environment. CI would use GitHub Actions encrypted secrets, never plaintext in the workflow file.

Rust

Rust is not required for this test. With more time I would consider it for the sandbox worker: a small isolated process that syntax-checks and executes generated HTML, where a tight memory-safe sandbox matters more than iteration speed. The rest of the app is I/O-bound TypeScript (HTTP, Stripe, OpenRouter) and would stay that way.


What I'd do with more time

  • Real user accounts (email/password or OAuth) instead of a single demo user — the schema already supports User.id as a foreign key everywhere.
  • Streamed generation — text/event-stream so users see code being written instead of a spinner.
  • Edit/regenerate loop — "make the stopwatch dark mode" as a follow-up message with the previous app as context.
  • Versioned deploys — keep old deployments; one-click rollback.
  • Per-user rate limiting and an admin dashboard for token usage/costs.
  • Postgres + proper migrations for production, with the SQLite dev db only local.
  • Idempotency keys on /api/generate to prevent double-charging on retries, and a retry/backoff for transient OpenRouter 429s.

Repo layout

Path Description
PLAN.md Build plan; phase statuses
take-home-test.md Original take-home prompt
docs/PHASE-*.md Per-phase design + verification records
.github/workflows/ci.yml CI: typecheck + lint + build
apps/api/prisma/schema.prisma User, GeneratedApp, LedgerEntry, Invoice

About

Mini-Lovable is an AI app builder: you describe an idea, an LLM generates a small web app, you preview and deploy it, and Stripe-bought tokens meter generation and deploy.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages