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

16 KiB
Raw Blame History

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.