Every other doc in this project points here for "the exact config" — the cookie flags, the rate limiter thresholds, the requireAuth pattern, the audit checklist. This is that file. Treat it the same way as CODING_STANDARDS.md: if a Cursor output doesn't match what's defined here, reject it and re-prompt rather than patching around it.
This file was written after the fact — PROMPTS.md, ARCHITECTURE.md, CODING_STANDARDS.md, BUGS.md, and DATA_PRIVACY.md were all already referencing it before it existed. Everything below was reverse-engineered to match those references exactly, so nothing here should contradict a prompt you've already run. One exception is called out explicitly below (/api/usage/admin's dev-only check) — that was genuinely undefined anywhere else, so a reasonable default is proposed, not extracted.
EventFlow v1 is a single-merchant tool, not a multi-tenant SaaS product. The auth model reflects that scope deliberately. This section exists so nobody mistakes "appropriately scoped for a single trusted user" for "insecure."
In scope — this file defends against:
- An unauthenticated stranger on the internet reaching
/api/bookingsand reading real client names/phone numbers - Brute-forcing the single login password
- A secret (JWT signing key, password hash) leaking into git history or a log file
- A malformed or malicious request reaching the database unvalidated
- A server error leaking internal details (stack traces, file paths, query text) to the client in production
- SQL injection via booking form fields or inventory fields
Explicitly out of scope for v1 — see the "Not in v1's threat model" section at the bottom for the full list and why.
- One merchant, one password, no user accounts table. There is no concept of "which user" — only "authenticated" or not.
- Password is never stored in plaintext. A bcrypt hash lives in
MERCHANT_PASSWORD_HASH(env var, server-side only). - On successful login, the server issues a JWT in an httpOnly cookie — not returned in the response body, not stored in
localStorage, not accessible to client-side JS at all. This is the single biggest XSS mitigation in the whole app: even if malicious JS somehow ran in the merchant's browser, it cannot read the session token. - The JWT itself carries no sensitive payload — just an issued-at/expiry pair. There's nothing inside it worth protecting if decoded; the cookie's
httpOnlyflag is what actually matters, not the token contents. - Every route except
POST /api/auth/loginrequires a valid session. No exceptions, no "just for testing" bypasses left in committed code. This now explicitly includes the inventory routes added after the original auth build — see the Route auth matrix below, and don't assume a route is covered just because it "feels internal."
Do this once per environment (local, and again separately for each deploy target — never reuse a dev secret in production).
JWT_SECRET — long random string:
openssl rand -base64 32Paste the output directly into JWT_SECRET in .env. Never reuse this value between dev and prod.
MERCHANT_PASSWORD_HASH — bcrypt hash of the chosen password, generated locally so the plaintext password never touches a file, a commit, or a chat log:
# run from /server, so bcryptjs resolves
node -e "const bcrypt = require('bcryptjs'); console.log(bcrypt.hashSync('your-chosen-password', 10))"Copy the printed hash into MERCHANT_PASSWORD_HASH in .env. Do not put the plaintext password in .env, a code comment, a commit message, this file, BUGS.md, or anywhere else — including pasting it into Cursor or Claude while debugging. Only the merchant should know it; per DATA_PRIVACY.md, the developer should know the hash exists, never the plaintext.
ADMIN_ACCESS_KEY (see the /api/usage/admin section below) — same pattern:
openssl rand -base64 24const isProd = process.env.NODE_ENV === 'production'
res.cookie(process.env.SESSION_COOKIE_NAME, token, {
httpOnly: true,
secure: isProd, // NEVER hardcode true — breaks login on local http://localhost
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days, in milliseconds
})Same-origin via Vercel proxy (production): The browser never talks to the Render API directly. client/vercel.json rewrites /api/* to the Render backend, and VITE_API_URL=/api (in client/.env.production) makes all frontend fetch calls same-origin on the Vercel domain. The session cookie is therefore first-party — no cross-site cookie rules apply. This is more reliable than SameSite=None on mobile browsers (iOS Safari ITP, privacy-hardened Android browsers) that block third-party/cross-site cookies even when SameSite=None; Secure is set correctly; see BUGS.md #024.
Local development: Vite on localhost:5173 and Express on localhost:3001 differ only by port, which browsers treat as same-site, so sameSite: 'lax' works there too with VITE_API_URL=http://localhost:3001/api.
Why each flag matters:
httpOnly: true— client-side JS cannot read this cookie, even viadocument.cookie. Core XSS mitigation.secure: isProd— cookie only sent over HTTPS in production. Must be conditional, not alwaystrue— seeBUGS.md's "Auth cookie not persisting" gotcha: hardcodingtruesilently blocks the cookie from being set at all on localhttp://localhost, and the failure mode looks like "login works but you're immediately logged out," which is confusing to debug.sameSite: 'lax'— correct in both environments because API requests are same-origin from the browser's perspective (Vercel proxy in prod, localhost same-site in dev).'lax'is spec-reliable across desktop and mobile; do not use'none'.- 7-day expiry — long enough that a single merchant isn't re-logging-in constantly, short enough that a stale session doesn't live forever if the device is lost.
Cookie name comes from process.env.SESSION_COOKIE_NAME (set to eventflow_session per ARCHITECTURE.md's .env.example) — never hardcode the literal string in more than one place.
// middleware/requireAuth.js
import jwt from 'jsonwebtoken'
export const requireAuth = (req, res, next) => {
const token = req.cookies[process.env.SESSION_COOKIE_NAME]
if (!token) {
return res.status(401).json({ error: 'You must be logged in to do that.' })
}
try {
jwt.verify(token, process.env.JWT_SECRET)
next()
} catch (err) {
return res.status(401).json({ error: 'Your session has expired. Please log in again.' })
}
}Rules:
- Always
401, never403— there's no concept of "logged in but not allowed" in a single-merchant app, so don't introduce that distinction. - The two failure messages above ("must be logged in" vs "session has expired") are the only two cases — don't add more granular messages (e.g. "invalid token signature") that could hint at why verification failed. There's nothing to gain from that detail and no reason to expose JWT internals to a client.
- Never log the token value itself, even in dev-only debug logging.
// routes/auth.js
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { loginRateLimiter } from '../middleware/rateLimiter.js'
import { requireAuth } from '../middleware/requireAuth.js'
router.post('/login', loginRateLimiter, async (req, res, next) => {
try {
const { password } = req.body
if (!password) {
return res.status(400).json({ error: 'Password is required.' })
}
const isValid = await bcrypt.compare(password, process.env.MERCHANT_PASSWORD_HASH)
if (!isValid) {
return res.status(401).json({ error: 'Incorrect password.' })
}
const token = jwt.sign({}, process.env.JWT_SECRET, { expiresIn: '7d' })
const isProd = process.env.NODE_ENV === 'production'
res.cookie(process.env.SESSION_COOKIE_NAME, token, {
httpOnly: true,
secure: isProd,
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000,
})
res.status(200).json({ authenticated: true })
} catch (err) {
next(err)
}
})
router.post('/logout', (req, res) => {
res.clearCookie(process.env.SESSION_COOKIE_NAME)
res.status(200).json({ authenticated: false })
})
router.get('/me', requireAuth, (req, res) => {
res.status(200).json({ authenticated: true })
})Always bcrypt.compare — never a plain === string comparison, even though there's only one password to check. A direct comparison is also vulnerable to a timing attack in theory; bcrypt.compare already handles this correctly.
// middleware/rateLimiter.js
import rateLimit from 'express-rate-limit'
export const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
message: { error: 'Too many login attempts. Please try again in 15 minutes.' },
standardHeaders: true,
legacyHeaders: false,
})
export const bookingCreateRateLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 20,
message: { error: 'Too many requests. Please slow down.' },
standardHeaders: true,
legacyHeaders: false,
})loginRateLimiter goes on POST /api/auth/login only. bookingCreateRateLimiter goes on POST /api/bookings only — high enough that a real merchant double-clicking submit never hits it, low enough to blunt a scripted abuse attempt. Per BUGS.md: confirm this is actually wired into the route, not just defined and imported nowhere — that exact gap is a known gotcha to check before every deploy.
Inventory and booking-edit routes are not separately rate limited — they're low-volume, manually-triggered actions for a single merchant, and adding limiters there would be solving a problem that doesn't exist at this scale. Don't add them speculatively.
Cross-check against this table any time a new route is added — every row except login needs both columns checked.
| Route | requireAuth? |
Rate limited? |
|---|---|---|
POST /api/auth/login |
No | Yes — loginRateLimiter |
POST /api/auth/logout |
Yes | No |
GET /api/auth/me |
Yes | No |
GET /api/bookings |
Yes | No |
POST /api/bookings |
Yes | Yes — bookingCreateRateLimiter |
PATCH /api/bookings/:id |
Yes | No |
PATCH /api/bookings/:id/status |
Yes | No |
GET /api/bookings/:id |
Yes | No |
PATCH /api/bookings/:id/log |
Yes | No |
POST /api/bookings/:id/expenses |
Yes | No |
DELETE /api/bookings/:id/expenses/:expenseId |
Yes | No |
DELETE /api/bookings/:id |
Yes | No |
GET /api/bookings/check-conflict |
Yes | No |
GET /api/bookings/pending-balances |
Yes | No |
GET /api/analytics/summary |
Yes | No |
GET /api/analytics/monthly |
Yes | No |
GET /api/usage/me |
Yes | No |
POST /api/usage/log-client-action |
Yes | No |
GET /api/usage/admin |
Yes + requireAdminKey (below) |
No |
GET /api/inventory |
Yes | No |
POST /api/inventory |
Yes | No |
PATCH /api/inventory/:id |
Yes | No |
POST /api/inventory/:id/damage |
Yes | No |
POST /api/inventory/check-availability |
Yes | No |
GET /api/settings |
Yes | No |
PATCH /api/settings |
Yes | No |
GET /api/reports/range |
Yes | No |
GET /api/records |
Yes | No |
Note: this section is a proposed default, not something extracted from an existing file. ARCHITECTURE.md references "a separate dev-only check, see SECURITY.md" but no other doc defines what that check actually is. This is one reasonable way to do it for a single-merchant app with no separate developer account; adjust if you had something else in mind.
Since there's no multi-user system to grant a "developer" role, the simplest correct approach is a second shared secret — known only to you, never the merchant, and never referenced from any client-side code (it must never be called from UsageView.jsx, which only ever hits /api/usage/me).
// middleware/requireAdminKey.js
export const requireAdminKey = (req, res, next) => {
const key = req.headers['x-admin-key']
if (!key || key !== process.env.ADMIN_ACCESS_KEY) {
return res.status(401).json({ error: 'Not authorized.' })
}
next()
}// routes/usage.js
router.get('/admin', requireAuth, requireAdminKey, async (req, res, next) => {
// ...
})You'd call this with the key set as a header — via Postman, curl, or a small script, never from the deployed frontend:
curl -H "x-admin-key: YOUR_KEY" https://your-api-url/api/usage/adminAdd ADMIN_ACCESS_KEY to server/.env and server/.env.example (placeholder only in the latter) alongside the other env vars listed in ARCHITECTURE.md.
Exact pattern (mirrors CODING_STANDARDS.md, repeated here because it's a security control, not just a style rule):
// middleware/errorHandler.js
export const errorHandler = (err, req, res, next) => {
console.error(err) // server-side log always has full detail
const isProd = process.env.NODE_ENV === 'production'
res.status(err.status || 500).json({
error: isProd ? 'Something went wrong. Please try again.' : err.message,
})
}This must be the last middleware mounted in index.js, after every route. Test it deliberately before every deploy by throwing an intentional error and confirming the production response body contains no stack trace, no file path, and no raw err.message.
Client-side zod validation (CODING_STANDARDS.md) is UX only — instant feedback, nothing more. It can be bypassed entirely by anyone hitting the API directly (Postman, curl, browser devtools). The server-side validate(schema) middleware is what actually protects the database. This applies equally to the new inventory schemas (inventory.schema.js) — an item name or quantity coming from the client is no more trustworthy than a booking field.
Known gotcha (from BUGS.md): the most common way this silently breaks is validate(schema) being added to a route's middleware chain but placed after the route handler instead of before it. Express runs middleware in array order — always:
router.post('/', requireAuth, validate(bookingSchema), async (req, res, next) => { ... })never:
router.post('/', requireAuth, async (req, res, next) => { ... }, validate(bookingSchema))Every query uses better-sqlite3's parameterized .prepare() with ? placeholders. No exceptions, no string concatenation or template literals with variables in SQL — including aggregate queries for analytics and the per-date inventory availability calculation. See CODING_STANDARDS.md for the exact pattern; this file just states it's a hard security requirement, not a style preference.
.envis gitignored in bothclient/andserver/..env.exampleships with placeholder values only — never a real secret, ever, even temporarily.- Before every commit, scan the diff for
password,secret,key,tokenwith a real value next to them instead ofprocess.env.X. Stop and fix before committing if found. - If a secret is ever accidentally committed, it must be rotated (changed to a new value) — not just removed in a later commit. Git history retains the old value forever otherwise. Rotating means: generate a new
JWT_SECRET/MERCHANT_PASSWORD_HASH/ADMIN_ACCESS_KEY, update it everywhere it's deployed, and treat the old value as permanently compromised. - Per
BUGS.md: ifJWT_SECRETorMERCHANT_PASSWORD_HASHever appears in aconsole.logduring debugging, search the whole codebase for that log line and remove it before the next commit — not just before deploy. - Per
DATA_PRIVACY.md: never paste real merchant secrets, or real client data, into Claude, Cursor, ChatGPT, or any AI tool — including this conversation.
app.use(cors({
origin: process.env.CORS_ORIGIN,
credentials: true, // required for the httpOnly auth cookie to be sent cross-origin
}))Must be mounted before routes in index.js. credentials: true is required because the session lives in a cookie, not a header.
In production the browser only hits the Vercel frontend origin; Vercel proxies /api/* to Render server-to-server, so the merchant's browser is not making cross-origin requests to Render. CORS on Render remains defense in depth: CORS_ORIGIN should still be the exact deployed frontend URL — never * once real client data is in scope (a wildcard origin combined with credentials: true is invalid per the CORS spec anyway). Direct calls to the Render URL (e.g. Postman, curl) are a separate path and do not affect the merchant's browser session.
- Vercel and Render both provision HTTPS automatically on their default domains — no extra config needed for the typical deploy path described in
ARCHITECTURE.md/DEPLOYMENT.md. - After deploy, confirm in a real browser (not just
curl) that there are no mixed-content warnings and the padlock icon shows a valid cert. - The
secure: truecookie flag (active automatically onceNODE_ENV=production) depends entirely on the connection actually being HTTPS — if you ever deploy somewhere that doesn't terminate TLS for you, login will silently fail in the same way as the local-secure:truegotcha above.
This is the checklist PROMPTS.md's Prompt 12 refers to. Run through all of it, not just the items that feel relevant to whatever you just built.
- Every route except
POST /api/auth/loginhasrequireAuthin its middleware chain — including newer routes (analytics, usage, inventory, and the booking-editPATCHroute) that are easy to forget once the original auth prompt is a few milestones back -
errorHandler.jsnever returnserr.messageor a stack trace whenNODE_ENV === 'production'— verified by deliberately throwing an error and checking the response - No
console.loganywhere includesreq.body, a password, a token, or a full booking object (phone numbers count as sensitive here too) - Every SQL query uses parameterized
.prepare()— no string concatenation, anywhere, including the inventory availability aggregate queries -
.envis confirmed not tracked by git (git statusshould not list it);.env.examplecontains only placeholders -
npm auditrun in both/clientand/server; any High/Critical findings fixed, not just noted -
loginRateLimiteris actually applied to the login route, not just defined and imported nowhere -
event_log.metadatanever containsclient_nameorphone— spot-check a few real rows, not just the schema (perDATA_PRIVACY.md) - Logged-out session can't read booking OR inventory data even by guessing API URLs (
/api/bookings,/api/analytics/summary,/api/usage/admin,/api/inventoryall return401) -
BUGS.mdupdated with anything found during this pass, severity rated honestly — a "minor" auth gap is still High by default per that file's own rule
Pre-deploy additions (on top of all of the above, run against the live URL — not localhost):
- Cookie
secureflag is actually working in the deployed environment — log in on the live URL and confirm the session persists across a refresh - HTTPS active with no browser security warnings
-
CORS_ORIGINset to the real deployed frontend URL, notlocalhost - A logged-out session genuinely cannot see any booking or inventory data on the live URL, not just locally
- No multi-factor authentication. Single merchant, single password, low-stakes POC scope. Self-service password reset and any auth upgrades are deferred to v1.5 per
ROADMAP.md— don't build them early. - No defense against the merchant's own device being compromised (keylogger, shoulder-surfing, shared device). That's a physical/device-security problem for the merchant, not something this app's auth layer can solve — flag it in the plain-language conversation described in
DATA_PRIVACY.md, don't try to engineer around it. - No protection against the developer themselves. You have direct database access during build and via the hosting dashboards.
DATA_PRIVACY.mdcovers the transparency obligation that comes with that access — this file is about external threats, not insider trust, which is a different problem with a different (non-technical) solution at this scale. - No DDoS protection beyond basic rate limiting.
loginRateLimiterandbookingCreateRateLimiterstop scripted brute-forcing and accidental abuse; they are not a substitute for platform-level protection (e.g. Cloudflare) at real scale. Not worth building further for a single-merchant POC. - No multi-tenant data isolation. There's only one merchant, so there's nothing to isolate yet. This entire file would need a substantial rewrite before onboarding a second merchant —
ROADMAP.md's v5+ section already flags this explicitly; don't try to half-build multi-tenancy into v1's auth model. - No race-condition-proof inventory locking. Like the existing date-conflict check, the inventory availability check is select-then-decide, not atomic. For a single merchant typing manually this is a low-probability, low-stakes bug — see
BUGS.md.
| File | Purpose |
|---|---|
ARCHITECTURE.md |
Tech stack, route list, env var names |
CODING_STANDARDS.md |
General code conventions, error handler and validation patterns in full |
BUGS.md |
Known security-relevant gotchas, bug severity definitions |
DATA_PRIVACY.md |
DPDP Act context, what data is collected and why, developer-access transparency |
TESTING.md |
Manual test steps that verify the controls in this file actually work |
PROMPTS.md |
Prompt 2 (auth build) and Prompt 12 (hardening pass) both implement this file directly |