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

13 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.