From ee5cd2a9461a592ffc17bef2f6fca4d8aa65191b Mon Sep 17 00:00:00 2001 From: lakshit verma Date: Mon, 17 Aug 2026 01:31:48 +0530 Subject: [PATCH] docs: add technical specification and plain-language readme --- README.md | 81 +++++++++ SPEC.md | 506 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 587 insertions(+) create mode 100644 README.md create mode 100644 SPEC.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..787ccd3 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# Work Order Desk + +Work Order Desk is a web application for field service teams. A technician logs a job, tracks it, and closes it. A dispatcher sees every job on the team. The application is built with MongoDB, Express, React, and Node.js. + +A work order is a job to do in the field. Each work order has a title, a description, a status, and a priority. The status is `pending`, `in_progress`, or `done`. The priority is `low`, `medium`, or `high`. + +## Roles + +- A technician is a user. A technician owns their work orders. +- A dispatcher is an admin. An admin manages users and sees every work order. +- A visitor can register, log in, and reset a password. + +## Auth and security + +- The app uses cookies for login. The cookies are httpOnly. JavaScript cannot read them. +- The app rotates refresh tokens. It detects reused tokens and revokes the token family. +- The app stores passwords with bcrypt at cost 12. +- The API uses a closed error catalog. +- Every request has a request ID. + +## Tech stack + +| Layer | Technology | +|---|---| +| Runtime | Node.js 20 LTS | +| Language | TypeScript 5.x | +| API | Express 4.x | +| Database | MongoDB 7.x with Mongoose 8.x | +| Frontend | React 18 with Vite 5 and Tailwind CSS 3 | +| Server state | TanStack Query 5 | +| Validation | zod 3 | +| Tests | Vitest, Supertest, Testing Library | +| Containers | Docker and Compose | +| Tooling | ESLint 9, Prettier 3, npm workspaces | + +## Run with Docker + +1. Copy the environment template. Run `cp .env.example .env`. +2. Start the stack. Run `docker compose up --build`. +3. Open http://localhost:5173. +4. Log in as `admin@example.com` with `Admin1234`. + +You can also log in as `user@example.com` with `User1234`. + +## Run without Docker + +1. Copy the environment template. Run `cp .env.example .env`. +2. Install packages. Run `npm install`. +3. Create the seed users. Run `npm run seed`. +4. Add demo data. Run `npm run seed:demo`. +5. Start the app. Run `npm run dev`. + +The backend runs on port 4000. The frontend runs on port 5173. + +The seed command creates an admin user and a user. The demo command adds technicians and work orders. The demo data makes the app look lived in. + +## Checks + +Run these commands before you push a change. + +``` +npm run lint +npm run typecheck +npm test +npm run build +``` + +CI runs these checks on every push. CI also runs coverage gates. + +## Project structure + +- `backend`: the Express API. It uses a layered architecture. +- `frontend`: the React application. +- `packages/shared`: the shared zod schemas and TypeScript types. +- `SPEC.md`: the technical specification. + +## Environment variables + +Copy `.env.example` to `.env`. The `.env` file is ignored by git. Never commit real secrets. Generate secrets with `openssl rand -hex 32`. + +The app reads the `.env` file from the backend workspace or from the repo root. Docker Compose reads the root `.env` file. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..7063573 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,506 @@ +# Field Service Work Order Management — Technical Specification + +**Version:** 1.2 +**Status:** Implementation contract +**Last Updated:** 2026-08-16 + +Incorporates findings from two adversarial reviews and two design reviews of v1.0 Draft. v1.2 rebrands the generic item model as work orders for field service and maintenance teams. + +## 1. Overview + +### 1.1 Purpose + +A production-oriented MERN (MongoDB, Express, React, Node.js) application purpose-built as a **work-order management system** for field service and maintenance teams. Technicians log jobs, track them through status and priority, and close them out; a dispatcher (admin) oversees every work order and the team. It keeps the production-grade baseline — layered architecture, cookie-based auth, typed data layer — and is not the smallest possible todo app. + +### 1.2 Goals + +- Working, deployable app out of the box (seeded admin + user). +- Domain-shaped work-order model: status/priority lifecycle instead of generic items. +- Layered backend and feature-sliced frontend. +- JWT in httpOnly cookies, refresh rotation with family reuse detection. +- Typed frontend data layer (wrapped `fetch` + TanStack Query). +- Unit + integration tests with coverage gates. +- Reproducible local + Docker setup. +- Structured logging, validation, and a closed error catalog. + +### 1.3 Non-Goals (v1) + +- Real-time (WebSockets). +- Multi-tenant / org isolation. +- Payments. +- File uploads / object storage (no stub interface). +- Internationalization (en-US only). +- Audit-log product UI (removed from admin persona). +- Work-order reassignment or scheduling: the creator owns a work order in v1 (dispatcher reassignment is a v2 concern). +- Multi-document Mongo transactions (standalone Mongo). +- CSRF tokens (same-origin deployment; see §8.5). + +### 1.4 Personas + +| Persona | Role | Primary needs | +|---|---|---| +| Dispatcher (admin) | Full access | Manage users and roles, oversee and search all work orders, view metrics | +| Technician (user) | Standard user | Log, update, and close own work orders; manage profile | +| Anonymous visitor | Unauthenticated | Register, login, request/complete password reset | + +## 2. Key Decisions + +| Decision | Options considered | Choice | Why | +|---|---|---|---| +| Auth transport | Bearer in memory vs httpOnly cookies | httpOnly cookies + SameSite=Lax | XSS cannot read tokens; see ADR-0001 | +| Deploy topology | Split origins vs reverse-proxy same origin | Same origin: Vite proxy (dev), nginx (prod/compose) | Lax cookies work; CSRF tokens out of scope | +| HTTP client | fetch vs axios | Wrapped `fetch` | No extra dep; tests mock `fetch` | +| Lockfile | npm vs yarn | `package-lock.json` only | npm workspaces default | +| Monorepo | workspaces vs turborepo | npm workspaces | Simpler for two packages + `packages/shared` | +| Schemas | duplicate vs shared package | `packages/shared` zod + types | Prevents envelope/DTO drift | +| Pagination | cursor everywhere vs mixed | Cursor on `/work-orders` and `/admin/work-orders`; offset on `/admin/users` | Users table is small; work orders follow NFR-2 | +| Admin all work orders | `scope=` vs dedicated route | No `scope`. `/work-orders` is owner-only. `/admin/work-orders` is global | Removes list IDOR | +| Refresh storage | embed on User vs collection | `RefreshSession` collection + `familyId` | Multi-device + reuse ⇒ revoke family | +| Refresh signing | opaque vs JWT | Opaque 32-byte, SHA-256 at rest | Drop `REFRESH_SECRET` | +| First admin | first-register vs seed | Idempotent `npm run seed` | Deterministic local/CI credentials | +| User disable | `isActive` vs `deletedAt` vs both | `isActive` only on User | One state machine | +| Work order delete | hard vs soft | Soft `deletedAt`; GET 404; lists exclude | Matches WO-7 | +| Mongo txns | replica set vs none | None in v1 | Compose Mongo is standalone | +| Perf SLO | p95 < 200 ms @ 1k conc. | Dropped from v1 DoD | bcrypt 12 cannot meet it; untestable | +| Register enumeration | hide vs 409 | `409 EMAIL_TAKEN` | Starter documents this leak | +| Lockout status | 423 vs 429 vs generic 401 | Generic `401 AUTH_GENERIC` | Avoids lockout oracle | +| HTTP access JWT role | trust JWT role vs DB | Load user every request; authorize from DB | Demotion/deactivation take effect immediately | + +ADRs: `docs/adr/0001-jwt-httpOnly-cookies.md`, `docs/adr/0002-npm-workspaces.md`. + +## 3. Tech Stack + +| Layer | Technology | Version | +|---|---|---| +| Runtime | Node.js | 20 LTS | +| Language | TypeScript | 5.x | +| API | Express | 4.x | +| Database | MongoDB | 7.x | +| ODM | Mongoose | 8.x | +| Frontend | React | 18.x | +| Build | Vite | 5.x | +| Routing | React Router | 6.x | +| Server state | TanStack Query | 5.x | +| HTTP | wrapped `fetch` | — | +| CSS | Tailwind CSS | 3.x | +| Validation | zod (in `packages/shared`) | 3.x | +| Auth | JWT HS256 + httpOnly cookies | — | +| Passwords | bcrypt cost 12 | 5.x | +| Tests | Vitest + Supertest + Testing Library | 2.x / 7.x / 14.x | +| Logs | pino | 9.x | +| Containers | Docker + compose | 24.x / 2.x | +| Lint / format | ESLint 9 flat + Prettier 3 | | + +Pin exact versions in the root `package-lock.json`. Do not commit `yarn.lock`. + +## 4. Architecture + +``` +Browser (one origin) + Vite :5173 ──proxy /api,/health,/ready──► Express :4000 ──► MongoDB :27017 + nginx :80 ──same paths──────────────────► Express :4000 ──► MongoDB +``` + +### 4.1 Backend dependency direction + +`routes → middleware → controllers → services → repositories → models` + +- Controllers map HTTP ↔ DTO. No business rules, no Mongoose. +- Services take an `Actor { id, role }` (never `req`/`res`). +- Repositories own queries. Default work-order reads add `deletedAt: null`. +- Models export schemas + TS types. Only repositories import `mongoose` at runtime. +- `import/no-cycle` is required. v1 does **not** use multi-document transactions. + +### 4.2 Middleware order (`app.ts`) + +1. `trust proxy` from `TRUST_PROXY_HOPS` (default 0) +2. request-id (`X-Request-Id`, generate UUIDv4 if missing/invalid) +3. pino-http (redact cookies/passwords/hashes) +4. helmet (API JSON CSP: `default-src 'none'`) +5. compression +6. cors (`origin` = `CORS_ORIGIN` list, `credentials: true`; reject `*` if credentials) +7. cookie-parser (`COOKIE_SECRET`) +8. json (`limit: 32kb`) — no `urlencoded` (blocks HTML-form CSRF) +9. hpp +10. recursive `$` / `__proto__` / `constructor` rejection on body + query +11. global rate limit +12. routes +13. 404 handler +14. error middleware + +### 4.3 Frontend layering + +`pages → features/{auth,workOrders,admin,profile} → components (primitives) → api/client → lib` + +- Query hooks live in `features/*/queries.ts`, not in `api/`. +- React Router loaders are not used. TanStack Query owns server state. +- `ProtectedRoute` / `AdminRoute` are UX only; API is the authority. +- Bootstrap: `GET /users/me`; 401 → anonymous. 401 interceptor single-flights `POST /auth/refresh` then retries once. + +## 5. Functional Requirements + +### 5.1 Authentication & authorization + +| ID | Requirement | +|---|---| +| AUTH-1 | Register `{ email, password, name }`. Email trim+lowercase. Name 1–80 chars. Password 8–72 chars, ≥1 letter, ≥1 number. Default `role=user`, `isActive=true`. Duplicate email → `409 EMAIL_TAKEN`. Success `201` + set session cookies (auto-login). | +| AUTH-2 | Login `{ email, password }`. Unknown email, bad password, inactive user → `401 AUTH_GENERIC` after dummy bcrypt. Success `200` + cookies, set `lastLoginAt`, reset `failedLoginCount`. | +| AUTH-3 | Access cookie `access_token`: JWT HS256, claims `sub`, `role`, `sid`, `iss`, `aud`, `iat`, `exp` (15m). httpOnly, SameSite=Lax, Secure iff `NODE_ENV=production`, `Path=/`, `Max-Age=900`. | +| AUTH-4 | Refresh cookie `refresh_token`: opaque 32 bytes, SHA-256 stored on `RefreshSession`, 7-day TTL, `Path=/api/v1/auth`, same flags as access, `Max-Age=604800`. Rotation keeps `familyId`. Reuse of a used/revoked token outside a 10s grace window revokes the **family** and returns `401 REFRESH_REUSE`. Multi-device = many families. | +| AUTH-5 | `authenticate` verifies JWT, loads user by `sub`, rejects missing/inactive, attaches `Actor` from **DB** (`id`, `role`). Invalid ObjectId params → `400 VALIDATION_ERROR`. | +| AUTH-6 | `POST /auth/logout` revokes the current session (`sid` or presented refresh). Clears both cookies (`Max-Age=0`). `POST /auth/logout-all` revokes every family for the user. | +| AUTH-7 | Forgot-password always `200` + generic message. Token: 32 random bytes, SHA-256 at rest, TTL 1h, replaces previous. MVP mailer logs a **redacted** URL (token replaced with `[redacted]`). Reset page `/reset-password?token=`. Successful reset revokes all families, clears reset fields, sets cookies. | +| AUTH-8 | Profile name via PROFILE-2. Password change via PROFILE-3 (same password rules). | +| AUTH-9 | Roles `admin` \| `user`. Only admin can change roles. Cannot target self. Cannot demote the last remaining admin (count admins, reject). Reload DB role (AUTH-5). Demotion revokes that user’s refresh families. | +| AUTH-10 | Owner/ids come from `Actor`, never from the body. Write schemas `.strict()`. | +| AUTH-11 | After **5** failed logins for an **existing** user within 15 minutes, set `lockedUntil = now+15m`. Unknown emails do not increment. Reset counter on success. Locked users still receive `401 AUTH_GENERIC`. IP limiter is separate (`429 RATE_LIMITED`). | + +### 5.2 Work orders + +A work order is a job logged by a technician and tracked from request to completion. The creator is the owner; dispatchers see everything via `/admin/work-orders`. + +| ID | Requirement | +|---|---| +| WO-1 | Fields: `title` (job summary) 3–100, `description` optional ≤2000 (job details/notes), `priority` `low`\|`medium`\|`high` (default `medium`), `status` `pending`\|`in_progress`\|`done` (default `pending`), `owner`, `version` (starts at 1), `createdAt`, `updatedAt`, `deletedAt?`. | +| WO-2 | `GET /work-orders` returns **only** `owner = actor.id`. No `scope` param. | +| WO-3 | Cursor pagination: default `limit=20`, max `100`. Sort `createdAt desc, _id desc`. Cursor = HMAC-SHA256(payload, `COOKIE_SECRET`) + payload `{ createdAt, id }`. Tamper → `400`. Last page: `nextCursor: null`. | +| WO-4 | Filter `status` and `priority`. Search `title` regex-escaped, case-insensitive, max 64 chars. | +| WO-5 | Create → `201` full `WorkOrderPublic`. `version=1`. Owner = actor. | +| WO-6 | Update owner or admin. `version` **required**. Stale → `409 CONFLICT_VERSION`. Predicate `{ _id, version, deletedAt: null }` then `$inc.version`. | +| WO-7 | Soft delete owner or admin. `version` **required**. `204` empty body. Already deleted → `204` (idempotent). GET-by-id and lists hide `deletedAt != null`. No undelete. | +| WO-8 | Zod `.strict()` on writes before services. | +| WO-9 | See WO-6/7. Create `version=1`. Custom field, mongoose `versionKey: false`. | +| WO-10 | `GET /work-orders/:id` owner or admin; anyone else or deleted → `404`. Invalid id → `400`. | + +### 5.3 Admin + +| ID | Requirement | +|---|---| +| ADM-1 | `GET /admin/users` admin only. Offset page, default 1, limit 20 max 100. Search name+email, escaped, max 64. Filter `role`. | +| ADM-2 | `PATCH /admin/users/:id/role` `{ role }` strict enum. Not self. Not last admin. | +| ADM-3 | `GET /admin/work-orders` all non-deleted work orders, cursor pagination. | +| ADM-4 | `GET /health` public `{ status: "ok" }`, no internals, excluded from rate limit. `GET /ready` public, pings Mongo, `503` if down. `GET /admin/metrics` admin `{ users, workOrders, uptimeSeconds }`. | +| ADM-5 | `PATCH /admin/users/:id/status` `{ isActive }`. Not self. Deactivate revokes that user’s families. | + +### 5.4 Profile + +| ID | Requirement | +|---|---| +| PROFILE-1 | `GET /users/me` → `UserPublic`. | +| PROFILE-2 | `PATCH /users/me` `{ name }` only. | +| PROFILE-3 | `POST /users/me/password` `{ currentPassword, newPassword }`. Wrong current → `401 AUTH_GENERIC`. Success revokes other families, rotates current session, sets cookies. | + +### 5.5 Frontend routes + +| Route | Access | Notes | +|---|---|---| +| `/` | Public | Landing | +| `/login`, `/register` | Public | Redirect to `/app` if already authed | +| `/forgot-password` | Public | Request form | +| `/reset-password` | Public | Token from `?token=` | +| `/app` | Protected | Redirects to `/app/work-orders` | +| `/app/work-orders` | Protected | List + filter (status/priority) + search + cursor | +| `/app/work-orders/new` | Protected | Create | +| `/app/work-orders/:id` | Protected | Detail / edit / delete; 409 reload | +| `/app/profile` | Protected | Name + password | +| `/app/admin` | Admin | Users, role, status | +| `*` | — | 404 page | + +UI gates are not authorization. + +## 6. API Contract + +**Public (unversioned):** `GET /health`, `GET /ready` + +**App base:** `/api/v1` + +Auth cookies: `access_token`, `refresh_token` (see AUTH-3/4). + +### 6.1 Envelopes + +```ts +type SuccessEnvelope = { success: true; data: T }; +type ErrorEnvelope = { + success: false; + error: { code: ErrorCode; message: string; details?: { field: string; message: string }[] }; + requestId: string; +}; +type ErrorCode = + | 'VALIDATION_ERROR' | 'UNAUTHORIZED' | 'FORBIDDEN' | 'NOT_FOUND' + | 'CONFLICT_VERSION' | 'RATE_LIMITED' | 'ACCOUNT_LOCKED' + | 'AUTH_GENERIC' | 'EMAIL_TAKEN' | 'REFRESH_REUSE' | 'INTERNAL'; +``` + +`204` has no body. Auth failures that must not distinguish cause use `AUTH_GENERIC` + 401. `ACCOUNT_LOCKED` is reserved but unused on the wire in v1 (lockout still enforced). + +### 6.2 Status map + +| HTTP | Code | When | +|---|---|---| +| 200/201 | — | Success envelope | +| 204 | — | Soft delete | +| 400 | VALIDATION_ERROR | Zod, bad ObjectId, bad cursor, search too long | +| 401 | UNAUTHORIZED | Missing/invalid/expired access token | +| 401 | AUTH_GENERIC | Login/password failures, inactive, locked | +| 401 | REFRESH_REUSE | Reused refresh family revoked | +| 403 | FORBIDDEN | Authenticated but not permitted (incl. last-admin, self-role) | +| 404 | NOT_FOUND | Missing or hidden work order/user | +| 409 | CONFLICT_VERSION | Stale work order `version` | +| 409 | EMAIL_TAKEN | Register duplicate | +| 429 | RATE_LIMITED | IP / route limiter | +| 500 | INTERNAL | Unexpected; no stack unless `DEBUG_ERRORS=true` | +| 503 | INTERNAL | `/ready` when Mongo down (body `{ status: "degraded" }`) | + +### 6.3 DTOs + +```ts +type UserPublic = { id: string; email: string; name: string; role: 'admin' | 'user'; createdAt: string; updatedAt: string }; +type UserAdmin = UserPublic & { isActive: boolean; lastLoginAt: string | null }; +type WorkOrderPublic = { + id: string; title: string; description: string | null; + priority: 'low' | 'medium' | 'high'; + status: 'pending' | 'in_progress' | 'done'; + owner: { id: string; name: string; email: string }; + version: number; createdAt: string; updatedAt: string; +}; +type CursorPage = { items: T[]; nextCursor: string | null }; +type OffsetPage = { items: T[]; page: number; limit: number; total: number }; +``` + +Never return `passwordHash`, reset/session hashes, `failedLoginCount`, or `lockedUntil`. + +### 6.4 Endpoints + +``` +POST /api/v1/auth/register { email, password, name } → 201 UserPublic +POST /api/v1/auth/login { email, password } → 200 UserPublic +POST /api/v1/auth/logout (auth or refresh cookie) → 204 +POST /api/v1/auth/logout-all (auth) → 204 +POST /api/v1/auth/refresh (refresh cookie) → 200 UserPublic +POST /api/v1/auth/forgot-password { email } → 200 { ok: true } +POST /api/v1/auth/reset-password { token, password } → 200 UserPublic + +GET /api/v1/users/me → 200 UserPublic +PATCH /api/v1/users/me { name } → 200 UserPublic +POST /api/v1/users/me/password { currentPassword, newPassword } → 200 UserPublic + +GET /api/v1/work-orders ?cursor&limit&status&priority&search → 200 CursorPage +POST /api/v1/work-orders { title, description?, priority?, status? } → 201 WorkOrderPublic +GET /api/v1/work-orders/:id → 200 WorkOrderPublic +PATCH /api/v1/work-orders/:id { title?, description?, priority?, status?, version } +DELETE /api/v1/work-orders/:id { version } (JSON body) → 204 + +GET /api/v1/admin/users ?page&limit&role&search → 200 OffsetPage +PATCH /api/v1/admin/users/:id/role { role } → 200 UserAdmin +PATCH /api/v1/admin/users/:id/status { isActive } → 200 UserAdmin +GET /api/v1/admin/work-orders ?cursor&limit&status&priority&search → 200 CursorPage +GET /api/v1/admin/metrics → 200 { users, workOrders, uptimeSeconds } + +GET /health → 200 { status: "ok" } +GET /ready → 200 { status: "ok" } | 503 { status: "degraded" } +``` + +## 7. Data Model + +### User + +``` +email string unique, lowercase, indexed +passwordHash string +name string +role 'admin' | 'user' +isActive boolean +lastLoginAt Date? +failedLoginCount number +lockedUntil Date? +passwordReset { tokenHash?: string, expiresAt?: Date } +createdAt Date +updatedAt Date +``` + +No `deletedAt` on User. + +### WorkOrder + +``` +title, description?, priority, status, owner, version, createdAt, updatedAt, deletedAt? +``` + +### RefreshSession + +``` +userId, familyId, tokenHash, usedAt?, revokedAt?, expiresAt, createdAt, userAgent?, ip? +``` + +Indexes: + +- User: unique `{ email: 1 }`, `{ role: 1 }` +- WorkOrder: `{ owner: 1, deletedAt: 1, createdAt: -1, _id: -1 }`, `{ owner: 1, status: 1, deletedAt: 1 }`, `{ owner: 1, priority: 1, deletedAt: 1 }` +- RefreshSession: unique `{ tokenHash: 1 }`, `{ familyId: 1 }`, `{ userId: 1 }`, TTL on `expiresAt` + +## 8. Security + +### 8.1 Passwords + +bcrypt cost 12 via `bcrypt.compare`. Dummy hash for unknown emails. Cap length 72 in zod. Never log passwords or raw tokens. + +### 8.2 Tokens + +Access JWT: HS256, `iss=mern-starter`, `aud=mern-starter-api`, `JWT_SECRET` min 32 chars, reject placeholders (`secret`, `changeme`, `replace-me`). Rotation of `JWT_SECRET` invalidates access tokens; refresh sessions remain valid. + +### 8.3 Cookies + +| Name | Path | Max-Age | Flags | +|---|---|---|---| +| `access_token` | `/` | 900 | httpOnly, SameSite=Lax, Secure in prod, host-only | +| `refresh_token` | `/api/v1/auth` | 604800 | same | + +`COOKIE_SECRET` signs cookie-parser cookies (integrity only). It is not authentication. + +### 8.4 Rate limits + +| Scope | Key | Window | Max | Env | +|---|---|---|---|---| +| Global (except /health /ready) | IP | 60s | 300 | `RATE_LIMIT_WINDOW_MS`, `RATE_LIMIT_MAX` | +| `/auth/login` | IP | 15m | 10 | `RATE_LIMIT_LOGIN_MAX` | +| `/auth/forgot-password` | IP + email | 15m | 3 | `RATE_LIMIT_FORGOT_MAX` | +| `/auth/register` | IP | 15m | 10 | (login max) | + +`trust proxy` hops from `TRUST_PROXY_HOPS`. In-process store (single instance). + +### 8.5 CSRF / CORS / fetch + +Same-origin only. CORS allowlist = `CORS_ORIGIN` (comma-separated), `credentials: true`. Frontend `credentials: 'include'`. CSRF tokens **out of scope**. Do not set cookie `Domain`. Do not enable `urlencoded`. + +### 8.6 Injection + +Recursive reject `$`/`__proto__`/`constructor` on body and query. Escape regex. HMAC cursors (never trust `owner` from cursor). Validate ObjectIds. Mongoose `sanitizeFilter` on user filters. + +## 9. Project Structure + +``` +mern-starter-app/ +├── package.json # workspaces: backend, frontend, packages/shared +├── package-lock.json +├── eslint.config.js +├── prettier.config.js +├── docker-compose.yml # dev: mongo + api + vite +├── docker-compose.prod.yml # mongo + api + nginx +├── nginx/nginx.conf +├── .github/workflows/ci.yml +├── .env.example +├── docs/SPEC.md +├── docs/GETTING_STARTED.md +├── docs/adr/ +├── packages/shared/ +├── backend/ +│ ├── Dockerfile +│ ├── src/{config,models,repositories,services,controllers,routes,middleware,utils,schemas,app.ts,server.ts} +│ ├── src/scripts/seed.ts +│ └── tests/ +└── frontend/ + ├── Dockerfile + └── src/{api,components,features,hooks,lib,pages,main.tsx} +``` + +## 10. Environment + +Validated at boot with zod; exit 1 on failure. + +| Key | Required | Notes | +|---|---|---| +| `PORT` | no | default 4000 | +| `NODE_ENV` | no | development \| test \| production | +| `MONGODB_URI` | yes | | +| `JWT_SECRET` | yes | min 32, not a placeholder | +| `COOKIE_SECRET` | yes | min 32 | +| `CORS_ORIGIN` | yes | comma-separated origins | +| `APP_URL` | yes | public SPA origin (reset links) | +| `SMTP_URL` | no | unused in MVP mailer | +| `TRUST_PROXY_HOPS` | no | default 0 | +| `DEBUG_ERRORS` | no | default false | +| `LOG_LEVEL` | no | default info | +| `RATE_LIMIT_*` | no | see §8.4 | +| `SEED_ADMIN_EMAIL` | no | default admin@example.com | +| `SEED_ADMIN_PASSWORD` | no | default Admin1234 | +| `SEED_USER_EMAIL` | no | default user@example.com | +| `SEED_USER_PASSWORD` | no | default User1234 | +| `VITE_APP_URL` | frontend | same as APP_URL | +| `VITE_API_URL` | frontend | empty in dev (relative `/api/v1`); prod may set origin | + +No `REFRESH_SECRET`. Never commit real secrets. + +## 11. Testing + +**CI Mongo:** GitHub Actions `services: mongo:7`. Local: `mongodb-memory-server` when `MONGODB_URI` unset. + +**Mandatory backend integration** + +- AUTH-1/2 register+login cookies; duplicate email 409; inactive 401 +- AUTH-4/6 refresh rotation; reuse revokes family; logout +- AUTH-7 forgot always 200; reset; token single-use +- AUTH-9/ADM-2 self-demote 403; last admin 403 +- AUTH-11 five failures then lock (still 401) +- WO-2 owner isolation; WO-10 404 for others +- WO-6/9 stale version 409; omitted version 400 +- WO-7 soft delete 204 then GET 404 +- WO-4 regex escaped +- Envelope shape 400/401/403/404/409/429 +- Extra fields on POST /work-orders do not persist + +**Frontend:** auth forms (zod), protected route redirect, 409 conflict UI. Coverage thresholds in vitest config (backend 80%, frontend 60% of included files). Excludes: `server.ts`, Docker, generated. + +## 12. CI/CD + +`.github/workflows/ci.yml` on Node 20: + +1. lint +2. typecheck +3. test + coverage (fail on threshold) +4. build +5. docker build (PRs and main) + +## 13. Definition of Done + +- §5 IDs implemented and covered by the test matrix. +- Lint, typecheck, tests pass with configured thresholds. +- README + GETTING_STARTED document setup, env, seed credentials. +- `docker compose up --build` serves the SPA and API on one browser origin. +- Seed produces admin + user. +- `/health` 200; register → login → create a work order works. +- No secrets committed. + +## 14. Open Questions (resolved) + +1. Mailer → log-only, redacted token. Real SMTP later. +2. Users → `isActive` only; no user hard/soft delete. +3. npm workspaces + Vite/nginx same-origin proxy. +4. React Query + auth context (`useMe`). No zustand. +5. i18n deferred; en-US. + +Remaining: none for v1. + +## 15. Risks + +| Risk | Sev | Mitigation | +|---|---|---| +| JWT secret leak | critical | min length, reject placeholders, 15m access TTL | +| Cookie CSRF on split origin | major | Same-origin only; no `urlencoded`; documented in ADR-0001 | +| Register email enumeration | minor | Accepted (`EMAIL_TAKEN`); forgot-password does not leak | +| Account lockout DoS | major | IP limiter first; lockout hidden behind AUTH_GENERIC | +| Refresh reuse vs parallel refresh | major | Client single-flight + 10s grace | +| NoSQL injection | major | Recursive key reject + zod + ObjectId + HMAC cursor | +| In-process rate limits | minor | Single-instance assumption | +| Last-admin race | major | Count admins in service before write; DB role on each request | +| bcrypt vs load SLO | minor | SLO removed from DoD | + +## 16. Glossary + +- **Cursor** — HMAC-protected token of `{ createdAt, id }`. +- **Family** — lineage of rotated refresh sessions; reuse revokes the lineage. +- **httpOnly cookie** — not readable from JS. +- **Soft delete** — `deletedAt` set; hidden from default reads. +- **Work order** — a job owned by a technician, tracked `pending → in_progress → done`. +- **Actor** — `{ id, role }` loaded from the database after JWT verify. + +*End of specification. v1.1 is the implementation contract.*