# 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: `//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`.