← Back to selected work
Case study

GatePass: single-use event passes with an atomic admission check

QR passes for events and a scanner page for door staff. The core promise is small and strict: a pass is admitted at most once, even when several scanners hit it at the same instant.

typescriptnode.jsexpresssqlitereactzxing wasmdocker
GatePass sign-in page with a banner saying this is a demo environment whose data is erased whenever the server restarts.
The public sign-in page, captured 10 Nov 2026. The banner is real: the live demo’s data is erased whenever its server restarts. I did not create an account to capture anything behind it.

Problem

At a door, the same pass can be scanned by several devices: a forwarded screenshot, two gates, two people arriving together. Exactly one scan of a pass may admit, and a time-slot with a capacity may never admit more than that capacity.

GatePass is built on CompleteTech-LLC/qr-code-toolkit (MIT). Upstream stored everything in a JSON file that was rewritten in place and replaced by an empty store on any parse error. I kept the upstream history and QR renderer and rebuilt the persistence, admission and access-control layers around a real database.

Architecture

  • One Node.js process: an Express API, a server-rendered attendee pass page, and the built React bundle (an admin app, and a scanner app at /scan).
  • Everything lives in one SQLite file opened through node:sqlite, with WAL, foreign keys, STRICT tables and synchronous = FULL. Migrations are an append-only list tracked by PRAGMA user_version, with a verified backup taken before an upgrade.
  • Each event has gates and admission policies (an entry window, an optional capacity, optional allowed gates). Admission is decided by a pure function, evaluateAdmission, in a fixed order of checks, so the rules can be tested without I/O.
  • Tokens are 32 random bytes and only their SHA-256 hash is stored. The QR encodes a URL, and opening that URL never changes state.
  • Recommended hosting is Docker Compose with Caddy on one VM. Vercel is an optional static front that proxies /api and /p to the API host with a shared origin secret. The server itself never runs on Vercel because SQLite needs a persistent disk.

Constraints and tradeoffs

  • One process, one writer. All writes are serialised through SQLite, which is exactly the property the redemption logic relies on, and it is also the throughput ceiling. There is no horizontal scaling and no high availability, and rate-limit state is in that process’s memory.
  • node:sqlite is still marked experimental in Node, and its calls are synchronous, so the redeem path is limited to indexed single-row lookups.
  • No offline scanning. Single use needs the server, so a venue network or server outage stops admission.
  • The hash-only token design means a lost pass link cannot be recovered, only reissued or revoked.

Hard parts

  • Atomic redemption. A redeem runs in one transaction that starts with BEGIN IMMEDIATE, so any other writer waits and then reads committed state. It reads the pass, the event window and the policy, evaluates the rules, claims capacity with a conditional UPDATE (zero changed rows means capacity_full), inserts the redemption and logs the scan. Any error rolls all of it back together.
  • The database as a backstop. redemptions.pass_id is the primary key and admission_policies carries CHECK (capacity IS NULL OR admitted_count <= capacity), so double admission and capacity overrun are refused even if the application rules were wrong.
  • Durability story. Every commit syncs the WAL; the CLI has backup, verify and restore commands, and a database newer than the build or a corrupt file stops startup instead of being replaced.
  • Roles and secrets. Accounts use a Host-prefixed, HttpOnly session cookie with a CSRF header; scanner devices use a per-device key. Scanner setup links carry that key in the URL fragment, which browsers do not send to servers.

Testing

  • The test matrix maps each requirement to a test and records the last run. The recorded full run was 21 files, 256 passed and 1 skipped (the SIGTERM test, which cannot run on Windows), on one Windows laptop. That is the doc’s figure for that run, not a CI result.
  • Concurrency tests run each redemption in its own worker thread with its own SQLite connection, released together, at 2, 25 and 100 threads. The history is checked against the sequential specification “the first redemption admits, every later one is already_redeemed”, using the real-time-order condition from Herlihy and Wing. The doc is explicit that this is evidence about observed histories, not a proof of linearizability.
  • The suite also covers process-recovery tests with real server processes killed and restarted, Playwright end-to-end runs on Chromium, Firefox and WebKit, and a Docker restart check where a redeemed pass stays redeemed afterwards.

Limitations and what I’d do next

  • The live demo is not durable. Its backend is a Render free instance with no persistent disk, so all data is erased on every restart, deploy or idle spin-down. The demo shows a warning, and the docs say not to run a real event on it. A durable setup (the same container on a persistent disk) is designed but not enabled.
  • Camera scanning on real iPhone or Android devices has not been verified; tests use fakes and a generated frame. The test matrix includes a manual checklist for it.
  • The doc records that GitHub-hosted CI had no recorded result yet when it was written, and that the free Render instance has not been load-tested. All performance figures come from my laptop.
  • Recorded security gaps include: no MFA or email verification, only a short local list of weak passwords, failed operator-token attempts not audited, and an open question about closing sign-up still allowing invited organizers.
  • Next: run on a durable host and verify a physical-device scan end to end before calling it event-ready.

Sources: the repository’s docs/architecture.md, docs/security-and-threat-model.md, docs/test-matrix.md and docs/decisions/ (ADRs 0001, 0003, 0007, 0009), plus its README for the upstream attribution and hosting status.

← Back to selected work