FERNWAY

Changelog

Every change, as it shipped.

Read from Fernway’s own commits, newest first, with the moment each one went live. 64 changes so far.

Tuesday, 10 November 2026

  1. 46

    83d7480

    Book a demo: real free times from GHL's calendar, booked as a tracked lead

    • A route can name a GHL calendar (migration 0005). The Book a demo page reads its free times (GET /calendars/{id}/free-slots, kept 60 s) and shows them in the visitor's own time zone; at sending the picked time is checked fresh, and a time taken meanwhile sends the visitor back to pick another. The booking is its own step after the contact (POST /calendars/events/appointments), its id kept so a retry never books twice, a refusal held with GHL's reason; retention removes the meeting from GHL's calendar. The dialog, Sent panel and receipt carry the meeting time. The setup tool lists calendars and takes calendar=<id>.
    • CI runs a fake GHL (scripts/fake-ghl.mjs, never on the box) to book all the way to signed for and to race two visitors for one time.
  2. 47

    dd3c22b

    Site forms: a form whose route isn't set up says so in plain words

    • Jerome saw 'Route: no route called "contact"' on the live Contact sales form, sent before its route was made. The visitor now reads 'This form isn't open yet; try again later' (503), and the real reason goes to the log.
  3. 48

    f62487e

    Forms walk the visitor through: a sent dialog, a Sent panel, and Track a lead

    • Jerome: after sending, the details stayed in the form and the only sign was a line of text, with nothing saying where to find the lead again. Now a dialog says it is on its way with its waybill number, a copy button for the receipt link and where it can be opened later; the form gives way to a Sent panel with Send another. Track a lead (header and footer) finds a waybill by its number and the email it was sent with: the number field shapes itself as typed, and a wrong email gets the same answer as a wrong number, 10 lookups an hour.
  4. 49

    8fca1e1

    Contact sales, and a lead's message reaches GHL as a note

    • GHL's contact upsert has no free-text field, so the engine adds the message as a note on the contact: its own step between the contact and the opportunity, its id kept so a retry never adds it twice, a refusal held with GHL's reason (migration 0004). Retention removes the old lead's note even when a newer lead keeps the contact. The trial form becomes one shared lead form; Contact sales is that form with a message box that counts as you type. The setup tool takes
    • for a contact-only route with tags.
  5. 50

    6d3aaa5

    CI: the same form sent twice is compared by waybill number (each receipt link carries its own expiry)

  6. 51

    1533a06

    Start a trial: a real lead from the site, tracked live on a signed receipt

    • The trial form sends the visitor's lead through Fernway into the GHL sandbox and turns its waybill into a live receipt (/track/<signed link>, 30 days): every scan with what GHL answered, never what was sent, GHL's ids or the full email. Fields help as you type. A hidden trap field, 5 sends per visitor an hour and 200 a day keep it at demo volume; the same form sent twice is one waybill. The worker removes the site's leads after 30 days, from GHL first (opportunity, then contact, unless a newer lead still uses them), and keeps a lead GHL can't be reached for until it can.
  7. 52

    0d8c6bd

    Pricing page: full rates, how leads are counted, the lost-lead calculator

    • The header, footer and shared styles move to src/ui so every public page uses one frame; the rates live in one file for the landing and pricing. The calculator fields help as you type: the unit in the field, only what the field can hold, thousands grouped, percentages capped at 100, arrow keys step, a one-line hint each, and an empty field says what is missing.
  8. 53

    04256c1

    Landing: how a lead travels, when GHL says no, rates, the real footer

    • The four real scans on one route with the first real lead as proof, every GHL answer and what Fernway does with it, the retry clock on a time-since-pickup scale, rate cards priced by leads signed for in GHL, and the footer. The hero now matches the engine: FW 26 numbers, the 30 s first retry, four scans, 1.3 s.

Monday, 9 November 2026

  1. 54

    bbf78fa

    Backups: nightly checked dump, weekly restore test, encrypted off-box copy

    • Distill's proven tools, fitted to Fernway: pg_dump at 03:07 UTC kept only if it reads back (14 days), a Sunday restore test into a scratch database with every table compared, and an age-encrypted copy to R2 once its key and token are on the box. The deploy installs them and their timers. CI runs them as root against the real stack, including a half dump that must fail and an upload that must fail.
  2. 55

    db7a677

    Connect GHL: trade an agency install for the sub-account's own token

    • GHL forces bulk install on for private apps, so installing from Agency View answers the code swap with an agency token. Fernway now asks GHL which sub-account has the app (installedLocations) and swaps for that sub-account's token (locationToken), which has its own refresh token. No sub-account, or several, saves nothing and says so. Adds the oauth.readonly and oauth.write scopes these calls need.
  3. 56

    4017472

    Connect GHL: send the code exchange form-encoded, as the real API requires

    • GHL's OAuth docs show the authorization-code exchange as JSON. The sandbox refused it on the first real connect: 400 "content must be application/x-www-form-urlencoded". Both token calls are now form-encoded, and the test that copied the docs now holds what the real API does.
  4. 57

    e0e2e5b

    CI: check the connect pages by path, since CI's app address is localhost

  5. 58

    8765c9f

    CI: quote the test token key, which YAML read as the number 1

    • An unquoted 000...0001 is an integer to YAML, so the worker got "1" and correctly refused to start. The other test secrets are quoted too, so none of them can be read as a number.
  6. 59

    b996ed3

    Connect GHL: OAuth as a Marketplace app, tokens encrypted and refreshed safely

    • db/migrations/0003_connections.sql: one GHL sub-account per Fernway account; tokens sealed with AES-256-GCM, the key only in the box's env file.
    • Connect GHL: a signed link (made on the box until the console exists) sends the owner to GHL's sub-account chooser with a signed state; the callback only accepts the same browser that started, swaps the one-time code for tokens (as GHL's OAuth docs: JSON for the code, form-encoded for a refresh, user type Location) and refuses an agency-level connection. /connected says what happened, in each case.
    • Tokens refresh five minutes before expiry. GHL's refresh tokens work once, so the refresh holds the row lock and a second worker reuses the first one's result. If GHL refuses the refresh, the connection is marked for reconnecting and leads are held with that reason instead of retried for a day.
    • The worker delivers through the real connection. apps/worker/dist/cli.js on the box: account, connect-link, pipelines, route, send-test, waybill.
    • 28 tests (9 new): sealing and signatures, the install link, the code exchange, refresh rotation, one refresh for two workers at once (proven by breaking the lock on purpose), reconnect marking and holding. CI checks the connect routes and the setup tool on the real images.
  7. 60

    d3d2288

    The delivery engine: shipments, scans, the retry schedule, no duplicates

    • db/migrations/0002_delivery.sql: accounts, routes (pipeline, stage, tags), shipments with waybill numbers and an idempotency key per account, and scans: every step with what was sent and what GHL answered. The app's role can add scans but never change or remove one.
    • packages/delivery: leads checked at the door (an email or a phone GHL can match, phones cleaned for GHL); the GHL client (contact upsert, then opportunity upsert in the route's stage; 2xx signed, 429/5xx/no answer retried, 401 refreshes the token, any other 4xx held with GHL's reason); the engine, which writes every scan in the same transaction that moves the shipment and skips any try that isn't due; one-click resend; and a sweep that puts overdue shipments back on the queue if Redis ever lost them. Drizzle over the SQL migrations, with a test that the two still match.
    • apps/worker: runs the queue (BullMQ on Redis, under GHL's rate limit) and the sweep every minute. Until GHL is connected (next step) a lead is held with that reason, never lost.
    • 19 tests against a real Postgres and Redis with a fake GHL: the landing page's promises (8 tries on the stated schedule, no second contact even when GHL's answer is lost, a scan for every hand-off), holds, 401s, repeated and late jobs, resend, the sweep, and a lead refused twice that the worker delivers on its own through the real queue.

Deploys are recorded from the first deploy after the status page shipped; earlier changes went live in the deploys of their day.