Queue-North-Website/docs/architecture
null 28b07abc28 fix(build): the prerender preloaded the logo, hoisted any tag, and hid its errors
Four faults in the one build step that decides what a crawler receives.
Closes #233 and #232.

1. Every page preloaded /logo.png. The hero hint was keyed on the first
   `loading="eager"` image, and that is the header logo, on every page. So the
   image each page actually paints first was never preloaded, and React's own
   preload for it was being discarded as a duplicate. It now keys on the image
   React marked with a high fetch priority, matched case-insensitively, because
   React writes the attribute camelCase in HTML and a case-sensitive match would
   have quietly removed every preload instead.

2. The hoist moved ANY title, meta or link out of the body into <head>. An
   inline <svg><title> is a picture's label, and microdata rides in
   <meta itemprop>: both would have become page-level head tags the moment the
   long-form copy carried an icon with a title. SVG blocks are now parked before
   the hoist, and itemprop tags stay where they are.

3. `page.replace('</head>', body)` interprets `$&`, `$'` and `$$` INSIDE the
   replacement, and the replacement is page copy. React escapes & into &amp;, so
   any `$` immediately before an escaped character injected markup. Copy carries
   no `$` today; the next page with a price would have. Replacement is now a
   split and join, and each marker must appear exactly once.

4. A render error named no route, and a Suspense fallback shipped silently as
   an empty page. Both now fail the build and say which route.

Also refuses to run over its own output: dist/index.html is both the template
and the home page, so a second run without a rebuild gave every page two
canonicals.

Proven by mutation, each restored afterwards: a probe <svg><title> in the footer
stays in the body on all 19 pages and no page gains a second title; a Suspense
boundary fails the build naming the route; a second prerender run refuses; copy
reading "Save $10 & more" reaches the page literally with one root div; and
every page now preloads its own hero, with none preloading the logo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:32:33 -05:00
..
GUARDS.md security: the scanner never ran its private-key pattern, and verify never saw dist 2026-09-10 04:23:39 -05:00
README.md fix(build): the prerender preloaded the logo, hoisted any tag, and hid its errors 2026-09-10 04:32:33 -05:00
zoho-setup.md docs: adopt project template, retire the markdown backlog 2026-08-18 01:21:13 -05:00

README.md

Architecture — Queue North Website

Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: docs/architecture/**, server/index.js, scripts/** — the module
         boundaries, the database schema and the API response shapes
Review trigger: Any new module, any change to a module boundary or a data shape;
                any new table or column; any new external service the server
                calls; any change to how routes are prerendered

The shape of it

One Express process serves everything. There is no separate web server, no reverse proxy inside the container, and no second runtime.

browser
  |
  +── queuenorth.com ──► nginx-proxy-manager (thor/exodus)  ┐   PRODUCTION
  |                                                          │
  +── qn.isnull.dev ───► Cloudflare ─────────────────────────┤
                                                             v
Express (server/index.js, port 3001) on nebula   ← the only process, one instance
  |
  +--> dist/            prerendered HTML + the React bundle, served static
  +--> /api/health      liveness, and a real SELECT against SQLite
  +--> /api/leads       POST → validate → SQLite → (fire and forget) Zoho
  +--> /api/support     POST → validate → SQLite → (fire and forget) Zoho Cases
  |
  +--> db/queuenorth.db  better-sqlite3, synchronous, single writer
  |
  +--> Google reCAPTCHA v3   verify, server-side, before any insert
  +--> Zoho CRM              WebToLead form post, or REST/OAuth as a standby

Module boundaries

src/ knows nothing about the database. It talks to three JSON endpoints through src/lib/api.js and nothing else. There is no ORM in the client, no shared schema module, and no import that crosses from src/ into server/.

server/index.js knows nothing about React. It serves dist/ as static files and falls through to dist/index.html for client routes. The one place this is not quite true is the privacy policy, below.

src/data/*.js is the content layer. Services, industries and the privacy policy text live there as plain data, imported by both the client pages and src/entry-server.jsx. Adding a service is a data edit, not a component edit.

scripts/prerender.js is a build step, not a runtime. It renders every route to static HTML at build time using src/entry-server.jsx. Nothing at request time renders React on the server.

It refuses to produce a page rather than produce a wrong one, and each refusal names its route:

  • a render that throws, so the build does not report a stack trace with no clue which of the nineteen pages produced it
  • a Suspense fallback (<!--$!-->), because renderToString does not wait: a lazy import anywhere above a route would silently ship an empty page to every crawler
  • a template that already carries a canonical, which means the script is being run over its own output, since dist/index.html is both template and home page

Two rules about what it moves. Metadata React leaves inline is hoisted into <head>, except inside an inline <svg> (an SVG <title> is a picture's label, not the page's) and except <meta itemprop> microdata, which belongs beside the thing it describes. And the hero preload is keyed on the image React marked with a high fetch priority: keying it on loading="eager" matched the header logo, so for months every page preloaded the logo and no page preloaded its own hero.

The three boundaries worth knowing about

  1. The privacy policy has two renderers, one source. src/data/privacyPolicy.js is the single source of truth; src/pages/PrivacyPolicy.jsx renders it in the SPA and the prerender step emits a static copy. This exists because Meta's crawler does not execute JavaScript, and a policy it cannot read is a policy that does not count. Do not add policy text to a component.

  2. Zoho is an overlay, never a dependency. Every handler writes SQLite first and then calls the forwarder without awaiting it. A Zoho outage, a bad token or a network timeout costs a CRM record and never a lead. forwardLeadToZoho dispatches to forwardToZohoWebToLead or forwardToZoho on ZOHO_FORWARDING_MODE; both carry a 10 s AbortController.

  3. reCAPTCHA is the one thing that runs before the insert. It is the only check that can reject a submission outright, so it is deliberately the only external call on the blocking path — with a 5 s timeout, and it fails open when RECAPTCHA_ENABLED is false.

Data shapes

server/index.js owns the schema and applies it at startup via initSchema(). There is no external migration runner; the one migration that exists rebuilds leads to add the UNIQUE constraint and is idempotent.

leads

Column Type Notes
id INTEGER PK AUTOINCREMENT
company TEXT NOT NULL max 200 after sanitisation
name TEXT NOT NULL max 100. Split on the last space for Zoho's First_Name / Last_Name
email TEXT NOT NULL UNIQUE max 254 (RFC 5321). A duplicate answers 409, and the Zoho forward is still attempted — the local row existing does not mean the CRM record does
phone TEXT
zip TEXT maps to Zoho Zip_Code
message TEXT
service_interest TEXT normalised from empty to NULL. Maps to Zoho Description, not a custom field
created_at DATETIME CURRENT_TIMESTAMP

support_requests

Column Type Notes
id INTEGER PK AUTOINCREMENT
name, company, email TEXT NOT NULL no UNIQUE — the same customer may raise many tickets
phone TEXT
issue TEXT NOT NULL minimum 10 characters, enforced client and server side
priority TEXT defaults to medium
created_at DATETIME CURRENT_TIMESTAMP

The asymmetry between the two tables is deliberate. A lead is a person you want once; a support request is an event that recurs. Adding UNIQUE to support_requests.email would silently drop a customer's second ticket.

Every response shape the API produces

Status Body When
200 the resource, or {status, db, timestamp} success
400 {error: 'Validation failed', fields: {…}} Zod rejected it
403 {error} reCAPTCHA below RECAPTCHA_MIN_SCORE
404 {error: 'Not found'} unmatched /api/* only; other paths fall through to the SPA
409 {error} duplicate leads.email
413 body over 1 MB
429 {error, message, retryAfter} rate limiter
500 {error} never a stack trace
503 {error, db: 'error'} health check could not reach SQLite
504 {error: 'Request timeout'} the 30 s request timeout fired

Documents here

  • GUARDS.md — how to write a check that actually checks. Read it before adding a structural test or a probe; every rule in it was learned from a guard that had been green over something broken.
  • zoho-setup.md — the CRM integration end to end: app setup, credentials, environment variables, and how to confirm a lead arrived.

What ships in scripts/

Ten scripts came from the template on 2026-08-18 and three were already here. The template's full catalogue is a menu, not an inventory — see ../TOOLS.md. This table is what this project actually has, and each row says what it does here.

Path What it is
scripts/check-env.sh which of the 17 Zoho / reCAPTCHA / CORS / rate-limit variables are set and plausible, before the server reads them. Exit 2 means nothing was checked
scripts/secrets.sh credential shapes in a staged diff, and --tracked for a whole-tree audit. --built dist/ is the one that matters here: VITE_RECAPTCHA_SITE_KEY is inlined into the bundle at build time, so the repository scan cannot see what users receive. npm run verify runs both, as guard 20-secrets. Every pattern is compiled before the scan, because a pattern grep cannot read produces the same silence as a clean tree (#235). A line that must show a credential shape carries secrets-ok: and its reason. Carries this project's own shapes, and one tightened pattern, described below
scripts/verify.sh every check this project has, in one table. Honestly thin — there is no test suite, and it says so rather than printing a green row
scripts/doc-triggers.py which documents a pending change fires, read from the Governs: headers. Run it before committing, not after
scripts/forgejo-issue.py files and closes issues in the tracker convention, refusing malformed ones before they are filed
scripts/status.sh what is running on nebula as qn-website-dev, its version and its restart count. Read-only
scripts/healthcheck.sh a liveness tick against queuenorth.com, asserting HTTP 200 and "status":"ok" and "db":"ok" — a 503 with a JSON body is a real answer, not an outage. HEALTHCHECK_BASE_URL points it at the other front door
scripts/preflight.sh headers and TLS against the live origin. No --auth checks: there are no accounts
scripts/backup.sh a verified SQLite dump. Its ENGINE block was rewritten for better-sqlite3's online .backup() — see below
scripts/restore-check.sh restores the newest dump into a scratch file, runs PRAGMA integrity_check, counts tables, and times it. A backup nobody has restored is a guess
scripts/release.sh publishes. Bump, guard, build, verify the image's own version label, push :vX.Y.Z, commit last, tag. Refuses to overwrite a published tag, to build on a dirty tree, or to build when the three hard-coded copies of the public origin disagree. Adapted from PrivacyLLC-Web's — see below
scripts/deploy.sh deploys, and does not build. Points Portainer stack 58 at an already-published numbered version — it refuses a floating tag — taking a verified backup first and preserving the stack's twelve environment variables, then waits for health and checks both public origins. Reports the digest before and after

| scripts/docker-test.sh | builds the image and runs it locally on 3001. Predates the template | | scripts/qa-browser.mjs | renders the site in real Chromium at real viewports and measures it — horizontal scroll, broken images, elements past the right edge, CLS and LCP. Exit 2 if playwright is missing, because "could not check" is not a pass. Not in verify.sh: playwright is global here, not a project dependency | | scripts/prerender.js | the build step that emits static HTML for every route. Predates the template |

Why release.sh and deploy.sh are two scripts. Publishing an image and running it are separate decisions, which is the rule both the template and PrivacyLLC-Web's release script state and the reason neither of them deploys. The template's deploy.py does build, push and deploy; adopting it beside release.sh would have produced two commands that both build, a second image for the same code, and two answers to "what is running". So deploy.sh does only the half that was missing.

Three things about release.sh differ from the script it was adapted from, and each is a fact about this project rather than a preference:

  • It gates on verify.sh, not on a test suite, because there is not one. The original refuses to release on a half-run 1,600-test run; this one says out loud that a build, a secret scan and a doc-header check are not tests and that nothing in the gate exercised a route, a form or an API response.
  • It publishes one tag and does not move :dev. Both scripts follow the policy below: production always runs a numbered version, so nothing deploys a pointer and publishing one would only misrepresent what is running.
  • It checks the public origin in three files rather than one. The original passes its origin in as a build arg, so it has one copy to validate. Here https://queuenorth.com is written out in src/lib/seo.js, src/components/SEO.jsx and scripts/prerender.js, and it is baked into every canonical URL, og:url, sitemap.xml and robots.txt. The guard asks whether the three still agree, because a wrong origin cannot be corrected without another build and is invisible until somebody reads the page source.

Neither script prunes the registry. The original does, because it releases often enough for that to matter. This project has published thirteen tags in its life, deleting a published image is irreversible, and the one that matters is whichever the running container was created from — exactly what a newest-N rule gets wrong.

Why secrets.sh has a tightened pattern. Its user:pass@host in a URL rule excludes only /, @, : and whitespace in the template. That is right for source and wrong for this project's build output: every prerendered page carries schema.org JSON-LD, and //queuenorth.com"},"areaServed":{"@ parses as a host, a password and an @. Ten findings per --built run, one more for every page added — the noise that turns a scanner into something people mute. Quotes, braces, commas and angle brackets cannot occur in real userinfo, so excluding them costs nothing and was checked against three real credential URLs before being applied. The reasoning is in the script's own header.

Why backup.sh and restore-check.sh are not the template's originals. Both ship as PostgreSQL tools. backup.sh is built to be adapted — everything engine-specific is in one ENGINE block — so that block now calls better-sqlite3's .backup() inside the running container and verifies the result with sqlite3 before renaming it into place. restore-check.sh had no such seam: it is pg_restore and psql end to end, so the SQLite version is a rewrite that keeps the argument and replaces the mechanism.

What does not belong here

  • Product intent — that is docs/planning/PROJECT_PLAN.md
  • Engineering standards and the stack policy — that is docs/planning/REQUIREMENTS.md
  • What it should look and sound like — that is docs/design/
  • What happened while building it — that is docs/history/

A note on drift

Architecture docs go stale faster than any other kind, because code changes under them silently. This is exactly what the Review trigger line is for: name the change that should send somebody back here, and a reader can tell whether the trigger has fired.

The specific instance to avoid in this repository: BUILD_SUMMARY.md carried a copy of the SQL schema that predated the UNIQUE constraint on leads.email. It was not carried forward on adoption. server/index.js owns the schema; the tables above describe it and do not duplicate it.