Technology Stack
@prisma/adapter-pgPrismaPg adapter injects the connection at runtime. Schema in prisma/schema.prisma. Migrations via raw SQL on Neon (no Prisma migration tracking in prod)municipalvote.com@upstash/ratelimit. In-memory fallback for local dev. Applied only to /api/join/api/meetings/[id]/import-ballot to parse warrant article PDFs into structured JSON/api/active/[roomCode] every few seconds to detect when a vote opens. The moderator dashboard polls /api/meetings/[id]/sessions. This was a deliberate simplicity choice — the polling interval is short enough for a meeting context and eliminates a stateful WebSocket dependency.
Data Model
Entity overview
Seven models. The core voting path is Meeting → VotingSession → Vote. The check-in path is RegisteredVoter ↔ CheckIn ↔ Meeting. Voter token issuance is Meeting → VoterToken ← deviceToken.
Key fields and design decisions
| Meeting.roomCode | 6-character uppercase code (e.g. LK4R2A) — unique, cryptographically random, excludes ambiguous characters (0/O, 1/I, S/5). Displayed on projector screen so voters can join. Unique constraint in DB. |
| Meeting.status | PENDING | ACTIVE | CLOSED. CLOSED is terminal — no new sessions can be opened and voter passes are deactivated. |
| Meeting.geofence* | geofenceEnabled (bool), geofenceLat/Lng (float), geofenceMeters (int, default 300). All checked at vote-submission time, not at join time. |
| VotingSession.parentSessionId | Self-referential string FK (no Prisma @relation). Set when the session is an amendment to another article. Used to compute word-level diffs in the moderator UI. |
| VotingSession.position | Integer ordering field. When inserting an amendment, all sessions after the parent are shifted down by 1 and the amendment takes parent.position + 1. |
| Vote.deviceToken | Client-generated UUID stored in localStorage. Combined with votingSessionId forms a unique constraint — the primary duplicate-vote prevention mechanism at the database level. |
| VoterToken.deviceToken | Null until claimed at /api/join. Claimed via conditional update (WHERE deviceToken IS NULL) inside a try/catch — prevents a race condition where two devices claim the same pass simultaneously. |
| Heartbeat | Upsert on every active-session poll. unique(meetingId, deviceToken). Used by the moderator report to count unique devices seen during the meeting. |
| CheckIn | Created by the check-in volunteer tablet when a voter is found in the RegisteredVoter table. unique(meetingId, registeredVoterId) prevents double check-in of the same voter. |
ID strategy
All primary keys use cuid() — collision-resistant IDs generated by Prisma. Room codes are separate cryptographically random strings (see Section 4). deviceToken is a UUID generated client-side with crypto.randomUUID() and persisted in localStorage.
API Surface
All routes are Next.js App Router Route Handlers (route.ts files). Every route returns JSON. Auth is enforced per-route — there is no middleware layer doing blanket auth.
Public voter routes (no auth required)
| Method | Path | Description | Key behaviour |
|---|---|---|---|
| POST | /api/join |
Join a meeting by room code | Rate-limited 2,000 req/60s per IP. Claims voter pass (conditional update on deviceToken IS NULL). Returns joinToken (HMAC, 8h TTL) binding (meetingId, deviceToken). Returns active session + all sessions. |
| POST | /api/vote |
Submit a vote | Validates joinToken, voter pass binding, geofence, session status, and duplicate vote. Single DB write. DB unique constraint is final guard. See Section 5 for full pipeline. |
| POST | /api/active/[roomCode] |
Heartbeat + active session poll | Upserts Heartbeat row. Returns currently OPEN session or null. Called every ~3s by voter clients. |
| GET | /api/room/[roomCode] |
Meeting metadata for voter join screen | Returns title, geofence config, requireVoterToken flag. No votes or device data. |
| GET | /api/meetings/[meetingId]/sessions |
Session list for results/display pages | Returns sessions with votes. If caller has valid mod token: includes deviceToken per vote (for unique-voter count). Otherwise: choice only. |
Moderator routes (mod token required)
| Method | Path | Description | Auth scope |
|---|---|---|---|
| POST | /api/moderator/login |
Exchange password for mod token | Password compared via SHA-256 + timingSafeEqual. Sets mod_auth HttpOnly cookie + returns token in body (for localStorage fallback). |
| POST | /api/moderator/logout |
Clear mod session | Clears cookie. Client clears localStorage. |
| GET POST | /api/meetings |
List / create meetings | Global mod token only. POST generates unique room code (crypto random, retry loop up to 10). |
| GET PATCH DELETE | /api/meetings/[meetingId] |
Get / update / delete a meeting | Meeting-scoped mod token. PATCH handles settings updates including geofence config and meeting status (adjourn). |
| GET POST | /api/meetings/[meetingId]/sessions |
List / create voting sessions | POST handles position shifting for amendments. Auto-assigns article number as {parent}.{N} when afterSessionId is provided. |
| PATCH DELETE | /api/sessions/[sessionId] |
Open/close/reconsider/edit/delete a session | Serializable transaction enforces one-open-at-a-time. Reconsider clears votes and reopens. Edit only allowed on PENDING sessions. |
| GET POST DELETE | /api/meetings/[meetingId]/tokens |
Generate / list / delete voter passes | POST bulk-inserts up to 10,000 VoterToken rows in a single transaction. DELETE removes all tokens and clears requireVoterToken. |
| POST | /api/meetings/[meetingId]/import-ballot |
AI warrant article import | Accepts PDF via multipart form. Passes base64-encoded PDF to Claude claude-sonnet-4-6 with a structured extraction prompt. Returns article array for moderator review before bulk insert. |
| GET | /api/meetings/[meetingId]/export |
CSV export of meeting results | Returns RFC-4180 CSV. All string values are CSV-safe (double-quoted, formula-injection-prefixed). |
| GET | /api/meetings/[meetingId]/attendees |
Live attendee count | Counts distinct Heartbeat rows for the meeting. Used by the moderator dashboard header. |
| GET PATCH | /api/meetings/[meetingId]/settings |
Meeting settings read/write | Separate from the main meeting route. Handles geofence, PIN, and voter token settings in one endpoint. |
Check-in routes (PIN-authenticated)
| Method | Path | Description |
|---|---|---|
| POST | /api/checkin/[roomCode]/verify-pin |
Validate the volunteer check-in PIN against the meeting's stored PIN. |
| GET | /api/checkin/[roomCode]/search |
Full-text search of RegisteredVoter by last name or street address. Returns paginated results with check-in status. |
| POST | /api/checkin/[roomCode]/checkin |
Mark a registered voter as checked in. Creates CheckIn row. Idempotent (returns existing if already checked in). |
| GET | /api/checkin/[roomCode]/stats |
Checked-in count vs total registered voters for the meeting. |
| GET | /api/checkin/[roomCode]/roster |
Full alphabetical roster with check-in status. Used for print-ready check-in sheets. |
Admin routes (global mod + admin password)
| Method | Path | Description |
|---|---|---|
| GET PATCH | /api/admin/settings |
Read/update global app settings (admin password hash, etc.) stored in GlobalSetting. |
| POST | /api/admin/voters/import |
Bulk import voter roll from CSV. Upserts on voterId. |
| GET | /api/admin/voters/search |
Search registered voter database by name, address, or precinct. |
| GET | /api/admin/voters/stats |
Total registered voters, breakdown by precinct and status. |
| GET | /api/cron/deactivate-tokens |
Vercel cron job — deactivates voter tokens for closed meetings. Runs on a schedule. |
Authentication & Authorization
The system has three distinct auth contexts: moderator auth, voter pass auth, and check-in PIN auth. None of them use a third-party auth provider — everything is implemented with Node.js crypto.
Moderator token (HMAC-SHA256, 12h TTL)
The token is transported two ways simultaneously: an HttpOnly; Secure; SameSite=Lax cookie (mod_auth) set at login, and in the response body for the client to persist in localStorage. API routes accept either. The dual transport exists because Safari ITP can block third-party cookies; the header fallback (x-mod-auth) via localStorage ensures moderators on Safari aren't silently logged out mid-meeting.
Join token (HMAC-SHA256, 8h TTL)
Issued by /api/join after a voter successfully enters the room. Proves that a specific (meetingId, deviceToken) pair went through the join flow. The vote endpoint requires it — preventing a script from generating random deviceToken values and submitting votes without ever joining.
Moderator login (SHA-256 password hash)
The stored credential is a SHA-256 hex digest of the plaintext password, set via the MOD_PASSWORD environment variable. At login, the supplied password is hashed and compared using timingSafeEqual. On success, a signToken("global") token is issued with a 12-hour TTL and set as an HttpOnly cookie.
Check-in PIN
A plain numeric PIN stored on the Meeting record (checkInPin field). Volunteers enter it on the check-in tablet. Verified at /api/checkin/[roomCode]/verify-pin. The PIN grants access only to check-in operations — not to moderator controls.
Authorization matrix
| Caller | Credential | Access |
|---|---|---|
| Voter | None (public) | /api/join, /api/vote, /api/active/*, /api/room/* |
| Meeting moderator | HMAC token (meeting-scoped) | All /api/meetings/[meetingId]/* and /api/sessions/* routes |
| Global moderator | HMAC token (global) | All of the above + /api/meetings (create/list) |
| Check-in volunteer | Meeting PIN | /api/checkin/[roomCode]/* only |
| Admin | Global mod token + admin password | /api/admin/* |
Vote Integrity Pipeline
The POST /api/vote handler is the most security-critical path in the application. Every vote passes through eight sequential checks before a DB write is attempted. A failure at any step returns a 4xx and logs the event.
@@unique([votingSessionId, deviceToken]) constraint on the Vote model ensures only one row is ever inserted. The P2002 catch in step 8 surfaces this as a 409 rather than a 500.
One-open-at-a-time enforcement
When a moderator opens a session, PATCH /api/sessions/[id] runs a Serializable-isolation transaction:
Real-Time & Polling
The application uses HTTP polling rather than WebSockets or SSE. This was a deliberate trade-off: polling is stateless, works through all proxy/CDN layers, and eliminates persistent connection management on a serverless platform.
Voter client polling (/api/active/[roomCode])
- The voter page calls
POST /api/active/[roomCode]with thedeviceTokenin the body every ~3 seconds while a vote is not open - The endpoint upserts a
Heartbeatrow (last-seen timestamp) and returns the currently OPEN session ornull - When an OPEN session is returned, the voter UI transitions to the ballot screen
- When the voter is on the ballot screen, polling pauses — the vote is submitted directly and the result is shown from the response
- Heartbeats stop being recorded for CLOSED meetings (no point counting attendees post-adjourn)
Moderator dashboard polling (/api/meetings/[meetingId]/sessions)
- The moderator control panel re-fetches the full session list periodically to pick up vote counts as ballots come in
- The sessions endpoint returns
voteswithdeviceTokenfor authorized callers — the client computes the tally from the raw votes array (no separate tally field is stored) - The tally is derived from
tallyFromVotes(session.votes)intypes/voting.ts— a pure function, no server-side aggregation stored
Display page
The public display page (/display/[roomCode]) polls /api/meetings/[meetingId]/sessions and renders live tallies with Recharts bar and pie charts. Intended to be projected on the auditorium screen during a vote.
/api/active path is the natural upgrade path.
Security Mechanisms
HMAC tokens — crypto.createHmac
Two independent HMAC token types. Both use the same MOD_SECRET env var, SHA-256 digest, and timingSafeEqual for constant-time comparison.
| Mod token format | {meetingId}.{base36-ts}.{base64url-sig} — meetingId embedded so scope is verifiable without DB lookup |
| Join token format | {base36-ts}.{base64url-sig} — meetingId/deviceToken NOT embedded; provided externally at verify time, binding checked via HMAC payload |
| Timing attack prevention | timingSafeEqual used for all HMAC comparisons and password hash comparison |
| TTL | Mod tokens: 12h. Join tokens: 8h (covers a full town meeting). Embedded base36 timestamp checked at every verification. |
Password storage — SHA-256 hex digest
The moderator password is stored as a SHA-256 hex digest in the MOD_PASSWORD env var (not in the database). This is not bcrypt — the trade-off is that SHA-256 is fast (no salting, no stretch), acceptable here because the password gates a single-operator system behind an additional HMAC token layer, and the token is the actual credential for all API calls after login.
GPS geofencing — Haversine formula
Voter pass token claiming — race condition prevention
Rate limiting — Upstash Redis sliding window
Input validation
- All ID fields capped at 128 characters — prevents oversized-payload DoS
choicefield validated as strict enum — no other values accepted- Room codes normalized to uppercase before DB lookup
- Voter token count capped at 10,000 — prevents mass DB row insertion via the token generation endpoint
- Article question text capped at 2,000 characters
- Meeting title capped at 200 characters
- CSV export sanitized against formula injection (
=,+,-,@,\t,\rprefixes are escaped)
Cookie security
Vote secrecy
The GET /api/meetings/[meetingId]/sessions endpoint conditionally includes or excludes deviceToken per vote based on the caller's auth status. Unauthorized callers (voters, public display) receive choice only. Moderators receive deviceToken as well, enabling the unique-voter count in the report. The individual choice → deviceToken mapping is never exposed publicly.
Infrastructure & Deployment
Vercel — Fluid Compute
Each Next.js API route handler deploys as an independent Vercel Function. Fluid Compute reuses function instances across concurrent requests — reducing cold starts compared to traditional one-request-per-instance serverless. The production domain is municipalvote.com aliased to the latest Vercel deployment.
Database — Neon PostgreSQL (two endpoints)
| Pooled endpoint | ep-silent-cloud-aqfjewjk-pooler.* — used by the running application via DATABASE_URL. Connection pooling handles concurrent serverless function connections. |
| Unpooled endpoint | ep-silent-cloud-aqfjewjk.* — used for migrations via DATABASE_URL_UNPOOLED. Direct connection required for DDL statements. |
| Migration strategy | Production DB was not initialized with Prisma migration tracking. Schema changes are applied as raw SQL via DATABASE_URL_UNPOOLED npx prisma db execute --stdin. Local dev uses the local Neon endpoint and prisma migrate dev. |
| Client initialization | PrismaPg adapter initialized with connectionString at runtime. Singleton on globalThis in dev to survive hot-reloads without exhausting the connection limit. |
Environment variables
| Variable | Required | Purpose |
|---|---|---|
DATABASE_URL | Yes | Neon pooled connection string (used by Prisma at runtime) |
DATABASE_URL_UNPOOLED | Migrations only | Direct Neon connection for DDL execution |
MOD_SECRET | Yes (prod) | HMAC signing key for mod tokens and join tokens |
MOD_PASSWORD | Yes (prod) | SHA-256 hex digest of the moderator password |
ANTHROPIC_API_KEY | For AI import | Claude API key for warrant article PDF parsing |
KV_REST_API_URL | For rate limiting | Upstash Redis endpoint (Vercel Marketplace KV format) |
KV_REST_API_TOKEN | For rate limiting | Upstash Redis auth token |
Room code generation
Cron job
GET /api/cron/deactivate-tokens is a Vercel cron route that runs on a schedule to deactivate voter tokens for meetings that have been closed. This prevents tokens from old meetings remaining claimable indefinitely.
AI Integration
Claude is used in exactly one place: parsing a town meeting warrant PDF into structured article data so the moderator doesn't have to enter each article manually.
Endpoint: POST /api/meetings/[meetingId]/import-ballot
The AI result is always shown to the moderator before anything is written to the database — it's a review step, not an autonomous import. The moderator can edit, reorder, or discard articles before confirming.
action="import" and the confirmed articles array) does the actual DB writes. This prevents a malformed Claude response from silently polluting the meeting's article list.
Testing
Test setup
- Vitest 4 — test runner and assertion library
- Prisma client is fully mocked —
vi.mock("@/lib/prisma")replaces all DB calls with jest-style mock functions. Tests never hit a real database. - Next.js
NextRequestconstructed inline — route handlers are called directly as async functions; no HTTP server is spun up - 50 test files, 707 tests — all passing
- Playwright configured for E2E against the running dev server
Key test patterns
Coverage areas
- Vote integrity pipeline — all 8 checks tested individually (join token, voter pass binding, geofence, duplicate, etc.)
- HMAC token sign/verify — valid tokens, expired tokens, tampered signatures, wrong meeting scope
- Geofence accuracy cap — boundary conditions at 150m accuracy, rejection above cap
- Amendment position shifting — sibling count, article number inheritance, parent-not-found path
- Reconsider flow — closed sessions cleared and reopened, conflict detection, meeting-adjourned guard
- Serializable transaction conflict paths — simulated via mock return values
- CSV export — formula injection escaping, special character handling
- Rate limiter — mock Redis responses, in-memory fallback behaviour