Files
visitor-signin/.claude-context/sessions/2026-09-23-visitor-kiosk-build.md
2026-09-30 23:01:32 +10:00

244 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Visitor sign-in kiosk: build, HTTPS, and server-side label printing
**Date:** 2026-09-23
**Project:** visitor-signin
**Status:** ongoing
**Tags:** node, express, sqlite, docker, kiosk, tls, jamf, brother-ql, ql-820nwb, label-printing, google-sheets, multi-site, 2fa, dithering
## Context
Built an internally hosted visitor sign-in/out kiosk for school campuses from scratch: guest and
recurring-visitor sign in with photo capture and WWCC/VIT recording, an admin console, and a
Google Sheet mirror for evacuation use. The session then iterated through multi-site support,
per-admin accounts with 2FA, self-signed HTTPS for iPad kiosks deployed via Jamf Pro, and
moving badge printing off the iPad onto the server, driving a Brother QL-820NWB directly. It
closed on getting two-colour (DK-22251) printing accepted by the printer and on photo halftoning.
## Key Decisions
- **Node/Express + SQLite + vanilla JS in one container, no front-end build step.** Simple to
run on a single docker host; no database server to manage. Rejected a React/SPA build as
unnecessary weight for a kiosk.
- **Recurring-visitor PINs are encrypted (AES-256-GCM keyed from `APP_SECRET`), not hashed.**
A 4-digit PIN has 10,000 values, so hashing gives no protection against someone holding the
DB file; encryption lets admins reprint a lost card. Real protection is the lockout (5 wrong
PINs = 15 min). Consequence: changing `APP_SECRET` makes every PIN unreadable.
- **Phone, email and PIN must each be unique.** PINs use a keyed HMAC column (`pin_lookup`)
because the ciphertext has a random IV and cannot be compared. Enforced in app code *and* by
partial unique indexes; migration backfills `pin_lookup` and names existing duplicates rather
than failing startup.
- **A stored recurring-visitor photo is copied onto each visit, not referenced.** Keeps the visit
log a true snapshot, and stops photo-retention purging from deleting a live profile photo.
- **"Remove" on a recurring visitor is a hard delete that keeps visit history.** Visits
denormalise name/contact/host, so history survives; identifiers are freed for reuse. "Active"
untick is offered as the reversible alternative.
- **TLS: a 10-year local CA plus a 398-day server certificate, auto-renewed.** Apple/Chrome reject
server certs over ~398 days, so a single self-signed cert would need reinstalling on every
tablet yearly. The server cert renews inside 30 days and hot-swaps via `setSecureContext`;
the CA never changes, so devices are configured once. Rejected: single long-lived self-signed
cert; reverse proxy (still supported as an option).
- **Cert change detection tracks configured names via a sidecar file, not the full SAN list.**
The container's docker bridge IP is included automatically and changes on restart; comparing
full SANs would reissue the cert on every boot.
- **Plain-HTTP helper port serves the CA (`/ca.crt`, `/ca.cer`, `/ca.zip`) and redirects the
rest to HTTPS.** Solves the chicken-and-egg of downloading a cert you don't yet trust.
- **CA offered as DER `.cer` and as a zip bundle.** Jamf's certificate payload rejects PEM `.crt`
on extension; browsers block bare `.crt/.cer` downloads. The zip (all encodings + README with
fingerprint and install steps) is written by an in-repo ZIP writer rather than a dependency,
validated against Python's `zipfile`.
- **Google Sheet holds only who is currently on site.** One tab, rewritten in full on every sign
in/out (never patched, so it can't hold stale names). The earlier append-only history tab was
dropped at the user's request; full history stays in the app (Visit log + CSV). Keeps visitor
contact/movement history off the cloud.
- **Server-side printing: render the badge to a bitmap with `@napi-rs/canvas`, send via
`brother_ql` over TCP 9100.** Takes the iPad out of the print path (it was mangling label
format). Chose `brother_ql` over hand-rolling Brother's raster protocol for hardware
reliability. Sign-in awaits the print briefly; on failure the kiosk falls back to browser print.
- **Roll type is an explicit per-site setting (`printer_label`), decoupled from the red styling
option.** Originally the media id was inferred from the "print in red" checkbox, which caused
unexplainable "Wrong Roll Type" refusals. See *Technical Details* for the `--red` finding.
- **Photo halftoning happens in the renderer (Floyd–Steinberg), not in the printer driver.** The
printer is 1-bit; `brother_ql`'s default threshold turned faces into solid blocks. Doing it
server-side makes the preview and the printed label the same image.
- **Admin login is a separate document at `/admin/login`.** Replaced an embedded panel that
"unfolded" inside the console. Routes are declared before `express.static` so the redirect
guard can't be bypassed.
- **Branding: four settable colours; everything else derived.** Text on coloured backgrounds is
chosen by WCAG luminance; the muted label colour is mixed toward the background only as far as
it can go while staying ≥4.5:1. A fixed grey was the cause of "text blending in". SVG banners
rejected (can carry script, served to every kiosk).
- **Kiosk has no admin link and no author credit.** A kiosk is a public terminal. Credit line
remains on staff-facing pages only.
- **Company is recorded, not printed.** Adding it to the badge overflowed 62×90 mm labels.
## Technical Details
**Ports / env:** container `3000` (HTTPS app) and `3001` (HTTP helper); host `HOST_PORT=8443`,
`HOST_HTTP_PORT=8080`. Key env: `HTTPS_ENABLED`, `HTTPS_HOSTNAMES` (comma list of names/IPs
the kiosks use), `HTTPS_PUBLIC_PORT` (must equal `HOST_PORT`), `APP_SECRET`,
`ADMIN_BOOTSTRAP_EMAIL`/`ADMIN_BOOTSTRAP_PASSWORD` (first account only; `ADMIN_PASSWORD` is
dead), `ADMIN_REQUIRE_2FA`, `ADMIN_ALLOWED_DOMAINS`, `EXPIRY_WARNING_DAYS`,
`SHEETS_ONSITE_TAB`, `PRINT_TIMEOUT_MS`, `PRINT_SIGNIN_WAIT_MS`.
**Operational gotchas (all hit during this session):**
- After editing `.env`, use `docker compose up -d`, **not** `docker compose restart` — restart
keeps the old environment. Symptom: cert covers only `localhost`, `visitors.local` and a
`172.x` bridge IP. Server now warns at boot when `HTTPS_HOSTNAMES` is at its default.
- Host `.env` predates many settings; compare with `diff <(grep -oE '^[A-Z_]+=' .env | tr -d '=' | sort -u) <(grep -oE '^[A-Z_]+=' .env.example | tr -d '=' | sort -u)`.
- Bind-mounted `./data` is created root-owned; `docker-entrypoint.sh` starts as root, chowns
`/data` only if ownership is wrong, then drops to `node` via `setpriv` (`util-linux` installed
explicitly).
- `pull_policy: build` in compose — the image is never pulled from a registry.
- `.gitattributes` forces LF (CRLF only for `.ps1`/`.bat`) so shell scripts survive Windows.
- `data/`, `.env`, `secrets/` are gitignored and survive `git reset --hard` (verified).
**Deploy flow (host):** branch is **`deploy`**.
`git fetch origin && git reset --hard origin/deploy && docker compose up -d --build`
then `docker compose exec visitor-signin npm run version` (reports which features are present).
**Printer — QL-820NWB findings (hard-won):**
- Head width on a 62 mm roll is **696 dots** at 300 dpi; bitmap is always 696 wide. 90 mm = 1063
raster lines. Rotation is applied to the finished image, not the layout.
- **DK-22251 (black/red) requires `brother_ql ... --label 62red --red` — even for an all-black
badge.** Proven on the wire: `--label 62red` alone is byte-identical to `--label 62` (1063
mono `g` lines, `ESC i K` mode byte `0x08`); adding `--red` produces 2126 two-plane `w` lines
and mode byte `0x09`. Printer message when missing: *"Black/Red on White paper is installed
now. Change it to Monochrome media."* Mismatch fails in both directions.
- `brother_ql`'s network backend **only writes; it never reads**, so it reports success even when
the printer refuses the job.
- The QL-820NWB **does not answer status requests on port 9100** (write-only print server).
**Brother Status Monitor on a connected Windows PC** gives the real refusal reason — use it
first.
- `62` = continuous roll; `62x100` etc. = die-cut. Sending one to the other is "Wrong Roll Type".
**Schema (SQLite, `src/db.js`, idempotent `addColumn` migrations):** tables `sites`, `hosts`,
`frequent_visitors`, `visits`, `admin_users`, `login_attempts`, `pin_attempts`, `settings`,
`sheet_queue` (now unused). Notable additions this session: `sites.printer_{enabled,host,port,
model,rotate,label}`, `sites.photo_{mode,threshold,contrast}`, `sites.badge_*`,
`sites.banner_{path,height,align}`, `sites.colour_{brand,signout,page,text}`,
`frequent_visitors.{pin_lookup,photo_path,company,site_id}`, `visits.{company,site_id,site_name}`.
**Diagnostic scripts (run inside the container):** `scripts/version.mjs` (`npm run version`),
`scripts/print-test.mjs [--dry] [--label ID] [--rotate DEG]`, `scripts/printer-status.mjs`
(won't work on the QL-820NWB — see above), `scripts/print-probe.mjs` (interactive, needs
`docker compose exec -it`), `scripts/make-cert.mjs [--force]`, `scripts/gen-cert.sh`.
**Push scripts:** `push-to-gitea.ps1/.bat/.sh`. Bugs fixed in-session: PowerShell functions
return their whole output stream (a wrapper made every git call look failed); branch was
hard-coded to `main` while work was on `deploy`; now detects unfinished rebases, checks git
identity, uses the checked-out branch, and verifies `$LASTEXITCODE` after each call.
## Code Changes
Whole project created this session. Principal files:
- `src/server.js` — Express app, HTTPS listener with cert hot-swap, HTTP helper (CA downloads +
redirect), admin page routing before static, `Cache-Control: no-cache` on html/js/css, jobs.
- `src/db.js` — schema, migrations, default site, `pin_lookup` backfill, duplicate reporting.
- `src/config.js` — env parsing incl. `https`, `sheets`, `printing`, `admin` groups.
- `src/auth.js` — scrypt passwords, RFC 6238 TOTP (verified against RFC vectors), recovery codes.
- `src/users.js` — admin accounts, domain restriction, lockout, bootstrap.
- `src/pins.js` — PIN encrypt/decrypt, keyed `pinLookup`, collision-aware `generatePin`.
- `src/photos.js` — save/copy/purge visit and profile photos.
- `src/sites.js` — site helpers, HTML badge renderer (browser fallback).
- `src/branding.js` — theme derivation, WCAG contrast, banner storage.
- `src/tls.js` — CA + server cert lifecycle, DER export, zip bundle.
- `src/zip.js` — dependency-free ZIP writer.
- `src/sheets.js` — single-tab "On site now" rewrite, human-readable Google error mapping.
- `src/printer.js` — canvas badge render, Floyd–Steinberg halftone, `brother_ql` invocation with
`--red` for `62red`, photo preview sheet, error translation.
- `src/routes/kiosk.js` — sign in/out, PIN auth, site scoping, server-print with fallback.
- `src/routes/admin.js` — auth/2FA, users, sites, hosts, recurring visitors, visits, printer
diagnostics/test/preview, TLS endpoints, status.
- `public/index.html`, `public/js/kiosk.js`, `public/css/kiosk.css` — kiosk flow (filter input
above host dropdown, square photo capture, badge fallback).
- `public/login.html`, `public/js/login.js`, `public/css/login.css` — standalone paged login.
- `public/admin.html`, `public/js/admin.js`, `public/css/admin.css` — console.
- `Dockerfile`, `docker-compose.yml`, `docker-entrypoint.sh`, `.gitattributes`, `.env.example`.
- `scripts/*` — diagnostics and cert tooling listed above.
## Open Questions / Next Steps
- **Confirm the `--red` fix prints on real hardware.** Pending: deploy, then **Test print**. The
site's roll setting is already `62red` with red on.
- **Latest changes exist only in the handed-over tarball, not in git:** CA zip bundle, printer
diagnostics/probe scripts, `--red` flag, photo halftoning. Next: extract over the Windows
working copy → commit → push `deploy` → host `reset --hard origin/deploy` → rebuild.
- **Tune photo halftoning from a printed label** (defaults: error diffusion, threshold 50,
contrast 20). 300 dpi dithering reads differently in the hand than on screen.
- Physical print output not yet verified: margins, cut position, rotation on the roll, red plane.
- Host `.env` is stale; recommend rebuilding from `.env.example`, preserving `APP_SECRET` exactly.
- Google Sheet returned 403 "caller does not have permission" — fix is sharing the sheet with the
service account (shown in Admin → System). Outcome not confirmed.
- Recommend a DNS name for the kiosk instead of a bare IP (IP-only certs are unreliable on
Android; DHCP changes break everything).
- Consider making `deploy` the default branch in Gitea / retiring the tangled `main`.
- Offered, not built: die-cut roll options in the roll dropdown; an admin field for
`HTTPS_HOSTNAMES`; banner/logo on the printed pass; theming the admin console.
- Housekeeping: `sheet_queue` table is unused; `brother_ql==0.9.4` is unmaintained (fork
`brother_ql_next` exists).
## Artifacts
- Repository: `<redacted-gitea-host>/<redacted-owner>/visitor-signin`, working branch `deploy`.
- Commits on `deploy`: `8bc7179` Server-side printing, roll type setting, cache headers;
`eb98658` Printing Debug; `b1a7933` Printer Debug 2.
- `brother_ql` 0.9.4 (PyPI) — `print --help` documents the `--red` requirement.
- Brother QL-820NWB specification: 12–62 mm media, 60.96 mm max print width, 300×300 dpi.
- RFC 6238 (TOTP) — test vectors used to verify `src/auth.js`.
---
## Addendum — 2026-09-23, PIN reset bug
**Symptom:** after changing a recurring visitor's PIN, phone + PIN was rejected at the kiosk.
**Cause:** the Edit form carried a "PIN" field whose value `PATCH /admin/api/frequent/:id`
never read. The record saved, the field was silently discarded, and the admin handed out a PIN
the system had never stored. (`POST /admin/api/frequent/:id/pin`, the "New PIN" button, was
always correct — verified by reproduction.)
**Fix:** `PATCH` now honours a supplied `pin`: 4 digits, checked for collisions via
`allocatePin(pin, row.id)`, written to `pin_enc` and `pin_lookup`. Blank keeps the current PIN.
A malformed or already-used PIN is rejected with a message instead of ignored. The response
carries `pinChanged`, and the console shows the printable card when it is true. Saving a record
also clears `pin_attempts` for both the old and new phone number, so a changed number cannot
leave a stale lockout.
**Files:** `src/routes/admin.js` (PATCH handler), `public/js/admin.js` (edit modal shows the
current PIN, relabelled field, shows the card after a change), `public/css/admin.css`, `README.md`.
---
## Addendum — iPad trust troubleshooting
Reported: Safari and Chrome on a Jamf-managed iPad still report an insecure connection after the
certificate was imported into Jamf.
**Found:** the server certificate was being issued for *exactly* 398 days — Apple's ceiling, with
zero margin for rounding or clock skew. `SERVER_DAYS` in `src/tls.js` reduced 398 → 396.
**Added:** `scripts/tls-check.mjs` (`npm run tls-check`), which connects to a given URL and checks
what iOS requires: address covered by a SAN, validity span, SAN present, `serverAuth` EKU,
signature algorithm, key size, and that the served leaf was issued by this server's CA. It prints
the authority and leaf fingerprints side by side.
Note: Node's `X509Certificate` does not expose extended key usage and `toString()` returns PEM,
so the EKU and signature checks shell out to `openssl x509 -noout -text`.
**Most likely causes, still unconfirmed on the device:** the *served* certificate was uploaded to
Jamf instead of the *authority*; or the address the iPad uses is not in `HTTPS_HOSTNAMES` (the
host `.env` was previously missing that line entirely).
---
## Addendum — promote a guest to a recurring visitor
Added **Make regular** to the On site list for any visit with a mobile number that is not already
a recurring visitor.
`POST /admin/api/visits/:id/make-regular` copies name, company, phone, email, check type/number/
expiry, host (as their default) and site from the visit, issues a random unique PIN via
`allocatePin(null)`, and copies the visit photo into a separate profile photo with
`copyStoredPhoto` so retention purging the visit cannot delete it. Returns the shaped record plus
`pin` and `photoCarriedOver`; the console opens the printable card.
Refusals: no mobile number on the visit (the number is the sign in name); phone or email already
belongs to a recurring visitor (error names them and returns `existingId`). Site-scoped admins are
checked with `assertSiteAllowed`. The visit row is deliberately not modified or relinked.
**Files:** `src/routes/admin.js`, `public/js/admin.js`, `README.md`.