What shipped

StackFill changelog.

Reverse-chronological. Pre-launch entries are dated; once the first paying shop is live we switch to semver.

Reverse-chronological. Pre-launch entries are dated; from v0.2.0 onward we use semver.

v0.2.0 — 2026-05-24

Post-launch hardening pass. Brings the public /v1 API, webhook delivery, API-key rotation, billing-readiness, and the front-of-house docs + disclosure surfaces to first-paying-customer quality.

Public REST API

  • /v1/fills — list (filter by status, template_id, source, since; cursor pagination), get (with last_render), finalize (idempotent on Idempotency-Key).
  • /v1/renders/{id}/download — streamed from R2 with attachment disposition (?inline=1 for inline).
  • /v1/renders/{id}/retry — in-place retry on failed renders.
  • /v1/templates/{id}/fields — derived field schema for callers.
  • /v1/templates/{id}/publish — promote ingested template to live; emits template_published event.

Webhook hardening

  • Retry sweep with exponential backoff [5, 15, 60, 240, 720, 1440] minutes, max 10 attempts.
  • /v1/webhook_endpoints/{id}/deliveries/{delivery_id} + /retry — delivery detail + manual redelivery.
  • /v1/webhook_endpoints/{id}/test — fires a stackfill.test event.
  • Auto-disable after 20 consecutive failures.
  • Operator sweep at /api/admin/webhooks/sweep (dual auth: platform admin session OR WEBHOOK_SWEEP_SECRET bearer).

API key rotation

  • 24-hour grace window. Old key keeps working during the grace; every response carries Deprecation (RFC 9745), Sunset (RFC 8594), and Link: rel="successor-version" (RFC 5988) headers so callers can swap in the new key before the cutover.
  • POST /v1/integrations/api-keys/{id}/rotate (team-owner) and /api/admin/api-keys/{id}/rotate (platform admin).

Billing readiness

  • STRIPE-PRODUCTION-CHECKLIST.md — 8-section runbook for live setup (products, secrets, webhook endpoint, customer portal, smoke test).
  • /api/admin/billing/config-health — diagnostic that audits Stripe + plan catalog + portal + renderer in one round trip.

Front-of-house

  • /docs/api/guides/errors/ — comprehensive reference for ~125 error codes with anchor-linkable rows.
  • /docs/errors/<code> → 302 to the guide page with #<code> fragment. Stable doc_url contract; no more SPA-fallback 200s.
  • /security/ + /.well-known/security.txt — RFC 9116 disclosure surface with scope, response timeline, safe harbor.
  • /changelog/ — renders the real CHANGELOG.md instead of SCAFFOLD PLACEHOLDER.
  • /app/ unauthenticated → 302 /app/login/?return=<original> instead of painting the workspace sidebar pre-auth.
  • POST /api/auth/magic-link — 400 missing_fields for {}-shaped bodies; non-POST methods 405 with Allow: POST; enumeration-resistant 200 for valid email shapes preserved.
  • Footer Security link across every page.

Storefront embed widget

  • Modal overlay locks host page scroll on mount, restores on close.
  • Wheel events on the overlay margin no longer leak to the host page.
  • /demo/storefront/ script src cache-busted so existing visitors get the new widget on next page load.

QA infrastructure

  • QA-CHECKLIST.md — 12-section walkthrough doc with action → expected → red-flags format, plus a populated issue log + summary table of what was tested vs deferred.
  • All 6 findings from the Chrome-MCP unauthenticated walk shipped + verified.

2026-05-22 — fix(upload): same-PDF uploads refresh stale parser scenes

Follow-up to the FIU text preservation fix: the parser was fixed, but same-file uploads still returned the previously saved scene because /api/templates dedupes on source_pdf_hash. That made re-uploading the FIU PDF keep the old O987... and ?rst... objects.

  • Parser output now includes _meta.parserVersion.
  • App uploads and /v1/templates uploads detect duplicate templates whose latest scene has an older/missing parser version.
  • Those duplicate uploads now append a fresh scene revision from the uploaded PDF, preserving the same template id/slug but updating the scene objects.
  • Template quota checks now happen after same-hash handling, so a user at the template limit can still refresh an existing/restored template by uploading the same PDF.

2026-05-22 — fix(editor+parser): named layer groups and FIU text preservation

Follow-up from the FIU Baptist card authoring pass:

  • Layer groups — new groups now receive a default name (Group 1, Group 2, ...), expose a Group name field in the inspector, and render as collapsible parent rows in the Layers panel with the individual child layers nested underneath.
  • Layers default — the Layers panel now opens on the Text filter because that is the first authoring pass most templates need.
  • Phone labels — parser coalescence no longer merges a bold one-letter phone label (O, F, M) into its light-weight phone number. The label and value now stay separate objects, preserving the number's original face.
  • FIU email ligature — ToUnicode parsing now accepts spaced destination hex like <0066 0069>, so the fi ligature in firstnamelastinital@Websitehere.net maps correctly instead of becoming ?.

2026-05-22 — feat(editor): split text objects, group-lock artwork, polish font upload step

User report from the FIU Baptist card: some logical fill fields arrive as one text run (Firstname Lastname, M.D., MBA, MHA), while some fixed text needs to be separated from variable data (O987-654-3210, where O is a fixed office label and the phone number is the field).

  • Text splitting — the inspector now exposes a split-text action for a single selected text object. The modal accepts | split marks, keeps all source styling, creates separate scene objects at measured text advances, and gives duplicate editable labels unique names so the fill form does not collapse them into one field.
  • Marquee selection — dragging on blank canvas now draws a selection rectangle and selects every object whose bounding box intersects it. Shift-drag adds/removes from the current selection.
  • Grouping and locking — multi-select now supports Group, Group + lock, Lock selected, and Ungroup from the inspector. Clicking one object in a group selects the whole group; group-lock marks those elements non-editable and drag-locked in one operation, which matches logo/artwork authoring.
  • Upload font step — the post-PDF upload flow now removes the ingest progress state, shows required fonts in a compact table, and moves the font dropzone into an upload modal. The user can recheck or skip/open the editor without an inline dropzone taking over the page.

2026-05-22 — fix(upload): re-uploading a deleted same-PDF template restores it

Soft-delete sets templates.status = 'archived', and normal listing / public fill lookup intentionally exclude archived rows. Upload also has an idempotency shortcut keyed on source_pdf_hash so re-uploading the exact same source PDF doesn't create duplicate templates.

Bug: the hash lookup did not account for archived status. If an operator deleted a template, then uploaded the same PDF again, /api/templates found the archived row by hash and returned it unchanged. The dashboard still hid it, and /fill/<slug> returned template_not_found, which made the upload look like it "succeeded" but produced a dead share link.

Fix:

  • getTemplateByHash() now orders active rows before archived rows, newest first, so a live duplicate wins if one exists.
  • App upload and /v1/templates upload now detect an archived hash match and restore it to draft before returning it ({ deduplicated:true, restored:true }).
  • The behavior remains idempotent: same PDF maps back to the same template ID, but it becomes visible and fill-linkable again.

2026-05-22 — fix(preview): Worker preview only loads fonts the scene uses

Follow-up to the bad-font hardening: the deployed preview path started returning Cloudflare 1102 Worker exceeded resource limits. Root cause was the same font boundary but from the opposite direction: after scene hydration, scene.fonts.registry contains the whole team library so the editor picker can show every face. /api/renders was then reading every team-library font from R2 and renderBytes() tried to embed every registry entry before drawing anything. That is fine in a container; it is too expensive inside a Pages Function.

Changes:

  • Shared used-font collectorcollectUsedFontIds(scene) mirrors renderer resolution order and returns only the explicit/default/suggested fontIds that text objects can actually use.
  • Preview/fill R2 filtering/api/renders and /api/fill/submit pass that set into their font loaders, so the Worker reads only those R2 blobs instead of the full team library.
  • Subset fonts in Workers — preview and customer fill renders now pass subsetFonts: true; the Fly print renderer keeps full-font behavior.
  • Renderer-side guardembedRegistry() skips registry entries outside the used-font set, so the optimization holds even if a caller accidentally sends extra font bytes.
  • Save retry — if pdf.save() still fails after custom-font embedding, the in-process renderer retries once with Helvetica fallbacks rather than returning a 500/1102-style dead end.

Verified: npm test ✓, npm run typecheck ✓.

2026-05-22 — fix(renderer): bad uploaded font bytes no longer crash preview

User report: Preview failed with Trying to access beyond buffer length @ checkOffset(...). That stack comes from @pdf-lib/fontkit while reading an uploaded font binary. The renderer had a try/catch around pdf.embedFont(), but pdf-lib defers part of custom-font embedding until PDFDocument.save(), so malformed or unsupported bytes could still poison the document and kill the whole preview after the renderer thought the font was accepted.

Changes:

  • Renderer hardening — after pdf.embedFont(bytes, { subset:false }), the renderer now immediately calls face.embed() inside the same try/catch. If fontkit throws, that face is skipped and text falls through to the existing Helvetica fallback instead of failing the render. Text drawing also retries with sanitized Helvetica if a custom face throws during layout.
  • Upload validation — team and template font upload endpoints now sniff the binary header before writing to R2. Real TTF (0x00010000 / true) and OTF (OTTO) are accepted; WOFF/WOFF2 and TTC are rejected with actionable messages. This prevents a webfont wrapper renamed to .ttf from becoming a bad library row.
  • Renderer service sync — copied the updated renderer + font helper into renderer/lib/... so the Fly renderer and in-Worker preview renderer share the same behavior.
  • Test scriptnpm test now runs the smoke file directly instead of passing a quoted glob literally to Node on this shell.

Verified: npm test ✓ (11/11), npm run typecheck ✓, build ✓ under bundled Node 24, copy guard ✓, head guard ✓. crawl-check still reports the existing /app/login/ → /api/auth/sso/start static-link false positive plus the expected under-linked app/fill routes; not introduced by this change.

2026-05-22 — feat(editor): multi-page support (Front / Back of business cards etc.)

The parser has always produced scene.pages[] with one entry per PDF page, but every editor surface — canvas, layers list, inspector, mutation reducer — was pinned to pages[0]. A 2-page upload (business-card front + back, postcard sides, two-up letterhead) silently lost the second page in the editor and only round-tripped if the operator never saved.

Threaded an active pageIndex through the whole editor stack:

  • Store — new pageIndex: 0 slot. Selection lives alongside it but is treated as page-scoped (cleared on a page switch since object IDs only resolve on one page at a time).
  • canvas.jscreateCanvas now takes pageIndex, internally tracks currentPageIndex + mutable w / h, and exposes update(scene, nextPageIndex). On a page change it rebuilds the SVG viewBox, updates the closed-over h that renderPath's Y-flip matrix reads, resets userScale to null so the new page auto-fits (which is the right behavior — switching pages is itself a reset event; the operator's explicit zoom doesn't make sense on a page with different dimensions). getPageIndex() added for symmetry.
  • layers.jssetPageIndex(i) setter; render() lists objects from pages[pageIndex], with defensive clamping for scene mutations that could shrink the page count below the current index.
  • inspector.jscreateInspector now takes a getPageIndex getter and looks up selected objects on the active page only.
  • index.jspatchObjects(ids, fn) mutates pages[pageIndex] (other pages pass through by reference, so unchanged page objects keep React-style structural identity for the renderer). reflect() passes the current page index to canvas.update() and re-renders the page-tab strip.
  • Page-tab strip — new <div class="editor-pagebar"> between the topbar and the canvas. Hidden for single-page templates. For 2 pages with matching dimensions it labels them Front / Back (the business-card / postcard mental model the operator is almost always thinking about). For everything else: Page 1 / Page 2 / … with the page dimensions in pt as a subscript so the operator can tell oversized layouts apart at a glance. Tabs are pill buttons; the active one fills navy. Clicking a tab clears selection.

Verified locally: parse ✓ on all four editor modules, typecheck ✓, tests 9/9 ✓, head-guard ✓. Existing single-page templates open with the pagebar hidden (no visual change).

2026-05-22 — feat(scene): editor opens with every text element pre-mapped to a team-library font

Before: a PDF ingested with an empty registry and every text run's fontId = null. The editor opened, the team library was merged into scene.fonts.registry server-side (so the picker had options) but per-element fontIds stayed null — every text run still rendered in Helvetica fallback until the operator manually picked a face per element. The original ingest code literally had a comment calling this out as "Phase 2.5".

This commit cashes the IOU. Single shared helper, called from two places:

  • New: lib/fonts/hydrateSceneFontsFromTeam(scene, teamFontRows) — (1) merges team rows into scene.fonts.registry (scene entries still win on font_id collision, so per-template overrides survive); (2) walks every i-text in every page and runs suggestFontId(registry, fontFamily, fontWeight, fontStyle, fontSmallCaps) on any object whose fontId is still null — NEVER overwrites an existing pick; (3) sets scene.fonts.default via pickDefaultFontId(suggested, registry) if it wasn't already set. Pure (returns a new scene; shallow-clones the affected pages + text objects only).

  • POST /api/templates — runs hydration right after ingest, before writing the scene to R2. The persisted v1 scene already has every matched fontId set. Hydration failure is caught and non-fatal (the un-hydrated scene gets saved and the GET-time hydration below covers the gap).

  • GET /api/templates/:id — replaces the older mergeTeamFontsIntoScene with the same helper. Two consequences: (a) templates ingested before this commit pick up fontIds on next read with no need to re-upload the PDF; (b) when the operator adds the missing faces via the post-ingest required-fonts checklist (Phase C this morning) and clicks "Open editor →", the editor's first GET hydrates the new matches automatically — no separate "rebuild fonts" action needed. Hydration is not persisted on GET; the next PUT /scene captures whatever the user ends up picking.

Verified with a synthetic Archivo Light + Bold scene + library: t1 (normal) → archivo-light ✓, t2 (bold) → archivo-bold ✓, t3 (pre-existing pick) → preserved untouched ✓, default = archivo-light (correct frequency-weighted pick), input scene not mutated ✓. typecheck ✓, tests 9/9 ✓, head-guard ✓.

2026-05-22 — fix(parser+upload): required-fonts checklist showed PDF resource keys ("TT0", "C2_0") instead of real BaseFonts

User report on spikes/ingest/samples/user/FIU-Baptist BC composite.pdf: the new post-ingest checklist surfaced two missing fonts named "C2_0" and "TT0" (with a "bold" subtitle). Those aren't font names — they're the labels the PDF's /Resources/Font dict uses to address fonts from content streams (the same way /F1, /R3, /T1_0 show up in other producers). The actual fonts in the file are MXLVAZ+Archivo-Bold and MXLVAZ+Archivo-Light.

Two-part fix:

  • lib/parser/parser.mjsfontInventory entries now carry name: info.raw, the real BaseFont with the MXLVAZ+ subset prefix stripped. The outer key (the resource label) stays for debugging but is no longer the only thing carrying identity. Comment added at the call site spelling out that anything user-facing must read name, not the key.

  • functions/api/templates/[id]/required-fonts.js — new displayNameFor(info) helper. Three tiers: (1) info.name is a real font name → run it through parseFontStem + prettyFontName so "Archivo-Bold" reads as "Archivo Bold"; (2) info.name is missing (legacy scenes uploaded before this commit) or matches /^[A-Z]{1,3}_?\d+(_\d+)?$/ (resource-label shape: TT0, C2_0, F1, R3, T1_0) → synthesize from family + weight + style; (3) family is empty or sentinel "Unknown" → skip the entry entirely.

Result on the FIU-Baptist file before this fix vs after:

before:    ✗ C2_0
           ✗ TT0  (bold)

after:     ✗ Archivo Light       (or Regular for legacy scenes)
           ✗ Archivo Bold

The fix is backward-compatible: scenes already in R2 (no info.name field) get the synthesized "Archivo Bold" / "Archivo Regular" display via tier 2, so the user doesn't need to re-upload to see the just-created template render correctly.

Verified locally: smoke test exercising legacy + new + Sabon-SC + spoofed-resource-label cases all produced sensible names; typecheck ✓, tests 9/9 ✓, head-guard ✓.

2026-05-22 — feat(upload): required-fonts checklist after PDF ingest

After a PDF lands, the upload page no longer redirects straight into the editor. Instead it shows a checklist of every font the source PDF uses with a ✓ (we have it), ✗ (missing from the team library) or "PDF standard — built-in" (Helvetica / Times / Courier / Symbol / ZapfDingbats / Arial — the renderer's built-in fallback covers them). If anything is missing, an inline drop zone underneath lets the user upload the real faces into the team library and re-check without leaving the page.

Server — new endpoint GET /api/templates/:id/required-fonts (functions/api/templates/[id]/required-fonts.js):

  • Loads the latest scene from R2, unions scene._meta.fontInventory across pages (the per-page font map the parser already emits), dedupes by (family, weight, style, smallCaps).
  • Annotates each entry: isStandard for the PDF Base14 + Microsoft Arial aliases (matched on the normalized family slug); matchFontId + matchName via the same suggestFontId helper ingest uses, so the answer the checklist gives matches what the editor will pick when the operator opens it.
  • Returns unmatched_count = required entries that are neither standard nor matched — i.e. the actionable count.
  • Auth-gated via requireSession; HEAD mirrors GET per safe-browsing-head-parity.

Client — post-ingest step on /app/templates/upload/ (public/app/js/upload.js, public/app/templates/upload/index.html):

  • After successful ingest the upload form hides; a new <section data-fonts-step> shows the checklist.
  • Each row: badge (✓ / ✗ / •), the PDF BaseFont name (monospace), face metadata (bold · italic · small caps), and a right-aligned note ("Matched: Sabon Bold", "Missing from team library", "PDF standard — built-in"). Color-coded row backgrounds make ✓/✗ scannable at a glance.
  • The missing-fonts section reuses createFontDropzone (the Phase B component) — drop or click TTF/OTF files, queued-file list with per-row remove, then Upload fonts runs N parallel POSTs to /api/team/fonts. After upload completes, the checklist auto-re-checks (same endpoint) so the ✓/✗ state updates without the user pressing Recheck library.
  • "Open editor →" is always available. Wording adapts: with unmatched fonts present it says "Skip and open editor →"; with everything matched it's the plain "Open editor →" and the blurb confirms readiness.
  • Edge cases: PDFs with no custom fonts skip the step and redirect straight to the editor; scenes from before fontInventory shipped return an empty list and behave the same; /required-fonts failure shows a soft message and still allows opening the editor.

Plumbing: api.getRequiredFonts(templateId) added next to uploadTeamFont in public/app/js/api.js. Mock mode falls back to an empty list (so mock-mode uploads still redirect straight through).

Verified locally: typecheck ✓, tests 9/9 ✓, head-guard ✓, copy-guard ✓, parse ✓.

2026-05-22 — feat(editor): drop zone in the font uploader modal

The editor's add-font modal had a bare <input type="file" multiple> and a "Drop one or more TTF or OTF files" line that didn't actually accept a drop. Replaced with a real dragand-drop zone, extracted as a reusable component (public/app/js/font-dropzone.js) so the upcoming post-ingest required-fonts step on the upload page can reuse it without copy-paste drift.

  • Drop or click: drag a folder's worth of TTF/OTF files onto the zone or click to browse. Multi-file in both paths. Non-TTF/OTF files are silently filtered (the accept= attribute on <input> is advisory only — the JS does the real check).
  • Queued-file list with per-row remove: every file added shows up as a row with an × so the user can drop a wrong file and back it out without restarting the modal. Idempotent re-drops (dedupe by name+size).
  • Submit button auto-binds to queue size: disabled when empty; otherwise reads "Upload N font(s)". After upload completes, queue is cleared so the modal can be reused for another batch in the same session without closing.
  • Keyboard: Enter/Space opens the picker. Drag-over visual state reuses the existing .is-dragover styling that the PDF upload dropzone already uses.

Pipeline unchanged below the surface: N parallel POSTs to /api/team/fonts, per-file status rows, per-file errors, scene registry merge, auto-close on all-green.

Verified locally: typecheck ✓, parse ✓.

2026-05-22 — feat(editor): font picker groups by family

The Canva-style picker shipped earlier today put every face on a single flat list — "Source Sans Pro Regular", "Source Sans Pro Italic", "Source Sans Pro Bold", "Source Sans Pro Bold Italic", "Source Sans Pro Semibold", … one row per face. With even a small team library that scans poorly. Grouped picker:

  • Faces sharing a reg.family slug collapse under a single uppercase family heading (e.g. "Source Sans Pro").
  • Inside each group, rows show JUST the variant ("Regular", "Bold Italic", "Bold Small Caps") — the family is already in the heading. Indented 22px so the parent / child relationship is visible.
  • Sort: families alphabetical; within a family by CSS-weight rank (Thin 100 → Light → Regular → Medium → Semibold → Bold → Black 900), then upright-before-italic, then non-SC-before-SC. So Bold and Bold Italic sit adjacent, and Black sits after Bold.
  • Headings are role="presentation" and non-clickable; only variant rows fire onChange. Real-glyph row previews, button label, mixed/inherit handling, keyboard close — all unchanged.

Grouping key is reg.family (the normalized slug from parseFontStem), which is authoritative — two faces share a group iff they share that slug regardless of casing or spacing in their pretty names. Heading text comes from reg.name with the trailing weight/style/SC qualifier stripped via a token-class regex. "Roman" is intentionally NOT a stripped token because prettyFontName emits "Regular" for unmarked faces — so "Times New Roman" stays as one family name.

Verified locally: smoke run over 10 representative faces (Source Sans Pro x6, Inter x2, Sabon x2 incl. small-caps) produced the expected groupings in the expected order. typecheck ✓, parse ✓.

2026-05-22 — fix(editor): preview, share fill link, font upload

Three editor surface controls were broken on the first live template (https://stackfill.com/app/templates/edit/?id=…). Audit + fix:

  • Previewpreview() was sending scene_revision: template.latest_revision, but GET /api/templates/:id only returned the revision at the top level of the response envelope, not denormalized onto template. Result: scene_revision: undefined400 missing_fields. Fixed server-side by denormalizing latest_revision onto the template object in both GET /api/templates/:id and PUT /api/templates/:id/scene responses (matches the shape GET /api/templates already uses for the list). No client change needed.
  • Share fill link — the Share modal's link target was ${origin}/fill/<slug>, but Pages routing 404s on that path because public/fill/ only ships index.html. Added /fill/* /fill/index.html 200 to public/_redirects so any slug serves the existing "Coming soon" stub until the real fill UI lands.
  • Font upload — the editor's add-font modal sends only the multipart font field; the server was demanding explicit font_id and family fields and returning 400 missing_fields. Server now derives font_id/family/name/weight/style/small_caps from the filename using the existing parseFontStem/makeFontId/prettyFontName helpers from lib/fonts/font-utils.mjs (the same heuristics ingest uses). Explicit form fields still override for the /v1 API path. The upload response now also includes a stable src (/api/templates/:id/fonts/:fontId/file) so editor scenes saved with this font reference a working URL.
  • New endpointGET /api/templates/:id/fonts/:fontId/file streams the font binary from the private TEMPLATES R2 bucket, session-gated. Cached private, max-age=3600 so the editor's @font-face load doesn't re-fetch on every selection. HEAD mirrors GET per the head-parity guard.

Verified: typecheck ✓, tests 9/9 ✓, head-guard ✓.

2026-05-22 — fix(email): Mailgun click-tracking off on auth mail; no more raw-URL anchors

The HEAD/GET parity fix earlier today was correct but not the whole story — the Chrome "Dangerous site" interstitial kept firing on the magic-link click. Audit narrowed the second trigger to the email delivery path. Mailgun's domain-default click tracking was rewriting the magic-link href to a tracking redirector on email.mail.stackfill.com, and the template was rendering the raw destination URL as the visible anchor text in the "or paste this link" fallback. Combined: display-text ≠ href mismatch + a low-reputation redirect hop on a <1-day-old sending domain = the social-engineering fingerprint the server-side fix never touched.

Two changes, both in functions/_lib/email.js:

  • o:tracking-clicks / o:tracking-opens / o:tracking all no on every send through sendEmail(). Transactional auth mail must not be link-rewritten. The recipient now receives the clean, direct https://stackfill.com/api/auth/verify?token=... with zero redirector hops.
  • Fallback "or paste this link" rendered as plain text, not as <a href="${link}">${link}</a>. Even with tracking off, leaving the raw-URL anchor is a latent foot-gun — the moment anyone re-enables tracking or a marketing helper gets factored out of the same sendEmail, the mismatch comes back. The button-style sign-in CTA stays; only the no-link copy-paste fallback changed.

Verified locally: typecheck ✓, tests 9/9 ✓, head-guard ✓, build ✓. Deploy in this commit.

Still pending (Osi): Safe Browsing review submission via Search Console → Security & Manual Actions → Security Issues → Request Review. The flag won't clear on its own. Suggested review-note text lives in SAFE-BROWSING-EMAIL-VECTOR-SPEC.md §5 R3. Typical clear 3–14 days.

Lesson banked at [safe-browsing-email-link-tracking](memory) next to the existing safe-browsing-head-parity so the email vector doesn't get re-introduced when a new transactional helper (password reset, recovery, etc.) lands.

2026-05-22 — fix(editor): zoom controls + decouple update() from sizeSvg()

User report: clicking on an object in the editor canvas felt like an unintended zoom-out. Root cause: every selection click fired store.set → canvas.update() → sizeSvg() → re-fit. The re-fit read host.clientWidth each time, so any subtle layout shift between initial mount and the post-click state changed the fit scale underneath the operator.

Two changes:

  • Decouple update() from zoom. canvas.update() now redraws content only; SVG dimensions persist. Window resize still re-fits but only in auto-fit mode (userScale = null).
  • Explicit zoom controls. New canvas API: getZoom, isFitMode, setZoom, zoomIn, zoomOut, zoom100, fitToHost, onZoomChange. Topbar gets a three-button cluster (− readout +); the readout doubles as a Fit ↔ 100% toggle. Cmd/Ctrl+wheel zooms; Cmd+= / − / 0 are bound globally on the editor page.

Verified live: 3× + clicks goes 78% → 134%; 1× goes 112%; clicking the readout from non-fit returns to fit. Selecting an object no longer changes either the readout or the SVG width.

2026-05-22 — fix: middleware catches thrown Response → no more CF 1101 page

The auth gates (requireSession, requireApiKey) use throw apiError(...) as a control-flow shortcut. apiError returns a Response; throwing it bubbled up to the Workers runtime as an uncaught exception → Cloudflare's branded error code: 1101 page instead of our {error:{...}} envelope. Authenticated traffic was unaffected (throws don't fire when there's a valid session) so the bug hid until I curled /api/templates from logged-out state during the Safe Browsing fix smoke test.

Fix lives in functions/_middleware.js: a try/catch around the downstream next(). If a thrown thing is a Response, return it as-is. Anything else → console.error + a generic 500 envelope. Verified live: unauth GET /api/templates now returns 401 + the canonical authentication_error / session_required envelope.

2026-05-22 — fix: clear Safe Browsing "Dangerous site" interstitial

Chrome flagged the magic-link verify URL the moment Mailgun started delivering. Same pattern as PreArrive on 2026-05-19: Pages Functions with only onRequestGet return 200 text/html empty on HEAD probes while GET returns the real response — Safe Browsing's social-engineering classifier reads that as cloaking.

  • HEAD = GET added across the seven read-only GET-only Functions (v1/renders/[id].js, v1/templates/[id].js, v1/templates/index.js, api/fills/index.js, api/renders/[id].js, api/templates/[id].js, api/templates/index.js).
  • /api/auth/verify gets a Pattern-2 HEAD — explicit no-op that returns 302 to /app/ without running the verify handler. Critical because magic-link tokens are single-use and atomically deleted on GET; a blanket mirror would let any pre-fetcher (Chrome preload, Slackbot, Safe Browsing's own crawler) burn the token before the human clicks.
  • New build guard build/lib/head-mirror-guard.ts fails the build if any Function exports onRequestGet without an onRequestHead. Wired into npm run deploy. Lifted verbatim from PreArrive.
  • Curl traces post-deploy confirm HEAD/GET parity on every affected endpoint.
  • Still pending: Safe Browsing review submission via Search Console (Osi). Typical clear time 3–14 days.

Lesson banked at [safe-browsing-head-parity](memory) so the next new auth-shaped endpoint doesn't pay this tuition a third time.

2026-05-22 — Phase 3 — Mailgun live, magic-link email confirmed

End-to-end email delivery working. mail.stackfill.com is the verified Mailgun sending domain (SPF / DKIM / DMARC / MX / tracking CNAME all green via the Cloudflare DNS records added earlier today). MAILGUN_API_KEY set as a Pages secret. Magic-link emails confirmed accepted + delivered to a real inbox via Mailgun's event log.

  • Bug caught and fixed: when the email helper was swapped from Resend to Mailgun, functions/api/auth/magic-link.js still gated on env.RESEND_API_KEY (which doesn't exist post-swap). The handler short-circuited and returned {ok:true} without ever calling sendMagicLink(). The framework's "always return ok to prevent email enumeration" policy hid the bug until we cross-checked Mailgun's send log and saw zero events. Lesson noted in the commit: always cross-check the email provider's events on first send after swapping providers.

  • First-send reputation note: a brand-new sending domain has zero reputation, so first emails to a given recipient very likely land in spam. Marking the first few "Not spam" trains the receiving server to route future StackFill mail to the inbox.

2026-05-22 — Phase 3 — Renderer container live on Fly.io

The Ghostscript print-grade renderer is deployed at https://stackfill-renderer.fly.dev. Pages now signs requests with the shared HMAC secret and posts to it for kind:final renders.

  • Fly app: stackfill-renderer in Personal org, mia region, auto-stop / auto-start machine, ~1 GB shared-cpu-1x (matches renderer/fly.toml).
  • GitHub integration: Current Working Directory = renderer, Config path = renderer/fly.toml, Auto-Deploy on push to main enabled. Pushes under renderer/ auto-rebuild and redeploy.
  • Shared secret: generated locally with openssl rand -hex 32, set as RENDERER_SHARED_SECRET on both Fly and Pages, temp file deleted. Never written to the repo.
  • wrangler.toml: RENDERER_SERVICE_URL = "https://stackfill-renderer.fly.dev".
  • Smoke tests: /healthz → 200 ok (~260ms cold start), / → 404 (by design), /render unauthenticated → 401 with the error envelope. Confirms image is alive, HMAC auth is wired, Pages and Fly agree on the secret.
  • What's blocked on Mailgun: end-to-end render through Pages still needs the magic-link flow, which needs Mailgun verified + MAILGUN_API_KEY set. Once that lands, /api/renders with kind:final flows through to the container.

2026-05-22 — Production wire-up

Site live at https://stackfill.com and https://stackfill.pages.dev (with www → apex 301 working). API responds with the proper auth envelope; landing renders cleanly; TLS 1.3.

  • Cloudflare resources — D1 stackfill (region ENAM, id 09b03589-866e-4f39-9f9e-43abda7987c6), 6 KV namespaces (MAGIC_LINKS, SESSIONS, LICENSES, API_RL, API_IDEMPOTENCY, WEBHOOK_SECRETS), 2 R2 buckets (stackfill-assets public, stackfill-templates private).
  • Schema applied — 11 tables on remote D1 via npm run db:apply:remote. One last-minute rename: fills.valuesfills.field_values because values is a SQLite reserved word.
  • Bindings renamedenv.ASSETSenv.PUBLIC_ASSETS everywhere; Pages reserves the literal ASSETS binding name for its built-in static-asset handler.
  • Pages project created (stackfill), SESSION_JWT_SECRET + LICENSE_JWT_SECRET generated via openssl rand -hex 32 and set as secrets (never written to disk).
  • First deploynpm run deploy: build + typecheck + tests all passed, 44 files uploaded, Worker compiled, deployment URL https://10fa7264.stackfill.pages.dev → promoted to https://stackfill.pages.dev.
  • Custom domainstackfill.com and www.stackfill.com attached to the Pages project via the Cloudflare API. DNS CNAMEs added through the dashboard (operator). SSL provisioned via Google CA, TLS 1.3 confirmed.
  • Mailgun swapfunctions/_lib/email.js rewritten from Resend to Mailgun's Messages API, matching PreArrive's pattern. New public vars MAILGUN_DOMAIN + MAILGUN_REGION in wrangler.toml; MAILGUN_API_KEY set as a Pages secret when the sending domain is verified.
  • Renderer container — code complete at renderer/ (Node http server + Dockerfile + fly.toml + README). Validated locally under Node 22 + Ghostscript 10.06: scene JSON → PDF/X-1:2001 confirmed, preflight pass, HMAC + Bearer auth working. Docker image build + Fly.io deploy is on Osi's plate per renderer/README.md (flyctl auth login is interactive). Target URL: https://stackfill-renderer.fly.dev.

Outstanding (none block "live" status):

  • Mailgun sending domain verification + MAILGUN_API_KEY (needed for magic-link email).
  • Ghostscript renderer container deploy via Fly.io.
  • RENDERER_SERVICE_URL (public var in wrangler.toml) + RENDERER_SHARED_SECRET (Pages secret) after the renderer is live.
  • Stripe wiring (Phase 3 proper).

2026-05-21 — Phase 2 (editor + REST API + ingest pipeline)

Two parallel agents landed the tool surface and its backend; one small follow-up pass closed the contract gaps they flagged.

  • Editor app (public/app/) — vanilla ES modules, no bundler. Dashboard, upload flow, three-column editor (layers / SVG canvas / inspector), font picker per element, stack-level default-font picker, font uploader, fills list, mock mode toggled by ?mock=1. Canvas tool: vanilla SVG (rejected Fabric to avoid a 600 KB CDN load on every editor open).
  • REST API (functions/api/ + functions/v1/) — magic-link auth (PreArrive shape: sf_session cookie, HS256 JWT, KV revocation mirror), template CRUD with R2-backed scene storage, font upload, render endpoint (in-process pdf-lib for preview, signed-HMAC POST to a renderer service for final), fill listing. The v1 surface adds Bearer-key auth, 60 req/min sliding-window rate limit (API_RL), Idempotency-Key replay (API_IDEMPOTENCY), CORS, and the api.stackfill.com hostname routing pattern.
  • Workers-safe entry points added to spike modules: lib/parser/parser.mjs exports ingestBytes(Uint8Array, opts) next to the CLI ingest; lib/renderer/renderer.mjs exports renderBytes(scene, opts) next to render. Functions dynamic-import these — no /tmp writes, no fs/path from the Workers path.
  • 9 smoke tests under tests/smoke.test.js covering nanoid21, JWT sign+verify round-trip, error envelope shape, and ingestBytes against a spike sample PDF. All green.
  • Stitching gaps closed inline: parser now stamps stable id: "obj_<n>" on every emitted object (the editor selects by id; spike scenes didn't have ids). App CSS .badge--* extended to cover the actual DB enum values for templates.status (draft / live / paused / archived), fills.status (open / finalized / expired), and renders.preflight_status (pending / pass / warn / fail) — the editor was styling earlier mock values only.
  • Deferred with rationale (each a one-block change later): R2 signed URLs (public bucket URL for now), font hydration from team library on first ingest, api_request_log table, drag-to-move on the canvas, multi-page templates, undo/redo, image upload for type: image objects.

Verified: typecheck ✓, build ✓ (20 HTML pages), crawl-check ✓ (0 broken, 4 expected under-linked on /fill/ + app pages), tests 9/9 ✓, editor renders end-to-end against mock data with 0 console errors.

2026-05-21 — Phase 1 scaffold

Repo skeleton in the PreArrive shape, validated build pipeline, spike code lifted into lib/. Design tokens applied per the brief (Inter + Space Grotesk; navy + sage on white + soft-surface).

  • Repo: package.json, tsconfig.json, wrangler.toml, .gitignore, PREREQS.md, README.md.
  • D1: db/schema.sql — 11 tables (customers, users, team_members, templates, scenes, fonts, fills, renders, api_keys, conversions, events).
  • Bindings: D1 + 6 KV namespaces (MAGIC_LINKS, SESSIONS, LICENSES, API_RL, API_IDEMPOTENCY, WEBHOOK_SECRETS) + 2 R2 buckets (ASSETS public, TEMPLATES private). IDs blank until wrangler … create is run per PREREQS.md.
  • Build pipeline: build/build.ts writes 15 HTML pages (landing + 13 placeholders + app stub) plus sitemap.xml, robots.txt, llms.txt. build/crawl-check.ts enforces zero broken internal links; build/lib/copy-variant-guard.ts is a passing stub until the SEO factory phase.
  • Design system: public/css/tokens.css, base.css, components.css. Self-loading Inter + Space Grotesk via Google Fonts (self-host before launch).
  • Cloudflare conventions: _headers (HSTS preload, immutable cache for /css /js /fonts /assets, short cache for HTML); _redirects (www → apex 301).
  • Spike lift into production: lib/parser/parser.mjs (PDF → scene; validated against BC-front, BH, FIU, CohnReznick + 6 synthetics), lib/renderer/renderer.mjs (scene → PDF via pdf-lib), lib/renderer/{finish.sh,preflight.sh,PDFX_def.ps,default_cmyk.icc} (Ghostscript pipeline for PDF/X-1a finishing), lib/fonts/font-utils.mjs (registry + matching).

2026-05-21 — Phase 0 spike (validation, pre-scaffold)

Pre-build validation against the brief's two riskiest assumptions. Both returned GO.

  • Renderer thesis: pdf-lib + fontkit + Ghostscript produces print-grade CMYK PDF/X-1a:2001 with pure-K text on the K plate only. Hosting recommendation: container (Cloudflare Containers / Fly.io), ~180–220 MB image. ~6–9 days to harden.
  • Ingest thesis ("use the file you already have"): tested on 4 real customer PDFs (Sabon88 / BH Business Card / CohnReznick / FIU-Baptist) plus 6 synthetic samples. All passed every fidelity dimension at 0.000pt position drift. Key architecture discovery: pdf.js getOperatorList, Ghostscript -sDEVICE=svgwrite, and pdftocairo -svg all silently convert DeviceCMYK to RGB — only a custom PDF content-stream parser preserves CMYK byte-for-byte. The spike's parser is the production parser.
  • 5 real bugs caught and fixed via the spike: ICCBased-RGB alt-cs for Separation tints; InDesign accessibility hex-in-dict OOM; Form XObject recursion (missing logos); SC font detection; ExtGState alpha multiplier (compounds across form scopes).
  • Three reference memories landed: ingest-cmyk-loss-paths, renderer-stack-decisions, scene-font-model.