Browser-based facial-recognition door entry — client-only ML. Single execution PRD: docs/PRD.md — Part I (E1–E10): MVP and validation; Part II (E11–E20): SPECS closure and evidence. Immutable assignment: docs/SPECS.txt.
This is Gatekeeper, a browser-based facial-recognition access system that runs entirely on the user’s device.
The pain I was solving was that most face-recognition systems require backend processing, cloud video upload, or dedicated hardware, which adds privacy risk and deployment complexity.
Gatekeeper uses the webcam to detect a face, generate a local face embedding, compare it against enrolled users stored in IndexedDB, and return GRANTED, UNCERTAIN, or DENIED.
I built the gate flow, admin enrollment, local access logs, configurable thresholds, CSV export, and browser-side ML pipeline using TypeScript, Vite, ONNX Runtime Web, and Dexie.
The hardest part was making ML inference work smoothly in the browser, so I moved heavier detection work into a background worker and added performance checks around model load and decision latency.
The result is a privacy-first access-control demo that runs like a normal static web app, keeps face data off the server, and shows a complete product workflow from enrollment to audit logs.
A browser-only facial-recognition “door” that checks who is at the camera and grants or denies entry without sending video to a server or requiring dedicated hardware.
Why browser-only?
So it ships as a normal web app: static hosting, no install, and the full pipeline (camera → models → policy) runs on the device using getUserMedia, WASM/ONNX in-page, and IndexedDB for enrollments and logs.
Why keep video off the server and skip extra hardware?
Frames and embeddings stay on the device—better privacy and simpler hosting (mostly static assets). It uses the camera and CPU/GPU you already have instead of a dedicated controller or a video-ingest backend.
How does it grant or deny entry?
- Detect a face and compute an embedding for the current frame.
- Compare it (cosine similarity) to enrolled vectors in IndexedDB.
[decideFromMatch](src/domain/access-policy.ts)maps the best score and margin vs. the runner-up to GRANTED, UNCERTAIN, or DENIED using configurable thresholds.
The gate UI reflects that verdict. GRANTED and DENIED outcomes are written to the local access log from the detection pipeline frame handler and the access decision path ([detection-pipeline/run-frame.ts](src/app/detection-pipeline/run-frame.ts), [access-decision-engine.ts](src/app/access-decision-engine.ts)).
- Node.js 20.19+ (required by Vite 8; the app targets modern Chromium)
git clone <repo-url> && cd let-me-in
pnpm install
pnpm run devOpen:
http://localhost:5173/— gate (home)http://localhost:5173/admin— admin (short URL; same as/admin.html)http://localhost:5173/log— entry log page (short URL; same as/log.html)
getUserMedia requires a secure context. Use https://localhost only if you terminate TLS locally; plain http://localhost is treated as allowed for development. Non-local HTTP shows a full-page HTTPS requirement message.
| Script | Purpose |
|---|---|
pnpm run dev |
Vite dev server with HMR |
pnpm run build |
Production build to dist/ |
pnpm run preview |
Serve dist/ locally |
pnpm run typecheck |
tsc --noEmit |
pnpm run lint |
ESLint (src/) |
pnpm run format |
Prettier write (src/) |
pnpm run format:check |
Prettier check |
pnpm test |
Vitest (unit; excludes tests/e2e) |
pnpm test:e2e |
Playwright (tests/e2e; installs Chromium on first run via pnpm exec playwright install) |
pnpm run test:scenarios |
Playwright scenarios project (tests/scenarios) |
pnpm run tests |
pnpm test then pnpm run test:scenarios |
pnpm run bench |
Stub-gate latency benches on port 5199 (start-server-and-test + bench:serve); see docs/BENCHMARKS.md |
pnpm run bench:detection / bench:e2e / bench:cold-load |
One automated bench each (same server wiring as bench) |
pnpm run bench:serve |
Vite on 5199 with stub env only (leave running, then pnpm exec tsx tests/accuracy/bench-*.js + BASE_URL if you want) |
pnpm seed:users |
Seeds three sample users into IndexedDB (gatekeeper) — see tests/scenarios/seed-3-users.js |
pnpm sync:netlify |
Rewrite netlify.toml redirect blocks from multi-page.ts |
pnpm verify:netlify |
Fail if redirects drift from multi-page.ts (no writes) |
pnpm run sweep or pnpm run construct |
Full repo verification. Use one, not both; construct already includes sweep plus build. |
- Entries:
[src/main.ts](src/main.ts),[src/admin.ts](src/admin.ts),[src/log.ts](src/log.ts)each call[bootstrapApp({ mount })](src/app/bootstrap-app.ts)(optionalpersistencefor tests). - Gate page:
[src/app/mount-gate.ts](src/app/mount-gate.ts)builds DOM and wires the camera preview via[src/app/gate-session.ts](src/app/gate-session.ts). - Admin / enrollment:
[src/app/mount-admin-shell.ts](src/app/mount-admin-shell.ts)+[src/app/mount-admin-enrollment.ts](src/app/mount-admin-enrollment.ts)— login modal, camera enrollment, IndexedDB save. E2E usesVITE_E2E_STUB_ENROLL=true(see PlaywrightwebServerenv in[playwright.config.ts](playwright.config.ts)). - Roster JSON backup: Admin import/export contract and backup schema are documented in
[docs/IMPORT_SCHEMA.md](docs/IMPORT_SCHEMA.md). - Cost projections: Development AI/tooling spend log and production projections are documented in
[docs/AI_COST_LOG.md](docs/AI_COST_LOG.md)and[docs/PRODUCTION_COSTS.md](docs/PRODUCTION_COSTS.md). - Runtime copy / seed:
[src/app/gate-runtime.ts](src/app/gate-runtime.ts)centralizes config- and env-derived values (page titles, camera strings, preview canvas size, dev FPS overlay). - Deploy routes:
[multi-page.ts](multi-page.ts)feeds Vite andnetlify.toml(keep in sync withpnpm sync:netlifyorpnpm verify:netlify).
- All org-tunable values live in
[src/config.ts](src/config.ts). - Admin credentials are resolved in
[src/app/admin-credentials.ts](src/app/admin-credentials.ts)(imported from the admin entry only, not the gate or log bundles). For production (pnpm run build/ VitePROD), bothVITE_ADMIN_USERandVITE_ADMIN_PASSmust be set to non-empty values in the build environment, or the admin app throws at load (see[.env.example](.env.example)). In development, if they are missing, a console warning is shown and defaultsadmin/adminare used. The main and log pages do not embed those defaults fromconfiganymore.
See docs/DEPLOY.md for admin credential env vars and rotation.
[netlify.toml](netlify.toml)— build command and publish directory.[public/_headers](public/_headers)— long cache for/models/* and*.onnx.- Canonical site (from PRD):
https://let-me-in-gatekeeper.netlify.app— after deploy, verify headers with
curl -I https://let-me-in-gatekeeper.netlify.app/models/yolov8n-face.onnx
(expectCache-Control: public, max-age=3600).
The app uses database name **gatekeeper** with stores users, accessLog, and settings (Dexie). After first load, settings is seeded with default threshold and cooldown snapshots supplied at bootstrap from [resolveGateRuntime().databaseSeedSettings](src/app/gate-runtime.ts) (same numbers as [src/config.ts](src/config.ts); [src/infra/persistence.ts](src/infra/persistence.ts) does not import config directly).
pnpm run typecheck && pnpm run lint && pnpm run format:check && pnpm test— all exit 0. Optionallypnpm test:e2eafterpnpm exec playwright install chromium.pnpm run build—dist/containsindex.html,admin.html,log.html.- In Chrome DevTools → Application → IndexedDB →
gatekeeper— three stores;settingshas two keys (thresholds,cooldownMs) after load.