--- type: reference tags: [parking, dev-environment, workflow] sources: [] updated: 2026-06-30 --- # Local Dev Workflow > Dev-environment reference, not product architecture. How to run the stack locally and the > gotchas that have bitten us. For device testing under WSL also read [[wsl-dev-networking]]. ## First-time setup ```bash pnpm install cp apps/server/.env.example apps/server/.env # then fill in JWT_SECRET # JWT_SECRET=$(openssl rand -hex 32) # server refuses to start without a strong one pnpm --filter @parking/db exec drizzle-kit migrate # create the SQLite schema pnpm seed:admin # create the first admin (see [[local-jwt-auth]]) ``` `apps/server/.env` and the `*.sqlite` files are **gitignored** (local-only). Leave `NODE_ENV` **unset** in dev so the auth cookies aren't `Secure`-only (Vite dev is plain http). ## Running ```bash pnpm dev # turbo runs both: Vite (web, :5173) + Fastify (server, :3000) ``` Open `http://localhost:5173`. The Vite dev proxy forwards `/api` + `/health` to the backend, so the SPA and API are **same-origin** and the [[local-jwt-auth|cookie auth]] works without CORS. Production uses an **nginx** reverse proxy (`deploy/nginx.conf`) for the same same-origin setup. ## Gotchas (all fixed, recorded so they don't recur) - **Server dev must not be `node --experimental-strip-types src/index.ts`.** Type-stripping does **not** rewrite `.js` import specifiers to `.ts`, so it crashed with `ERR_MODULE_NOT_FOUND` and silently never started — the symptom was the SPA hanging for *minutes* (the Vite proxy waiting on a dead backend), then finally erroring. The `dev` script uses **`tsx watch`** instead. - **Vite proxy → `127.0.0.1`, not `localhost`.** `localhost` resolves to IPv6 `::1` first while the backend binds IPv4; Node's proxy can stall on the v6 attempt. Same class of "slow then works" hang, worse under WSL2 mirrored mode ([[wsl-dev-networking]]). - **`.env` must actually be loaded.** The server reads `process.env` only; the dev/start scripts load the file via Node's `--env-file-if-exists=.env`. An empty `JWT_SECRET=` makes the server fail-fast at boot. - **Seed into the DB the server reads.** `seed:admin` and the server must use the same `DATABASE_URL`; running via `pnpm seed:admin` (which loads `apps/server/.env`) keeps them aligned. ## Useful one-offs - First admin: `pnpm seed:admin` (prompts; blank username → `admin`). Non-interactive: `ADMIN_USER=.. ADMIN_PASS=.. pnpm seed:admin`. Reset a password: add `FORCE=1`. - Hardware test scripts (UHPPOTE): `apps/server/scripts/uhppote-listen.mjs` (live events), `uhppote-relay.mjs` (guarded door-open). See [[uhppote-controller]]. ## Database reset — training / demo only (2026-06-30) A site is sometimes run live to **train** operators/admins on the real app; afterwards the demo data must go without leaving an obvious self-serve button (an operator must not be able to wipe history). So the reset is a **CLI script**, not UI: `packages/db/scripts/reset-db.mjs`, run via `pnpm db:reset`. ```bash RESET_ALLOWED=1 pnpm db:reset --financial # default DB = apps/server/parking.sqlite RESET_ALLOWED=1 DATABASE_URL=/path node packages/db/scripts/reset-db.mjs --all ``` **Category flags** (combinable; ≥1 required) — grounded in which tables hold what: | Flag | Wipes | Keeps | | --- | --- | --- | | `--financial` | `ledger_events` (entry/exit/payment/void/shift/cash/anomaly), `device_events`, `snapshots`, subscription **instances** + credentials/plates, `blocklist` | users, devices, config, tariffs, subscription **plans** | | `--config` | `site_config`, `devices`, `setup_state` (→ re-runs first-run setup), tariffs + versions + **drafts**, subscription plans | everything else | | `--users` | `users`, `roles`, `role_permissions`, auth `sessions` | everything else | | `--diagnostics` | `app_logs` (the unsigned [[app-logs]] store behind `/setup/logs`) | everything else | | `--all` | every table (blank slate) | — | **Drift guard** (2026-07-08): before doing anything, the script compares the category union against `sqlite_master` and **refuses if any table is uncategorized** — `app_logs` and `tariff_drafts` had silently survived every reset (including `--all`) because the hand-maintained table list lagged the schema. A new table now forces a deliberate one-line categorization decision. > **⚠ `--financial`/`--all` TRUNCATE the append-only, signed [[append-only-event-chain|ledger]].** > That is the anti-fraud record; a *partial* delete would break the hash chain, so a financial reset > wipes the whole ledger back to empty (re-seeding starts a NEW chain under the **same** > `EVENT_SIGNING_KEY` — the key is **not** touched). This is the opposite of how the ledger is meant to > behave, hence the gates below. It is a **training/demo** tool; never point it at a live booth. **Two safety gates ([[threat-model|operator-as-adversary]]):** 1. **`RESET_ALLOWED=1`** env must be set — a real booth never sets it, so the command is inert in production even if typed. 2. **Typed confirmation** of the DB filename (interactive). `--yes` skips it for CI/scripted training setup only. Runs as a single transaction (all-or-nothing) + `VACUUM` to shrink the re-used demo DB. After `--users`/`--all` (users cleared), re-seed an admin: `pnpm seed:admin`. The `EVENT_SIGNING_KEY` and `BACKUP_KEY` are intentionally left alone (see [[backup-recovery]] on key custody). > **On the BOOTH there is no `pnpm`** — only Docker containers. `pnpm db:reset` is the *dev* form; > on an appliance, run the same script via `docker exec` into the `server` container > (`node node_modules/@parking/db/scripts/reset-db.mjs …`, `DATABASE_URL=/data/parking.sqlite`). > Full booth procedure: [[appliance-provisioning]] §7d.