- SunnyTune ☀️
- Stack
- What is Prisma?
- Generating Secrets
- Scripts Reference
- Quick Start — Docker (recommended)
- Quick Start — Without Docker
- Environment Variables
- Authentication
- Adding New SunnyPilot / Comma AI Features
- Running Database Migrations
- Security
- Config Import / Export
- QR Code Sharing
- Verified Vehicle List
- SP Version Compatibility Filter
- Configuration Sharing
- Deploying Online (Vercel — Frontend & Backend)
- GitHub Actions Workflows
- Project Structure
Fine-tune your SunnyPilot. Share. Drive chill.
A community web application for creating, storing, and sharing SunnyPilot and Comma AI openpilot configurations. Build fully-detailed configs through a structured UI covering all SunnyPilot parameters — lateral/longitudinal control, speed limiting, lane changes, navigation, UI settings, Comma AI core options, and advanced tuning.
Configs can be shared via a unique public URL. After sharing you can continue editing and saving — each save increments the version counter so the community always sees your latest tune.
Contributing? See CONTRIBUTING.md for the full guide — setup, code style, how to add SP parameters, PR process, and more. Security? See SECURITY.md for the responsible disclosure process and architecture overview.
| Layer | Technology |
|---|---|
| Frontend | React 18 + TypeScript + Vite 8 |
| Styling | Tailwind CSS (dark zinc theme) |
| Icons | Lucide React |
| State | Zustand + TanStack Query |
| QR Codes | qrcode.react (SVG, ESM-safe) |
| Backend | Node.js + Express + TypeScript |
| ORM | Prisma 5 (see below) |
| Database | PostgreSQL 16 |
| Auth | Bearer token (UUID, no password) |
| Container | Docker + Docker Compose |
| Testing | Vitest + @testing-library/react |
| Deploy | Vercel (frontend) + any Node host |
Prisma is a type-safe ORM (Object-Relational Mapper) for Node.js. Instead of writing raw SQL, you define your database schema in server/prisma/schema.prisma and Prisma generates a fully-typed client that lets you interact with PostgreSQL using plain TypeScript objects.
Key things Prisma handles for this project:
- Schema —
schema.prismais the single source of truth for the database structure (users, configurations, ratings, comments, likes, favorites, collections, notifications, reports, page views) - Migrations —
npx prisma migrate devauto-generates and applies SQL migrations when the schema changes - Client —
npx prisma generatebuilds the TypeScript client used inserver/src/config/database.ts - Prisma Studio — a visual database browser, accessible via the
docker:*:studionpm scripts
When you add a new field to schema.prisma, run npx prisma migrate dev --name describe_your_change and both the database and the TypeScript types update automatically.
# TOKEN_SECRET — used to sign/validate bearer tokens (32+ chars)
openssl rand -hex 32
# ADMIN_SECRET — password for the /admin panel
openssl rand -hex 24
# POSTGRES_PASSWORD — database admin password
openssl rand -hex 16
# Node.js alternative (if openssl is not available)
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"All scripts are run from the project root unless noted otherwise.
| Script | What it does |
|---|---|
npm run dev |
Starts both the Express server (port 3001) and the Vite client (port 5173) concurrently with hot reload |
npm run dev:server |
Starts only the Express backend with tsx watch |
npm run dev:client |
Starts only the Vite frontend dev server |
npm run build |
Compiles the TypeScript server (tsc) then builds the Vite client bundle |
npm run clean |
Removes all node_modules directories and compiled output (client/dist, server/dist) |
npm run clean:vite |
Clears only the Vite cache (client/node_modules/.vite) — fixes stale HMR issues without a full reinstall |
| Script | What it does |
|---|---|
npm test |
Runs all tests — server (Vitest/Node) then client (Vitest/jsdom) |
npm run test:server |
Runs only the server-side tests |
npm run test:client |
Runs only the client-side tests (React Testing Library) |
Tests live in __tests__/ directories next to the code they test. All tests must pass — Docker builds will also fail if tests fail.
Dev containers use hot reload: source files are bind-mounted so changes take effect without rebuilding. Project name: sp-dev.
| Script | What it does |
|---|---|
npm run docker:dev:build |
Builds the dev Docker images (runs tests during build — fails fast on test failures) |
npm run docker:dev:build:clean |
Same as above but forces a full rebuild with --no-cache — use when package.json or base images change |
npm run docker:dev:up |
Starts the full dev stack (DB + server + client) in the foreground — logs stream to your terminal |
npm run docker:dev:up:d |
Starts the dev stack in the background (detached mode) |
npm run docker:dev:down |
Stops and removes all dev containers (database data is preserved) |
npm run docker:dev:down:v |
Stops containers and wipes the database volume — gives you a completely fresh DB on next start |
npm run docker:dev:fresh |
Tears down, removes local images, force-rebuilds, and starts in the background — full reset in one command |
npm run docker:dev:restart |
Restarts all running dev services without rebuilding |
npm run docker:dev:logs |
Tails the live logs from all dev containers (Ctrl+C to exit) |
npm run docker:dev:upgrade |
Pulls the latest base images, rebuilds all services, and restarts them — useful for keeping base images up-to-date |
npm run docker:dev:shell:server |
Opens an interactive shell (sh) inside the running server container |
npm run docker:dev:shell:db |
Opens a psql session inside the running database container |
npm run docker:dev:migrate |
Runs prisma migrate dev inside the server container — use this after changing schema.prisma |
npm run docker:dev:studio |
Starts Prisma Studio inside the container — opens a database browser at http://localhost:5555 |
Dev URLs: http://localhost:5173 (client) · http://localhost:3001 (API)
Prod containers use multi-stage builds (smaller images, no dev dependencies). Prisma migrations run automatically on container start. Project name: sp-prod.
| Script | What it does |
|---|---|
npm run docker:prod:build |
Builds the production Docker images (runs tests during build) |
npm run docker:prod:build:clean |
Same as above but forces a full rebuild with --no-cache |
npm run docker:prod:up |
Starts the full prod stack in the foreground — logs stream to your terminal |
npm run docker:prod:up:d |
Starts the prod stack in the background (detached mode) |
npm run docker:prod:down |
Stops and removes all prod containers (database data is preserved) |
npm run docker:prod:down:v |
Stops prod containers and wipes the database volume |
npm run docker:prod:fresh |
Tears down, removes local images, force-rebuilds, and starts in the background — same as docker:dev:fresh for prod |
npm run docker:prod:restart |
Restarts prod services without rebuilding |
npm run docker:prod:logs |
Tails live logs from prod containers |
npm run docker:prod:upgrade |
Pulls latest base images, rebuilds prod, and performs a rolling restart |
npm run docker:prod:shell:server |
Shell inside the running prod server container |
npm run docker:prod:shell:db |
psql session inside the prod database container |
npm run docker:prod:migrate |
Runs prisma migrate deploy inside the prod server (safe for production — never resets data) |
npm run docker:prod:studio |
Prisma Studio against the prod database at http://localhost:5555 |
Prod URL: http://localhost (port 80 by default; set CLIENT_PORT in your .env to change it)
Dev + prod can run simultaneously — they use separate Docker project names (
sp-dev/sp-prod), separate networks, and separate database volumes.
Dev and prod databases are completely isolated. Dev uses the
postgres_dev_datavolume; prod usespostgres_data. They have no shared storage, network, or containers — wiping dev withdocker:dev:down:vhas zero effect on prod.
cd /path/to/sunny-tune
cat > .env << EOF
POSTGRES_USER=postgres
POSTGRES_PASSWORD=$(openssl rand -hex 16)
TOKEN_SECRET=$(openssl rand -hex 32)
ADMIN_SECRET=$(openssl rand -hex 24)
CORS_ORIGIN=http://localhost:5173
VITE_API_URL=http://localhost:3001
EOFnpm run docker:dev:build # builds images + runs all tests
npm run docker:dev:up:d # starts DB + server + client in backgroundnpm run docker:dev:migrate
# When prompted: enter a migration name, e.g. "init"- App: http://localhost:5173
- Admin panel: http://localhost:5173/admin (use your
ADMIN_SECRETas the password) - API: http://localhost:3001
LAN access: The Vite dev server binds to
0.0.0.0(viahost: trueinvite.config.ts), so the client is also reachable from other devices on your local network athttp://<your-machine-IP>:5173. SetCORS_ORIGINandVITE_API_URLin your root.envto your machine's LAN IP if you want cross-device testing.
From now on, npm run docker:dev:up:d is all you need.
- Node.js 20+
- PostgreSQL 16 running locally
# 1. Install all dependencies (monorepo workspaces)
npm install
# 2. Create server environment file
cp .env.example server/.env
# Edit server/.env — set DATABASE_URL, TOKEN_SECRET, ADMIN_SECRET, CORS_ORIGIN
# 3. Create client environment file
echo "VITE_API_URL=http://localhost:3001" > client/.env
# 4. Run database migrations (creates all tables)
cd server
npx prisma migrate dev --name init
cd ..
# 5. Start both services with hot reload
npm run dev- Client: http://localhost:5173
- Server API: http://localhost:3001
Copy .env.example to server/.env and fill in the values:
DATABASE_URL="postgresql://postgres:changeme@localhost:5432/sptune"
NODE_ENV=development
PORT=3001
CORS_ORIGIN=http://localhost:5173
# Generate with: openssl rand -hex 32
TOKEN_SECRET=replace_me
# Generate with: openssl rand -hex 24
# Leave empty/unset to disable the admin panel entirely
ADMIN_SECRET=replace_meFor Docker, place these in a .env file at the project root (Docker Compose reads it automatically):
POSTGRES_USER=postgres
POSTGRES_PASSWORD=replace_me
TOKEN_SECRET=replace_me
# Use ADMIN_SECRET_HASH (bcrypt) for production — see Security section below
ADMIN_SECRET_HASH=replace_me_with_bcrypt_hash
# Or plaintext fallback (not recommended for production):
# ADMIN_SECRET=replace_me
ADMIN_ALLOWED_IPS= # comma-separated IPs; empty = no IP restriction
CORS_ORIGIN=http://localhost:5173
VITE_API_URL=http://localhost:3001
CLIENT_PORT=80 # host port to expose the nginx frontend onSunnyTune uses anonymous UUID tokens — no sign-up or password required.
- On first visit, the client auto-calls
POST /api/users/registerwhich returns asp_<uuid>token. - The token is stored in
localStorageundersp_user_tokenand sent asAuthorization: Bearer sp_...on every request. - To view your token: click the key icon in the header to open the Token modal.
Users can set an optional display name in the Token modal. Once set it appears on all shared configs (visible to others on the shared config page and explore cards) and auto-fills the comment nickname field. The name can be changed or cleared at any time.
On first visit after v2.2.1, a one-time dismissable banner below the header prompts users to set their name.
If you open SunnyTune on a new device it will generate a fresh token (disconnecting you from your existing configs). To restore access:
- Copy your token from the Token modal on your original device.
- On the new device, open the Token modal and click Use token from another device…
- Paste the token and click Confirm. SunnyTune validates it server-side and, if valid, replaces the generated token immediately.
If the token is invalid, the previous token is automatically restored — you will not be left in a broken state.
Access the admin panel at /admin. You'll be prompted for the ADMIN_SECRET (or the plain-text value behind ADMIN_SECRET_HASH) value set in your environment.
The secret is stored only in sessionStorage (cleared automatically when the browser tab closes — never in localStorage).
Admin panel features:
- Dashboard — total users, configs, collections, ratings, comments, likes, pending reports, page views, engagement trends
- Users — list all users with config counts, view details, delete accounts
- Configurations — browse all configs, filter by shared, force-unshare, delete
- Analytics — page view analytics broken down by path and day
- Reports — review and action user-submitted content reports
Security measures:
ADMIN_SECRETchecked withcrypto.timingSafeEqual(timing-attack safe)- Rate limiter: 10 requests/min per IP on all admin endpoints
- Secret never echoed in any server response
- If
ADMIN_SECRETis unset or too short the entire admin API returns503
The best sources for new SP and Comma AI parameters:
- SunnyPilot changelog — RELEASES.md and GitHub releases document every new
SP_*param per version. - SunnyPilot source — search for
Params().put/Params().getin the sunnypilot repo to find all param keys in use. - openpilot params — the canonical Comma AI param list lives inside
selfdrive/in the openpilot repo. - Community forum — community.sunnypilot.ai often documents new features before the changelog catches up.
Every registry entry has a required source field. TypeScript will refuse to compile if you omit it:
| Value | Meaning | When to use |
|---|---|---|
'sunnypilot' |
Exclusive to the SP fork | Params with SP_* prefix, SP-only behaviour |
'openpilot' |
Stock Comma AI / upstream openpilot | Params that exist in vanilla openpilot |
The source field is used internally for registry bookkeeping. The SP / Comma AI origin chips (yellow/blue badges) that previously appeared in the config editor UI have been removed — the source field is still required in the registry but is no longer rendered visually.
All configurable parameters are registered in client/src/lib/featureRegistry.ts. Adding a new feature:
// client/src/lib/featureRegistry.ts — add to the relevant section array:
{
id: 'myNewFeature', // must match the key in SPConfig
section: 'commaAI', // longitudinal | laneChange | commaAI | advanced
label: 'My New Feature',
description: 'What it does and when to use it.',
type: 'toggle', // toggle | number | slider | select | text
default: false,
source: 'sunnypilot', // REQUIRED: 'sunnypilot' or 'openpilot'
experimental: true, // shows an "Experimental" badge in the UI
since: '0.9.8', // SP version that introduced this param
spKey: 'MY_NEW_FEATURE_PARAM',// original param name in SP/openpilot source
},Then:
- Add the field to both the
SPConfiginterface andcreateDefaultConfig()inclient/src/types/config.ts - For dynamically-rendered sections the
<ParamRow>is auto-generated from the registry - For manually-laid-out sections (e.g.
VehicleSection), add a<ParamRow spKey="MY_NEW_FEATURE_PARAM" ...>in the component - Optionally register help text in
client/src/lib/fieldHelp.ts— when present, a ⓘ icon appears on hover in the config editor and shared config page:
// client/src/lib/fieldHelp.ts — add to FIELD_HELP:
MY_NEW_FEATURE_PARAM: {
summary: "One-sentence description shown in the tooltip.",
tips: ["Practical tip for users."],
tradeoffs: ["Any downsides or caveats."],
defaultNote: "Disabled",
recommended: "Enabled for most cars",
docsUrl: "https://docs.sunnypilot.ai/settings/section-name/",
},A migration is only needed when server/prisma/schema.prisma changes — i.e. when the database structure itself is modified. Pure frontend changes (adding a feature to featureRegistry.ts, updating types, changing components) never require a migration.
| Change type | Migrate? |
|---|---|
Add a field/model/index to schema.prisma |
Yes |
Remove or rename a field/model in schema.prisma |
Yes |
Add a new param to featureRegistry.ts only |
No — stored inside the config JSON column |
| Change server route logic or middleware | No |
| Add/update React components or pages | No |
| Update environment variables or secrets | No |
Tip: If the new feature stores its value inside the existing
configJSON field on theConfigurationmodel (which is the case for all SunnyPilot/Comma AI params), no schema change is needed — just updatefeatureRegistry.tsandconfig.ts(theSPConfiginterface +createDefaultConfig()) as described above.A schema change is only required when you need a new top-level column or table — for example, adding a new model, a new scalar field directly on a model, or a new relation.
# 1. Edit server/prisma/schema.prisma
# 2. Apply the migration and regenerate the Prisma client:
npm run docker:dev:migrate
# When prompted, enter a short descriptive name, e.g. "add_vehicle_trim_field"The command runs prisma migrate dev inside the running server container, creates a new timestamped SQL file under server/prisma/migrations/, and regenerates the TypeScript client automatically.
# Prod uses prisma migrate deploy (safe — never resets data, no prompt):
npm run docker:prod:migrateThis is also run automatically every time the prod stack starts (docker:prod:up), so in most cases you only need it when applying a migration to an already-running prod stack without a full restart.
cd server
npx prisma migrate dev --name describe_your_change
# The Prisma client is regenerated automatically after the migration- The updated Prisma client types are available immediately in the server code.
- Restart the server (
npm run dev:serverordocker:dev:restart) if it was already running and didn't pick up hot reload. - Commit the new migration file in
server/prisma/migrations/— it is part of the source code and must be version-controlled.
Recommended approach — store a bcrypt hash instead of the plain-text secret so a leaked env file does not expose the password.
# Generate the hash once (cost factor 12)
cd server
npm run hash-secret -- "your-chosen-admin-password"
# Outputs something like: $2b$12$xxxxxxxxxxxxxxxxxxxThen in your .env:
# Recommended: bcrypt hash (plaintext never stored)
ADMIN_SECRET_HASH=$2b$12$xxxxxxxxxxx...
# Legacy fallback: plaintext (still timing-safe, but less secure)
# ADMIN_SECRET=your-chosen-admin-passwordThe middleware checks ADMIN_SECRET_HASH first. If it is set the ADMIN_SECRET variable is ignored. Both behave identically for the admin UI — just type the plain-text password in the browser prompt.
Restrict admin panel access to specific IP addresses (e.g. your home/office IP):
# Comma-separated, no spaces
ADMIN_ALLOWED_IPS=XX.XX.XX,XX.XX.XXWhen unset, any IP can attempt authentication (still protected by rate-limit + secret).
A user can invalidate their current bearer token and issue a fresh one via the header key-icon → Regenerate token. This immediately signs out all other devices using the old token. Use it if a token is leaked.
Every config can be round-tripped as a .sunnytune.json file — useful for sharing outside the app, version-controlling your tuning, or migrating between SunnyTune instances.
Export — click the download icon on any ConfigCard or use the Export button in the configurator toolbar. A JSON file is saved locally.
Import — click Import JSON in the configurator toolbar (or on the My Configs page). Two formats are accepted:
- SunnyTune (
.sunnytune.json) — the app's own export format; validated against the full schema before loading. - SunnyLink v2 (
.json) — the raw parameter export produced by the SunnyLink mobile app directly from your Comma device. SunnyTune translates all known SP/OP parameters intoSPConfigfields automatically, including the active driving model (ModelManager_ActiveBundle→ displayed as e.g.WMI V12 (January 13, 2026)).
The SunnyTune export format:
{
"exportVersion": 1,
"exportedAt": "2026-04-01T00:00:00.000Z",
"name": "My Honda Config",
"description": "...",
"vehicleMake": "honda",
"vehicleModel": "Civic",
"vehicleYear": 2023,
"tags": ["highway", "smooth"],
"category": "daily-driver",
"config": { ... }
}When a config is shared successfully, the Share modal displays a QR code generated from the public URL. Scan it with any device (including your Comma device's browser) to instantly open the config. The QR code is generated client-side using qrcode.react — no external service call.
The server maintains a curated list of vehicles supported by SunnyPilot/OpenPilot in server/src/lib/vehicles.ts. It covers 18+ makes and 200+ models.
- The list is exposed via
GET /api/explore/vehiclesand used as autocomplete data for the vehicle make/model selector. - The
othermake entry is intentionally empty — it acts as a catch-all for unlisted vehicles. - To add a new vehicle: add an entry to
VERIFIED_VEHICLESinvehicles.tsand it will immediately appear in the client picker (no migration needed — the list is not stored in the database).
The Explore page has a Min SP Version filter. Enter a version string (e.g. 0.9.8) to show only configs whose metadata.sunnypilotVersion is an exact match or higher (semver order). Leave the field blank to show all versions.
Every shared config supports a set of social and community interactions:
| Feature | Who can use | Visible to | Notes |
|---|---|---|---|
| Likes | Any authenticated user | Everyone | One like per user per config; count updates in real-time; owner gets a notification |
| Ratings | Any authenticated user (not owner) | Everyone | 1–5 stars; upsert — you can change your rating |
| Comments | Any authenticated user | Everyone | Threaded replies up to 2 levels; owner can delete any comment on own config |
| Favorites | Any authenticated user | Only you | Private bookmark; stored per user |
| Collections | Any authenticated user | Public or private | Named groups of configs; toggle public/private |
| Reports | Any authenticated user | Admins only | Flag a config or comment for moderation review |
All social counts (likeCount, ratingCount, commentCount, cloneCount, viewCount) are returned on every config record from the explore, configs, and favorites endpoints.
- Open any saved config and click Share.
- A unique share link is generated:
/shared/<token>. - Anyone with the link can view the config and leave ratings, comments, and likes.
- Click Clone to create your own editable copy of any shared config.
- After sharing you can continue editing and saving — each save increments the version counter. The public share URL always reflects the latest version.
Both the frontend and backend run on Vercel — completely free, no credit card required, no sleep or cold-start issues.
- Frontend: existing
client/Vercel project (unchanged) - Backend: a second Vercel project pointing at the
server/directory, running as a serverless Node.js function via@vercel/node
Browser → Vercel (client/) → /api/* rewrite → Vercel (server/) → Neon PostgreSQL
1. Deploy the backend to Vercel
- Go to vercel.com → Add New Project → import this GitHub repo.
- On the configuration screen set Root Directory to
server. - Leave the framework preset as Other (Node.js auto-detected).
- Set all required environment variables before clicking Deploy:
| Variable | Value |
|---|---|
DATABASE_URL |
postgresql://USER:PASSWORD@HOST/DB?sslmode=require |
NODE_ENV |
production |
PORT |
3001 |
TOKEN_SECRET |
(run openssl rand -hex 32 to generate) |
ADMIN_SECRET_HASH |
(run cd server && npm run hash-secret -- "your-secret" to generate) |
CORS_ORIGIN |
https://YOUR_FRONTEND.vercel.app |
- Click Deploy — note the backend URL (e.g.
https://sunny-tune-server.vercel.app).
2. Update the frontend rewrite
Update the /api rewrite destination in client/vercel.json to your backend URL:
{
"source": "/api/(.*)",
"destination": "https://your-backend.vercel.app/api/$1"
}Push to main — the frontend auto-redeploys.
3. Auto-deploy is already active
Both Vercel projects are connected to this GitHub repo via native integration. Every push to main automatically triggers a production deploy — no GitHub Actions secrets or workflow files needed. You can see deployment status in:
- Vercel Dashboard → Project → Deployments
- GitHub repo → commits → the green ✔ / Vercel bot check
The postinstall script in server/package.json runs prisma generate automatically during every Vercel build. To apply a new schema migration against the hosted Neon DB:
cd server
DATABASE_URL="postgresql://USER:PASSWORD@HOST/DB?sslmode=require" \
npx prisma migrate deployVercel Dashboard → Project → Deployments → Redeploy (works for both projects).
Or via CLI:
# Backend
cd server && vercel --prod
# Frontend
cd client && vercel --prodEight workflows live in .github/workflows/:
| Workflow | File | Trigger | What it does |
|---|---|---|---|
| CI | ci.yml |
Push / PR to main |
Installs deps, runs all tests (server + client), type-checks, builds both packages |
| CodeQL | codeql.yml |
Push / PR / weekly | GitHub code scanning — static analysis for JS/TS security vulnerabilities |
| Lighthouse | lighthouse.yml |
PR to main |
Runs Lighthouse CI against the PR preview; posts performance scores as a status check |
| Dependency Review | dependency-review.yml |
PR to main |
Blocks PRs that introduce dependencies with known CVEs |
| Secret Scan | secret-scan.yml |
Push / PR | Scans commit diff for accidentally committed secrets/tokens |
| PR Labeler | pr-labeler.yml |
PR opened / edited | Automatically applies labels (client, server, docs, etc.) based on changed paths |
| Stale | stale.yml |
Daily schedule | Marks issues and PRs stale after 60 days of inactivity; closes after 7 more days |
| Create Labels | create-labels.yml |
Manual (workflow_dispatch) |
Creates all standard labels used by the PR labeler — run once after making repo public |
Deployments are handled by Vercel's native GitHub integration, not by a workflow file. Both the frontend (
client/) and backend (server/) Vercel projects are connected to this repo and auto-deploy on every push tomain.
sunny-tune/
├── client/ # React + Vite frontend
│ ├── src/
│ │ ├── api/ # Axios API client + admin API
│ │ ├── components/
│ │ │ ├── config/sections/ # One component per config section
│ │ │ │ ├── SunnyLinkExportModal.tsx # SunnyLink device export with validation
│ │ │ │ └── ... # Other config modals / shared config card
│ │ │ ├── layout/ # Header, Layout
│ │ │ └── ui/ # Button, Modal, Badge, HelpTooltip, etc.
│ │ ├── lib/
│ │ │ ├── featureRegistry.ts # ← Add new SP/Comma AI params here
│ │ │ ├── fieldHelp.ts # ← Register tooltip help text for each spKey
│ │ │ ├── sunnyLinkValidation.ts # SunnyLink export pre-flight validation rules
│ │ │ └── configExport.ts # JSON + SunnyLink import/export logic
│ │ ├── pages/
│ │ │ ├── HomePage.tsx
│ │ │ ├── ConfiguratorPage.tsx
│ │ │ ├── ExplorePage.tsx
│ │ │ ├── MyConfigsPage.tsx
│ │ │ ├── SharedConfigPage.tsx
│ │ │ ├── CollectionDetailPage.tsx # Collection item management
│ │ │ ├── DashboardPage.tsx # Stats & charts dashboard
│ │ │ ├── AdminPage.tsx
│ │ │ ├── ChangelogPage.tsx # Version changelog timeline
│ │ │ ├── DocsPage.tsx # In-app documentation
│ │ │ └── AboutPage.tsx
│ │ ├── store/ # Zustand stores (auth, config)
│ │ ├── types/ # Shared TypeScript types (SPConfig, etc.)
│ │ └── __tests__/ # Vitest + React Testing Library tests
│ ├── Dockerfile # Multi-stage production build
│ └── Dockerfile.dev # Dev image (hot reload via bind mounts)
├── server/ # Express + Prisma backend
│ ├── prisma/
│ │ └── schema.prisma # ← Database schema (edit then run migrate)
│ ├── src/
│ │ ├── lib/
│ │ │ ├── vehicles.ts # Verified SunnyPilot vehicle list
│ │ │ ├── querySchemas.ts # Shared Zod schemas for query params
│ │ │ ├── guards.ts # Type-guard helpers
│ │ │ ├── logger.ts # Structured request logger
│ │ │ └── sanitize.ts # Input sanitization utilities
│ │ ├── middleware/ # auth, adminAuth, errorHandler, pageView, rateLimiter
│ │ ├── routes/
│ │ │ ├── configs.ts # Config CRUD
│ │ │ ├── explore.ts # Public explore/search
│ │ │ ├── community.ts # Ratings & comments
│ │ │ ├── likes.ts # Likes
│ │ │ ├── collections.ts # Collections CRUD
│ │ │ ├── favorites.ts # Favorites
│ │ │ ├── notifications.ts # In-app notifications
│ │ │ ├── reports.ts # User reports
│ │ │ ├── users.ts # User registration & token management
│ │ │ ├── admin.ts # Admin panel API
│ │ │ └── index.ts # Router aggregator
│ │ └── __tests__/ # Server unit + integration tests
│ ├── Dockerfile # Multi-stage production build
│ └── Dockerfile.dev # Dev image
├── .github/
│ └── workflows/ # CI, Deploy, CodeQL, Lighthouse, etc.
├── docker-compose.yml # Production compose
├── docker-compose.dev.yml # Development compose (hot reload)
├── .env.example # Template — copy to .env (Docker) or server/.env (local)
└── package.json # Root monorepo scripts (see Scripts Reference)