Three things, all in the path between this repository and the running image.
Closes#225, #224 and #223.
1. THE PRODUCTION SITEMAP CARRIED NO LASTMOD AT ALL. Dates come from git
history, and the image build cannot see git: .dockerignore excludes .git and
node:alpine has no git binary. prerender.js read the failure into an empty
catch commented "git unavailable or file untracked", so all 18 URLs came out
undated while the build printed a success line. Local builds looked perfect,
which is why nobody caught it.
release.sh now computes the map where git exists, passes it as the
SITEMAP_LASTMOD build arg, and then asks the built image whether its sitemap
has dates, refusing to publish one that does not. prerender prints the count
on every run, so "18 URLs, 0 dated" can never again read as success. The
route-to-source map moved into scripts/lib/routes.js, where a service page
now also counts its own content file, so editing one page's copy moves that
page's date and no other.
Proven: an image built with the arg carries 18 lastmod entries; a build with
git deliberately unreadable and no arg reports "18 URLs, 0 carrying a
lastmod" and warns.
2. THE DOCKER BUILD CONTEXT CARRIED CLIENT MATERIAL AND LIVE SECRETS. .drop/,
zoho.md (the reCAPTCHA secret and the Zoho tokens), Levi.md and two 30 MB
zips were all sent to the daemon on every build, along with four agent
workspaces. The final image copies only built output, so none of it ever
shipped, but one careless COPY would have changed that. Proven by listing the
context from inside a throwaway image: before, all of it; after, none of it.
3. UNTRACKED FILES PASSED SILENTLY. docker build packs the working tree, so an
untracked module the code imports produces an image that works and a tag that
cannot rebuild it. release.sh now refuses while untracked files are present,
and pre-commit's note counts them too.
#223 also claimed post-commit hides a refused push. It does not: it printed
"push was refused. The commit is safe locally and the branch is now ahead."
during this batch. The issue was corrected on the tracker rather than acted on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
scripts/status.sh --deployed-version reads org.opencontainers.image.version and
the image carried no such label, so on 2026-08-18 it correctly answered
"unknown" and the digest was the only way to tell one deploy from another. Step
3 of the incident runbook depends on that command.
ARG APP_VERSION defaults to 0.9.3 and can be overridden at build time.
Verified by building: docker build succeeds and
`docker image inspect --format '{{json .Config.Labels}}'` reports
org.opencontainers.image.version = 0.9.3, plus title and source.
Takes effect on the next image build. OPERATIONS.md says so rather than
implying the running container has it — it does not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_null confirmed queuenorth.com as the permanent public origin, which settles
#212 and exposed a wrong claim written earlier the same day.
docs/OPERATIONS.md said queuenorth.com "is not this deployment ... do not
diagnose against it". That came from a DNS lookup and an assumption. It is this
deployment: both hostnames serve the identical bundle and this server's own
/api/health shape, 24.41.108.95 is this network's own public IP, and both reach
qn-website-dev on nebula — queuenorth.com through nginx-proxy-manager on
thor/exodus, qn.isnull.dev through Cloudflare. Two front doors, one container,
no non-production environment.
That is the worst direction for a runbook to be wrong in, so the correction
quotes the wrong sentence rather than replacing it silently. The QA Round 0
table likewise gained the production observations as extra rows instead of
having its originals rewritten.
Dockerfile: the CORS_ORIGIN fallback was '*'. The server sets credentials:true
and browsers reject '*' with credentials outright, so that fallback would have
broken every form rather than over-permitting. Now the real origin.
healthcheck.sh and preflight.sh now watch production by default, with the
second front door reachable through their env overrides — the two ingresses
terminate TLS in different places and can rot independently.
Also fills a gap adoption left explicitly undone: the deploy path is a Portainer
stack, id 58 on nebula, found from the container's own compose labels.
OPERATIONS.md documents it, including that the stack file is a separate copy
from this repository's docker-compose.yml and the two have already drifted.
That drift is all that remains of #212 — one trailing slash on line 21 of the
stack file. Left in place: nothing is broken today, and fixing it recreates the
container and takes both front doors down together.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Fix critical bug: token refresh now uses ZOHO_ACCOUNTS_DOMAIN
(accounts.zoho.com) instead of API domain (www.zohoapis.com).
The OAuth token endpoint lives on a different domain.
- Remove unnecessary redirect_uri from refresh token request
- Add ZOHO_ACCOUNTS_DOMAIN env var (separate from API domain)
- Split contact name into First_Name/Last_Name for Zoho schema
- Replace Service_Interest (non-standard field) with Description
+ Lead_Source: Website (standard picklist value)
- Switch from Insert to Upsert API with duplicate_check_fields:
[Email] so duplicate submissions update instead of error
- Add trigger: ['workflow'] for explicit workflow control
- Add token refresh retry (1 retry on transient failure)
- Add ZOHO_CASES_ENABLED env var for future Cases forwarding
- Update .env.example with full Zoho config documentation
- Update FUTURE.md with detailed Phase 7 Zoho integration plan
- Remove obsolete ZOHO_REDIRECT_URI from Dockerfile
- #4: Replace su-exec with USER nodejs in Dockerfile (P0)
- #6: Add UNIQUE constraint on leads.email with migration (P1)
- #10: Consistent NULL handling for optional fields (P1)
- Hero section rewrite: B2B value proposition, prominent 8x8 badge
- Clean up .bak file left by agent
v0.2.2: Removed COPY --from=builder node_modules from runner stage.
The full dev+prod modules (116MB) were being copied as a permanent
Docker layer, then npm ci --omit=dev installed a separate prod-only
set on top. Now only the prod install runs, cutting 116MB.
- Multi-stage Dockerfile with non-root nodejs user
- Healthcheck using Node 20 built-in fetch (no wget)
- docker-entrypoint.sh: root permission fix, then exec to nodejs
- server/db.js: deferred SQLite init for Docker volume permissions
- docker-compose.yml with named volumes for persistence
- .dockerignore and .env.example added
- README updated with Docker usage section
Security reviewed by Private Hudson. All blockers resolved.