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.
- 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. - Preview — the result renders in an iframe with
sandbox="allow-scripts"before you commit tokens. - Deploy — pay a flat 1,000-token fee; the app gets a
*.pages.devURL (Cloudflare Pages; mock URL when no credentials are configured). - 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.
- History — tabs for your generated apps, the token ledger (every credit/debit with running balance), and HTML invoices (
INV-YYYY-000001).
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 :5173Open 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.
| 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).
This is a single Node web service (not npm run dev). The API serves /api/* and the built React app from the same $PORT.
- New Web Service, repo root, Node 22.
- Build command:
npm ci --include=dev && npm run build:render(installs Chromium intonode_modulesso the sandbox works at runtime) - Start command:
npm start - 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.
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. |
| 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) |
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.)
Configured via env (see table above). Defaults:
- 1 USD =
TOKENS_PER_USDtokens on purchase (default 10,000). Generation usestokens = 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 to0to make deploy free. - New demo sessions start with
DEMO_STARTING_TOKENS(default 50,000). GET /api/billingexposes these values so the UI stays in sync. Restart the API after changing env.- Every credit/debit writes an atomic
LedgerEntrywith arunningBalanceinside a Prisma transaction (server-side enforced — the API never trusts the client). - Generation is refused with
402before 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.
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)
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):
- Static gates — prompt-injection markers rejected; output must be a complete HTML doc with no external
<script src>/<link>, nofetch/XMLHttpRequest/WebSocket; inline JS must pass avm.Scriptsyntax check. - 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.
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 |
- Auth — still a stub (no signup).
POST /api/auth/democreates a new User and returnsBearer <DEMO_USER_TOKEN>:<userId>. That token is stored inlocalStorage, 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-checkoutwith your session token and credits instantly. In real mode Stripe Checkout returns to/buy?success=1and 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/:idand are listed on the History tab.
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 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.
- Real user accounts (email/password or OAuth) instead of a single demo user — the schema already supports
User.idas a foreign key everywhere. - Streamed generation —
text/event-streamso 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/generateto prevent double-charging on retries, and a retry/backoff for transient OpenRouter 429s.
| 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 |