Document 03 of 14 · ARCHITECTURE.md

Artisan PDF Studio, architecture

How a template becomes a customized PDF that someone paid for. Written for the owner first (who reads HTML and CSS and follows the rest), then for the Claude Code session that builds it. Facts about vendor limits were checked on 2026-09-16 and carry their source; re-check before relying on a number a year from now.

1. The whole thing in one paragraph

A template is a folder of plain HTML and CSS plus two small JSON files. The customizer page loads that HTML into an iframe and, as the visitor types, a shared script (studio.js) writes their words into the marked places and swaps the colour theme. When they pay, a small function on Vercel saves what they typed, sends them to Stripe, and when Stripe says "paid" it sends the same HTML plus the same values to a rendering service that returns a PDF. The PDF goes into a private bucket on Supabase, the visitor gets a link on the success page and by email, and the link keeps working for a year. The free version of every template is the same HTML rendered once, by hand, with the default values, and committed as a static file, so it costs nothing to serve.

2. The template package: studio/<slug>/

studio/invoice/
  template.html     the sheet(s)
  template.css      print-first styles
  fields.json       what the customizer exposes
  meta.json         catalogue data
  shots/            generated: preview.png, og.png, pin.png

template.html

A complete HTML document (so it can be opened on its own in a browser and printed), with hooks the runtime understands:

<div class="sheet">
  <header>
    <img data-bind-src="logo" data-show="logo" alt="">
    <h1 data-bind="business_name" data-max>Studio &amp; Co.</h1>
    <p  data-bind="address" data-max>12 Bay Street, Portland</p>
  </header>
  <table>
    <tbody data-list="items">
      <template>
        <tr>
          <td data-bind="description" data-max>Design retainer</td>
          <td data-bind="qty">1</td>
          <td data-bind="rate" data-format="money">1,200.00</td>
          <td data-bind="line_total" data-format="money">1,200.00</td>
        </tr>
      </template>
    </tbody>
  </table>
  <section data-show="show_notes">
    <p data-bind="notes" data-max>Thank you for your business.</p>
  </section>
  <p data-bind="total" data-format="money" data-computed>1,200.00</p>
</div>

template.css

@page { size: Letter; margin: 0; }   /* the default only; see below */
.sheet { width: 8.5in; height: 11in; padding: .5in; box-sizing: border-box; background: var(--t-paper); color: var(--t-ink); }
html.a4 .sheet { width: 210mm; height: 297mm; }
:root { --t-paper:#fff; --t-ink:#1a1a1a; --t-accent:#b3261e; --t-muted:#6b6f76; }

An @page rule cannot be selected by a class, so the paper size is switched by the runtime, which writes a <style id="page-size">@page { size: A4; margin: 0 }</style> (or Letter, or Letter landscape when meta.orientation says so) into the head and toggles the a4 class for the sheet's own dimensions. Proven in spikes/render/: the same document printed at 612 by 792 and 595 by 842 points.

Fonts are @font-face rules pointing at /assets/fonts/<face>-<weight>.woff2, static instances, one file per weight and style, never the variable font. The render proof found that Chromium writes a variable font into the PDF as Type 3 outlines with no font program and four times the file size, and writes a static instance as a proper embedded TrueType. The site shell may use the variable face on screen; a template may not. The renderer sees the files through a <base href> (section 4), the preview sees them relative to the site. Never a Google Fonts link.

fields.json

{
  "groups": [
    { "title": "Your business", "fields": [
      { "key": "business_name", "label": "Business name", "type": "text", "max": 40, "default": "Studio & Co." },
      { "key": "logo", "label": "Logo", "type": "image", "hint": "PNG or JPEG, square works best" },
      { "key": "notes", "label": "Notes", "type": "long", "max": 220, "assist": true,
        "assistBrief": "A short, warm payment note at the foot of an invoice." }
    ]},
    { "title": "Line items", "fields": [
      { "key": "items", "type": "list", "max": 12, "row": [
        { "key": "description", "type": "text", "max": 60 },
        { "key": "qty", "type": "number", "min": 0, "max": 9999 },
        { "key": "rate", "type": "number", "min": 0, "max": 999999 }
      ]}
    ]},
    { "title": "Options", "fields": [
      { "key": "currency", "type": "select", "options": ["USD","EUR","GBP","CAD","AUD"], "default": "USD" },
      { "key": "show_notes", "type": "toggle", "default": true },
      { "key": "theme", "type": "theme" },
      { "key": "size", "type": "size", "default": "letter" }
    ]}
  ],
  "compute": "items.forEach(i => i.line_total = i.qty * i.rate); v.total = items.reduce((s,i) => s + i.line_total, 0)"
}

The compute string is the only arithmetic a template may carry, and it is evaluated in a sandboxed function with v (the values) as its sole argument. The same string runs in the preview and in the render, so the two cannot disagree. Every max, min and option list here is enforced twice: in the customizer as a courtesy and in api/checkout.js as the rule.

meta.json

{
  "slug": "invoice", "title": "Invoice Template", "category": "business",
  "tier": 12, "pages": 1, "sizes": ["letter","a4"],
  "themes": {
    "ledger":    { "--t-accent": "#1f4e79", "--t-muted": "#5b6b7a" },
    "light-ink": { "--t-accent": "#333",    "--t-muted": "#777", "printerFriendly": true }
  },
  "fonts": ["newsreader", "inter-tight"],
  "phrase": "invoice template pdf",
  "family": "business-set",
  "free": { "size": "letter", "theme": "ledger", "credit": true },
  "version": 3
}

version bumps whenever template.html or template.css changes in a way that alters output; orders record the version they were rendered with.

3. The runtime: assets/js/studio.js

One file, used in four places: the customizer preview, the template page's live sheet, the homepage hero and the render service. It exposes:

The customizer talks to the iframe with postMessage; the render service never needs to, because the values arrive inline (section 4).

4. Rendering

4.1 Primary: Cloudflare Browser Run, REST endpoint

api/render.js assembles one HTML string and makes one request:

  1. Read studio/<slug>/template.html from the deployment (the folder is shipped with the function via includeFiles in vercel.json).
  2. Insert, in the <head>: <base href="https://www.artisanpdfstudio.com/"> so every relative font and image URL resolves to the live site; <script>window.__values = {…}</script> with the order's values JSON (serialised with < escaped as <); and <script src="/assets/js/studio.js">.
  3. POST https://api.cloudflare.com/client/v4/accounts/{CF_ACCOUNT_ID}/browser-rendering/pdf with the bearer token, a JSON body of { html, waitForSelector: { selector: "html[data-ready]" }, pdfOptions: { format: "Letter", printBackground: true, preferCSSPageSize: true } }. The response body is the PDF.
  4. Do it again with A4 (and the a4 class in the values), so every paid order gets both sizes.
  5. Upload both to the private orders bucket at orders/<order_id>/<slug>-letter.pdf and …-a4.pdf, set the order to ready.

The endpoint accepts html, addStyleTag, addScriptTag, setJavaScriptEnabled, gotoOptions, viewport, pdfOptions and waitForSelector, returns PDF bytes, and is documented at https://developers.cloudflare.com/browser-run/quick-actions/pdf-endpoint/ (the product was renamed from Browser Rendering on 2026-04-15). Limits that matter: Workers Free allows ten browser-minutes a day and one quick-action request every ten seconds; Workers Paid ($5 a month) includes ten browser-hours, then $0.09 an hour, billed on duration only. A render takes a few seconds, so ten hours is on the order of ten thousand PDFs. Sources: https://developers.cloudflare.com/browser-run/pricing/ and https://developers.cloudflare.com/browser-run/limits/

Retry rule: a 429 or 5xx from the endpoint leaves the order at paid with render_attempts incremented and the function returns; the webhook's own retry (section 6) and the success page's poll both call render again with backoff. After five attempts the order goes to render_failed and the owner gets an email.

4.2 Fallback: Chromium inside the Vercel function

If Browser Run is ever unavailable or too slow, the same HTML string can be rendered by @sparticuz/chromium with puppeteer-core in the function itself. Checked 2026-09-16: @sparticuz/chromium v153 pairs with the current puppeteer-core, the package is ESM-only (the function must be .mjs), it unpacks about 130 MB into /tmp, and it needs fonts supplied through its fonts directory because only Open Sans ships with it. Vercel functions on Fluid compute get 2 GB memory and a 300-second ceiling on every plan, and the 250 MB bundle limit still applies, all of which this fits. Sources: https://github.com/Sparticuz/chromium/releases/tag/v153.0.0, https://vercel.com/docs/functions/limitations, https://vercel.com/docs/functions/configuring-functions/memory

Not built in Phase 1. Documented so that switching is a known job and not a research project.

4.2a What was proven before the build (2026-09-16)

spikes/render/ prints a stand-in invoice through the same DevTools call Browser Run makes and checks the files. Results: Letter and A4 from one document by switching a class; data-ready gating on document.fonts.ready works; the overflow guard measures real boxes (and caught a box in the proof itself that was one line too short); fonts embed only when they are static instances. The pdfcheck in docs/QA.md asserts the last point on every file: FontFile2 present, Type3 absent.

4.3 The free PDFs, previews, OG cards and pins

These never touch the cloud renderer. studio-src/free.mjs and shots.mjs drive a local Chrome over the DevTools protocol, exactly the way measureandbuy's og/generate.mjs does, and commit the output: templates/<cat>/<slug>/free/<slug>-letter.pdf, shots/preview.png (page one at 1200 px wide), og/<slug>.png (1200 by 630, the sheet at an angle on the desk with the title), pins/<slug>.png (1000 by 1500, the sheet upright with the title and "free version" flag). Hand-run, no build step, same reasoning as the generators decision on measureandbuy.

5. Data model (Supabase, Postgres)

create table orders (
  id              uuid primary key default gen_random_uuid(),
  token           text not null,                 -- 32 random bytes, base64url; the capability in the link
  status          text not null default 'pending'
                  check (status in ('pending','paid','ready','render_failed','refunded')),
  slug            text not null,
  template_version int not null,
  values          jsonb not null,                -- what they typed, validated
  size            text not null default 'letter',
  email           text,                          -- from Stripe after payment
  amount_cents    int not null,
  currency        text not null default 'usd',
  stripe_session_id text unique,
  stripe_payment_intent text,
  pdf_letter      text,                          -- storage path
  pdf_a4          text,
  render_attempts int not null default 0,
  downloads       int not null default 0,
  created_at      timestamptz not null default now(),
  paid_at         timestamptz,
  ready_at        timestamptz,
  expires_at      timestamptz                    -- paid_at + 1 year; links refuse after
);

create table order_events (                      -- the audit trail: every webhook, render, email, download
  id          bigserial primary key,
  order_id    uuid references orders(id),
  kind        text not null,                     -- 'stripe.checkout.session.completed', 'render.ok', 'email.sent', 'download', ...
  stripe_event_id text unique,                   -- makes webhook handling idempotent
  detail      jsonb,
  created_at  timestamptz not null default now()
);

create table assist_uses (                       -- rate limit for "Write it for me"
  ip_hash     text not null,
  day         date not null,
  count       int not null default 0,
  primary key (ip_hash, day)
);

alter table orders enable row level security;
alter table order_events enable row level security;
alter table assist_uses enable row level security;
-- No policies on purpose. Only the service role, from Vercel functions, touches these tables.

Storage: one private bucket, orders. Files are reached only through signed URLs minted by api/order.js (24 hours each, re-minted on every visit until expires_at). Supabase's createSignedUrl(path, seconds) has no upper bound on the expiry, but short links re-minted on demand are the safer shape because a leaked link dies in a day. Free plan: 1 GB storage, 5 GB egress a month, 50 MB per file; a two-size order is about 300 KB, so a thousand orders is a third of a gigabyte. Sources: https://supabase.com/docs/reference/javascript/storage-from-createsignedurl, https://supabase.com/docs/guides/platform/manage-your-usage/storage-size

A free Supabase project pauses after seven days without activity. During the build, and in any quiet week before launch, that would turn every purchase into an error. Either the weekly GitHub Action (section 9) runs a one-row query to keep it warm, or the project goes to Pro ($25 a month) the day real keys go in. Source: https://supabase.com/docs/guides/deployment/going-into-prod

Which project. The existing "PDF Farm" project, ahjsvadwxacschqgdahj, in the Synergy org, us-east-1, already paid for on the Pro plan. Owner's call, 2026-09-16: it is the consolidation point for the earlier attempts. It is reset in place rather than extended, because nothing in it is wanted:

  1. supabase/migrations/20260916000001_reset_pdf_farm.sql drops the old trigger on auth.users, the eight tables, the seven helper functions, the app_role enum, and the two public buckets with their one object. Every statement is if exists, so it can be re-run.
  2. supabase/migrations/20260916000002_orders.sql creates the schema above and the private orders bucket.
  3. In the dashboard: delete the twelve old edge functions (SETUP.md lists them), rename the project "Artisan PDF Studio", create a new secret API key for the Vercel functions and disable the legacy anon and service_role keys once nothing else uses them, because earlier attempts' deployments may still hold copies. The one auth user can be deleted or left; the new site has no accounts until Phase 3.

The Lovable-managed project the old app uses (wonwshiaormnewhwtwwn) is not visible from the Synergy org and goes away when the owner deletes the Lovable project.

Phase 3 adds customers (magic-link accounts) and a passes table for the all-access subscription; nothing in v1 is shaped in a way that blocks that.

6. API routes (api/, Node, no framework)

Vercel serves static files as-is and turns each file under api/ into a function with no configuration. Node 24 is the default runtime for new projects. Source: https://vercel.com/docs/functions/quickstart

Route Method Does Guards
/api/checkout POST Validates {slug, values, size} against fields.json (types, enums, lengths, list caps, image data URL type and size). Reads the price from meta.json, never from the request. Inserts the order (pending) with a fresh token. Creates a Stripe Checkout Session with inline price_data, metadata.order_id, success_url=/order/?id=…&t=…, automatic_tax if enabled. Returns the Stripe URL. 20 per hour per IP
/api/stripe-webhook POST Verifies the signature with the raw body. Handles both checkout.session.completed and checkout.session.async_payment_succeeded (delayed payment methods complete unpaid first). Inserts an order_events row keyed by stripe_event_id; a duplicate insert means "already handled", return 200. Marks the order paid, records email and payment intent, calls render, sends the email. Also handles charge.refundedrefunded. Stripe signature; idempotent by event id and by session id
/api/render POST Section 4. Internal. INTERNAL_KEY header
/api/order GET {id, t} → status, and when ready, two fresh signed URLs. Increments downloads on a ?dl= hit. If the order is still pending a minute after creation, asks Stripe for the session directly and completes it (belt and braces for a delayed webhook). Refuses after expires_at or when refunded. token match
/api/draft POST Section 7. Turnstile token, 10 per day per IP
/api/subscribe POST Adds the address to the Resend contact list with the "paper set" tag, triggers the welcome email with the bundle link. Turnstile token, 5 per day per IP

Stripe facts that shaped this, checked 2026-09-16: metadata allows 50 keys with values up to 500 characters, which is why the values live in Postgres and only order_id rides on the session; webhook delivery is at-least-once with retries for up to three days, which is why fulfilment is keyed on the session id; inline price_data works with automatic_tax. Sources: https://docs.stripe.com/metadata, https://docs.stripe.com/checkout/fulfillment, https://docs.stripe.com/api/checkout/sessions/create

Stripe Tax, if turned on, is 0.5% of volume on Checkout with no code beyond automatic_tax: {enabled: true} and a tax code on the line item; Stripe's list names txcd_10000000 ("General, electronically supplied services") for digital downloads sold outside the US and has a separate digital-goods code for US sales; pick both from the list in the dashboard rather than from memory. Source: https://docs.stripe.com/tax/digital-products

7. Claude, in two places and never in the money path

7.1 "Write it for me" (api/draft.js)

For fields marked assist: true. The request carries {slug, key, brief, current}; the function loads the field's label, limit and assistBrief from fields.json (the client's own copy is not trusted), verifies the Turnstile token, checks assist_uses, then calls the Anthropic API with the official Node SDK (@anthropic-ai/sdk), model claude-opus-5, a short system prompt ("You write the wording that goes on a printed {template}. Plain, warm, no cliché. At most {max} characters. Return only the wording."), the brief as the user turn, and a structured output schema { text: string } so nothing but the wording comes back. It trims to max and returns it. The field shows the draft with an Undo. Enable the server-side refusal fallback the SDK offers so a rare declined request still returns a draft rather than an error.

Cost, at Opus 5's $5 per million input tokens and $25 per million output: a draft is about 400 tokens in and 80 out, so roughly $0.004 each; ten thousand drafts is about $40. If that ever matters, claude-haiku-4-5 does this job at a fifth of the price, and that is the owner's call, not a default. Model IDs and prices from the claude-api skill's table, cached 2026-06-24.

The function has a hard monthly ceiling (an env var, default 5,000 drafts) and simply returns "Not available right now" past it. Nothing about the page depends on it working.

7.2 Copy drafting (studio-src/copy.mjs, hand-run)

Given a slug, it reads meta.json and the template's default text and asks Claude for a first draft of the description, five FAQ pairs, the pin title and description and the OG line, writing them to studio/<slug>/copy.draft.md. A person rewrites that into meta.json's copy block; the draft is never committed as-is and the human-voice lint runs on the result. This is a design-tool script, not a build step, and it does not run on Vercel.

Nowhere on the site does the word "AI" appear. The button says "Write it for me". House rule.

8. Security and abuse, the short list

9. Operations

10. Failure modes and what the visitor sees

What breaks What happens What the visitor sees
Renderer returns 429/5xx Order stays paid, retry with backoff from the webhook retry and the success-page poll; after 5 attempts render_failed, owner emailed "Your PDF is taking longer than usual. Your order number is … and a link will be emailed within the hour." Never a spinner forever.
Stripe webhook delayed Success page polls /api/order; after 60 s the order route asks Stripe for the session itself A slightly longer wait, then the buttons.
Supabase paused (free plan) Checkout cannot insert the order "We can't take orders right now." The weekly keep-warm exists to make this not happen; Pro removes it.
Cloudflare Browser Run withdrawn or changed Switch render.js to section 4.2 Nothing, if done before it bites.
Draft assist over budget or down Button returns "Not available right now" They type it themselves.
A template's fonts fail to load in the render data-ready never sets; waitForSelector times out; order retries Same as a renderer failure; the smoke test catches this before a visitor does.

11. What it costs to run

Service At launch When it grows
Vercel Hobby, $0 (personal projects only; commercial use needs Pro, $20) Pro, $20 a month, which this site needs on Vercel's terms as soon as it sells
Supabase PDF Farm, already paid for ($10 a month on the Pro org); nothing new same, until traffic warrants more compute
Cloudflare Workers Paid (Browser Run) $5 a month $5 plus $0.09 per browser-hour past ten
Stripe 2.9% + $0.30 per sale; Stripe Tax 0.5% if on same
Resend Free: 3,000 emails a month, 100 a day, 1,000 contacts $20 a month for sending, marketing tiers from $40 at 5,000 contacts
Anthropic pennies tens of dollars at ten thousand drafts a month
Domain already owned

Note on Vercel's Hobby plan: its terms restrict it to non-commercial use. A site that takes payments should be on Pro from the day live keys go in. That is the one line item above that is not optional.

All documents