torqueDASH-Next
August 25, 2026 · View on GitHub
Guidance for contributors working on the torqueDASH-Next backend (repo root) and
the React/Vite frontend (apps/frontend/).
Known issues and follow-up items are documented below. See the "Known Issues" section for scalability, security, and correctness gaps.
1. Prerequisites
- Node.js 22 (LTS) and npm — the Docker images are built on
node:22-bookworm-slimand CI runs on Node 22. Vite 8 requires Node 20.19+ / 22.12+, so Node 22 is the supported floor. - PostgreSQL with the TimescaleDB extension (
CREATE EXTENSION timescaledb;) - A Torque Pro device/app (or a scripted
GET /api/upload) to generate data - (Frontend only) a modern browser
2. Install
Backend (repository root)
npm install
Installs Express 4, Sequelize 6, pg, Passport, Joi, bcrypt, express-session,
connect-pg-simple, cors, helmet, nanoid, plus dev tooling
(eslint, @eslint/js, globals, husky, lint-staged, nodemon).
Frontend (apps/frontend/)
cd apps/frontend
npm install
Installs React 19, Vite 8, TypeScript 7, Tailwind CSS 4, ECharts 6, react-leaflet 5, TanStack Query 5, zustand 5, react-router 8.3.0 (exact pin). No Tremor — all UI uses native Tailwind utilities (Plan 049).
react-router version: Exact-pinned to 8.3.0 (not
^8.3.0) as the singlereact-routerpackage. Plan 045 migrated fromreact-router-dom6.30.4 toreact-router7.18.2, resolving the two 6.x Dependabot advisories (GHSA-wrjc-x8rr-h8h6 backslash open redirect, GHSA-337j-9hxr-rhxg constructor injection via SSR hydration); Plan 047 then upgraded to 8.3.0, which also resolves the previously tracked GHSA-qwww-vcr4-c8h2 advisory (high, RSC-mode CSRF, affected react-router 7.12.0–8.2.0). All frontend imports come fromreact-router(thecreateBrowserRouter+RouterProviderpattern).
3. Environment Variables
Set these at the backend repo root (.env or exported in the shell).
| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL | yes | REQUIRED — no default | Postgres/TimescaleDB connection string. App crashes on startup if missing. Also used by scripts/migrate.js. |
CORS_ORIGINS | prod | '' (empty) | Comma-separated list of SPA origins allowed to call /api with cookies (e.g. https://app.example.com). An empty value blocks all cross-origin SPA calls (see Known Issues, LOW). |
COOKIE_SECURE | prod | unset (lax) | Set true in production to set sameSite:none; secure on the session cookie (required for cross-origin SPA auth). Dev (same-origin) keeps lax and works without HTTPS. |
NODE_ENV | yes | unset | production disables sequelize.sync() so the TimescaleDB migration is the source of truth. Any other value runs sequelize.sync() on boot. |
PORT | no | 3000 | Backend listen port. |
SESSION_KEYS | yes | REQUIRED — no default | express-session secrets (array accepted via comma-separated string). App crashes on startup if missing or if a placeholder value is used. Generate with openssl rand -hex 24. |
PUBLIC_ORIGIN | no | unset | Optional. Overrides the expected CSRF origin. Set to the browser-visible origin (e.g. https://app.example.com) when nginx terminates HTTPS but forwards HTTP to the backend, so X-Forwarded-Proto doesn't mislead the origin check. |
DISABLE_SYNC | planned | — | Intended as an explicit kill-switch for sequelize.sync(). Not yet wired — today the sync gate is solely NODE_ENV !== 'production'. (Listed for forward compatibility; do not rely on it yet.) |
UPLOAD_RATE_LIMIT_MAX | no | 600 | Max /upload requests per UPLOAD_RATE_LIMIT_WINDOW_MS window, per client IP. Raised from the original 60/min to absorb Torque reconnect bursts. |
UPLOAD_RATE_LIMIT_WINDOW_MS | no | 60000 | Window length (ms) for the /upload rate limiter. |
UPLOAD_API_TOKEN | yes (production) | unset | Uploads REQUIRE Authorization: Bearer <token> once a token is configured — without a matching header they return 401. Email alone is sufficient only when no token exists anywhere (discouraged bootstrap mode; insecure for production). Precedence: the env value always wins and locks the Settings UI (generate/clear return 403 while env-managed); without env, the Settings-UI/DB token applies. Generate with openssl rand -hex 24. Matching-token requests also bypass the per-IP upload rate limiter. |
DISABLE_REGISTRATION | no | unset | Hard kill-switch: when 'true', UserController.register returns 403 and GET /api/settings reports disableRegistration: true regardless of the runtime Settings toggle. |
LLM_ENCRYPTION_KEY | yes (AI) | unset | 64-char hex key for AES-256-GCM encryption of LLM API keys at rest. Generate with openssl rand -hex 32. Required when using the AI analysis feature. |
The migration script (
scripts/migrate.js) readsDATABASE_URL, falling back toconfig/config.js(postgres://postgres:heslo@localhost:5432/torquedash).
4. Running the Database Migration
The migration script (scripts/migrate.js) loads every .sql file in
infra/timescale/ in lexicographic order and executes each statement via pg.
In Docker, migrations run automatically. The backend container's CMD
(Dockerfile) executes node scripts/migrate.js at every container start
(after sequelize.sync()), so docker compose up -d applies any pending
migrations — no manual step needed for Docker deployments. A manual run is
only required for non-Docker (manual) setups:
node scripts/migrate.js
The script:
- Reads all
.sqlfiles frominfra/timescale/, sorted by filename. - Strips SQL comments and splits each file into individual statements on
;. - Runs each statement via
pg; benign "already exists" / "does not exist" errors are tolerated (idempotent re-runs).
Current migration files (in execution order):
| File | Purpose |
|---|---|
log_hypertable.sql | Creates the Logs hypertable, promoted columns engine_rpm / vehicle_speed, unique index on id, and the log_1min continuous aggregate |
settings.sql | Seeds the Settings singleton row (misc global configuration) |
003_add_llm_settings.sql | Adds llmProvider, llmModel, llmEndpoint, llmApiKey columns to Settings |
004_add_analyses_table.sql | Creates the Analyses table for cached AI analysis results |
005_add_analysis_reasoning.sql | Adds reasoning column to Analyses (stores LLM chain-of-thought) |
006_add_deepseek_settings.sql | Adds llmThinkingMode and llmReasoningEffort columns to Settings |
007_add_timezone_offset.sql | Adds timezoneOffset column to Settings for session name formatting |
008_add_session_notes.sql | Adds nullable notes TEXT column to Sessions |
009_add_vehicles.sql | Creates the Vehicles table and adds vehicleId FK to Sessions |
010_add_llm_max_tokens.sql | Adds llmMaxTokens INTEGER column to Settings (NOT NULL, default 16384) |
011_add_retention_settings.sql | Adds retentionEnabled BOOLEAN (NOT NULL, default false) and retentionDays INTEGER (NOT NULL, default 365) columns to Settings for the data retention policy |
Run this against a TimescaleDB-enabled database (the timescaledb extension
must exist). For large existing datasets, run in a maintenance window
(migrate_data => true re-chunks existing rows).
5. Running the Backend
node app.js
# or: npm start
- In non-production, the server runs
sequelize.sync()then listens onPORT. - In production (
NODE_ENV=production),sequelize.sync()is skipped. /healthreturns{ status: 'ok', ts }for probes.
6. Model & Controller Patterns
The Vehicle model (models/Vehicle.js) and controller
(controllers/VehicleController.js) serve as the reference pattern for adding
new entities. Key conventions:
6.1 Model (models/Vehicle.js)
- Sequelize model with explicit field types,
allowNull, anddefaultValue. - Associations defined in an
associatefunction — projectsbelongsTo/hasManyfrom both sides so Sequelize resolves foreign keys correctly. - Dynamically loaded by
models/index.js(auto-reads all files in themodels/directory), no registration step needed. - Example from
Vehicle:Vehicle.associate = function (models) { Vehicle.belongsTo(models.User, { as: 'User', foreignKey: 'userId' }); Vehicle.hasMany(models.Session, { as: 'Sessions', foreignKey: { name: 'vehicleId', allowNull: true }, onDelete: 'set null', }); };
6.2 Controller (controllers/VehicleController.js)
- Static methods on a class, one per action:
getAll,getOne,create,update,delete, plus domain-specific actions likesetDefault. - Ownership scoping — every query includes
where: { userId: req.user.id }so users can only access their own data. - Validation — early returns with
4xxJSON errors before database writes. - Error handling — try/catch with
console.error+500JSON response. - No Express
routerregistration in the controller — routes are defined inroutes/api.js.
6.2a Extracted Helpers (SessionController.js)
SessionController extracts shared logic into standalone functions (exported
on the module):
| Helper | Purpose |
|---|---|
loadOwnedSession(sessionId, userId) | Single Session.findOne with ownership scoping — used by updateNotes, cut, filter, copy, join, reassignVehicle. |
decorateWithSummaries(session, summary) | Merges aggregate fields (startDate, endDate, duration, maxSpeed, maxRpm) onto a session JSON object. |
aggregateSummaries(sessionIds) | Single GROUP BY query across multiple session IDs — computes min(timestamp), max(timestamp), max(vehicle_speed), max(engine_rpm) in one pass. Replaces the legacy pattern of loading full Log arrays per session. |
formatDuration(start, end) | Formats a [start, end] pair into a compact human string (e.g. "1h 2m 5s"). |
sanitizeFilename(name) | Strips path-dangerous chars from session names for Content-Disposition filenames. |
csvEscape(val) | Escapes a single CSV cell, including Excel formula injection guard. |
6.3 Routes (routes/api.js)
- Route → controller mapping is explicit in
routes/api.js:const VehicleController = require('../controllers/VehicleController'); // ── Vehicle CRUD ── router.get('/vehicles', authenticate, VehicleController.getAll); router.post('/vehicles', writeLimiter, authenticate, VehicleController.create); router.put('/vehicles/:vehicleId', writeLimiter, authenticate, VehicleController.update); router.delete('/vehicles/:vehicleId', writeLimiter, authenticate, VehicleController.delete); router.patch('/vehicles/:vehicleId/default', authenticate, VehicleController.setDefault); - Write operations use
writeLimiterrate limiter; reads useauthenticateonly.
6.4 Migration SQL (infra/timescale/009_add_vehicles.sql)
- Raw SQL with
IF NOT EXISTS/ idempotent guards. Lexicographic filename ordering determines execution order (e.g.008_runs before009_).
6.5 Joi Validation Pattern (lib/validators.js)
Input validation is centralised in lib/validators.js using Joi schemas.
Controllers import the relevant schema and call .validate(req.body) early,
returning 400 with the first error detail on failure:
const { renameSchema } = require('../lib/validators');
// ...
const { error: valErr } = renameSchema.validate(req.body);
if (valErr) {
return res.status(400).json({ error: valErr.details[0].message });
}
Exported schemas:
| Schema | Used by | Purpose |
|---|---|---|
renameSchema | SessionController.rename | { name: string, 1–255 chars } |
notesSchema | SessionController.updateNotes | { notes: string|null, max 10000 } |
cutSchema | SessionController.cut | { from, to } ISO dates with from ≤ to custom validator |
filterSchema | SessionController.filter | { filterNumber: int 2–100000 } |
copySchema | SessionController.copy | { name: string, required } |
joinSchema | SessionController.join | { joinSessionId: positive int, name: string } |
addLocationSchema | SessionController.addLocation | { locations: { start, end } } strings |
vehicleCreateSchema | VehicleController.create | Vehicle fields: name required, make/model/year/engineCc/vin optional |
vehicleUpdateSchema | VehicleController.update | Same as create, all optional, min(1) — at least one field required |
telemetryRangeSchema | (available) | { from, to } ISO dates + limit (1–10000, default 5000) + offset (≥0) |
Pure validation helpers (legacy, used in UserController.updateSettings):
| Helper | Validates |
|---|---|
validateProvider(v) | LLM provider against PROVIDER_ALLOWLIST (openai, anthropic, ollama, deepseek, custom) |
validateLlmThinkingMode(v) | Boolean |
validateLlmMaxTokens(v) | Integer 2048–32768 |
validateRetentionEnabled(v) | Boolean |
validateRetentionDays(v) | Integer 90–365 |
7. Running the Frontend
Dev server (Vite)
cd apps/frontend
npm run dev
Vite serves the SPA (default http://localhost:5173) and proxies /api —
including the native /api/upload ingestion endpoint — to http://localhost:3000
(vite.config.ts). In dev the browser and API are same-origin, so the
Lax cookie works without HTTPS.
Frontend build
cd apps/frontend
npm run build # runs `tsc --noEmit && vite build` → apps/frontend/dist
The backend does not currently serve
apps/frontend/dist(it serves the legacypublic/). In production the SPA is expected to be served by a separate origin/CDN or an nginx layer that proxies/apito the backend. See Known Issues (LOW).
Frontend API client (lib/api.ts)
The API client at apps/frontend/src/lib/api.ts provides typed fetch wrappers
for all backend endpoints. New functions added in Plans 040–041:
| Function | Endpoint | Purpose |
|---|---|---|
getVehicles() | GET /api/vehicles | List all vehicles |
getVehicle(id) | GET /api/vehicles/:id | Get a single vehicle |
createVehicle(body) | POST /api/vehicles | Create a new vehicle |
updateVehicle(id, body) | PUT /api/vehicles/:id | Update a vehicle |
deleteVehicle(id) | DELETE /api/vehicles/:id | Delete a vehicle |
setDefaultVehicle(id) | PATCH /api/vehicles/:id/default | Set as default vehicle |
reassignSessionVehicle(sessionId, vehicleId) | PATCH /api/sessions/:sessionId/vehicle | Reassign session to a vehicle |
updateSessionNotes(sessionId, notes) | PATCH /api/sessions/notes/:sessionId | Update session notes |
All functions use request() with credentials: 'include' for cookie-based
auth. Vehicle types (Vehicle, UpdateVehicle) and the extended Session
type (with notes, vehicleId, vehicleName) are defined in lib/types.ts.
8. AI Analysis — Prompt Pipeline
The AI analysis feature (POST /api/sessions/:id/analyze) streams diagnostic
insights from an OpenAI-compatible LLM. The prompt is built by
lib/llmPrompt.js, which after Plan 042 exports five functions instead of the
previous two.
8.1 Prompt Assembly Flow
POST /api/sessions/:id/analyze
→ controllers/AnalysisController.js
→ lib/llmPrompt.buildAnalysisPrompt(session, settings, telemetrySample, pidKeys)
├── buildContext(...) → vehicle info, session metadata
├── computeSummaryStats(...) → min/max/mean/median per PID
├── buildTelemetryCsv(...) → resampled CSV telemetry data
└── returns full prompt text → sent to LLM provider
8.2 Key Functions
| Function | Purpose |
|---|---|
computeSummaryStats(telemetrySample, pidKeys) | Pre-computes min, max, mean, median for every PID. Filters null/empty values before numeric conversion to avoid Number(null) === 0 corruption. Also computes Combined Fuel Trim (STFT + LTFT) per-row. |
resampleTelemetry(telemetrySample, maxRows = 80) | Uniform resampling across the full timeline (replaces the older head/tail slicing approach). Ensures the LLM sees data from start, middle, and end of every drive. |
buildTelemetryCsv(telemetrySample, pidKeys) | Outputs raw CSV instead of Markdown tables (~30% token savings). Removes lat/lon columns. Extracts HH:mm:ss via regex. Calls resampleTelemetry() internally. |
buildContext(session, settings, telemetrySample, pidKeys) | Builds the vehicle/session context block with cleaner formatting and a "Data points in sample" label. |
buildAnalysisPrompt(session, settings, telemetrySample, pidKeys) | Assembles the complete prompt from all of the above. Includes pre-calculated stats, four diagnostic guardrails, dynamic engine size, and five analysis categories. |
8.3 Design Notes
- Statistics pre-computed in JS — exact min/max/mean/median values are calculated server-side, so the LLM does not need to estimate them from sampled rows. This eliminates hallucinated figures in the analysis.
- Uniform resampling — evenly-spaced rows across the full telemetry range (start, middle cruising, end) replace the older approach of keeping only the first and last N rows.
- CSV format — saves approximately 30% of tokens compared to Markdown tables for the same telemetry sample, reducing per-analysis cost.
- Diagnostic guardrails — four domain-specific rules encoded in the prompt prevent the LLM from flagging normal OBD-II behaviour (negative fuel trims within ±10%, A/C idle load, ECU torque management timing, deceleration fuel cut-off) as mechanical faults.
lib/pidRegistry.jsis imported to resolve PID short keys to human-readable names and units in both CSV column headers and the stats display.
8.4 LLM Token Budget & Provider Status (Plan 043)
llmMaxTokenssetting — theSettingssingleton gains an INTEGERllmMaxTokensfield (migration010_add_llm_max_tokens.sql, NOT NULL, default 16384, validated range 2048–32768).PUT /api/settingsrejects out-of-range values with400; both the GET and PUT responses include the field. Covered bytest/settingsValidation.test.js(7 cases).- Provider token budget —
lib/llmProviders.jsnow sendsmax_tokens: options.maxTokens || settings.llmMaxTokens || 16384for all providers (OpenAI, Anthropic, DeepSeek, Ollama, Custom), replacing the old hardcoded 8192. Explicit caller options take precedence — e.g. the connection test passesmaxTokens: 20to keep probes cheap, while production analyses use the configured budget. This matters for DeepSeek thinking mode, where reasoning and content share the same budget. - Settings UI —
AiProviderCard.tsxadds a general "Max Output Tokens" input (min 2048, max 32768, step 1024) with a cost warning, and the provider status badge now shows the human-readable provider name plus chips for Model, DeepSeek Thinking / Effort, and Max tokens.
9. Development Tooling
9.1 ESLint
The project uses ESLint 10 (flat config) for backend code with a
project-local eslint.config.js configuration (@eslint/js recommended presets
globals17):
npm run lint
The config (node env, es2022, eslint:recommended) ignores
apps/frontend/dist/ (Vite build output) and node_modules/. Custom rules include:
no-unused-varsset towarn(ignoring args prefixed with_).no-consoleis off — the server intentionally usesconsole.log/console.error.no-emptyiserror— empty catch blocks are forbidden.
9.2 Pre-commit Hooks (husky + lint-staged)
The project uses husky 9 and lint-staged 17 to run lint and syntax checks on every commit:
- husky (
package.json→"prepare": "husky") installs Git hooks afternpm install. - lint-staged is configured in
package.json:
Before every"lint-staged": { "*.js": ["eslint --fix", "node -c"] }git commit, staged.jsfiles are checked witheslint --fixand validated withnode -c(syntax check). If either step fails, the commit is blocked.
First-time setup: run
npm install(ornpm run prepare) to initialise the husky hooks directory (.husky/).
9.3 Test Commands
Backend (Node built-in test runner):
npm test # runs: node --test (discovers test/*.test.js)
npm run test:coverage # runs: c8 node --test (coverage via c8)
Frontend (Vitest):
cd apps/frontend
npm test # runs: vitest run
npm run test:watch # runs: vitest (watch mode)
9.4 CI Pipeline
A GitHub Actions workflow (.github/workflows/ci.yml) runs on every push
or pull request to the development branch:
- Backend checks:
npm ci→npm test→npm run test:coverage→npm run lint. - Frontend checks:
npm ci→npm run lint(placeholder) →npm test(vitest) →npx tsc --noEmit(typecheck) →npm run build.
The frontend currently has no lint coverage: typescript-eslint cannot parse
TypeScript ≥7 (its peer range tops out below 6.1 — tracked at
typescript-eslint#10940),
so the stack was removed and npm run lint is an explicit placeholder that prints the
reason and exits 0. When typescript-eslint ships TypeScript ≥7 support, re-enable linting
by restoring the flat config, reinstalling typescript-eslint / @eslint/js / globals,
and flipping the script back to eslint src/.
The workflow uses actions/checkout@v7 and actions/setup-node@v7 with npm
caching and Node 22 (node-version: '22', matching the node:22-bookworm-slim
runtime images). The backend lint step is enforced — the previous continue-on-error: true
has been removed, so ESLint failures correctly block the build.
9.5 Versioning
A Version Bump workflow (.github/workflows/version-bump.yml) runs on every
push to master. It:
- Analyses commits since the last tag using Conventional Commits heuristics to determine the bump type (major / minor / patch).
- Runs
npm version <bump> --no-git-tag-versionto updatepackage.jsonandpackage-lock.json. - Commits the result as
chore: release v<version>and creates an annotated tag. - Pushes the commit and tag back to
master.
Chaining to Docker builds: pushes made with the default
GITHUB_TOKENdo not trigger downstream workflows (likedocker-publish.yml). To enable the chain, configure a PAT withcontents:writeassecrets.GH_PATand replace the token reference in thegit pushstep.
Docker images built by docker-publish.yml now include semver tags in
addition to the SHA and latest tags — v<version> and <major>.<minor> for
pinned deployments.
10. Known Issues / Follow-up Items
These are documented issues from code reviews. Severity is assigned per the review.
Auth contract (SPA ↔ backend) ✅ RESOLVED
The auth contract mismatch (SPA vs backend) is fixed. All four original blockers are resolved and re-reviewed as PASS:
- ✅
app.jsnow registersexpress.json({ limit: '1mb' })on/api(before the api router) so JSON bodies populatereq.body. - ✅
middleware/auth.jsbranches onreq.originalUrl.startsWith('/api')and returns 401 JSON; legacy HTML routes keep the redirect. - ✅
models/User.jsconfirmPasswordis now.optional()(still validated when present). - ✅
UserController.register/loginreturn JSON for/apirequests (201/{ ok: true }) and callreq.logIn; the SPAlogin()probes an auth-gated endpoint to confirm the cookie.
High priority ✅ RESOLVED
- ✅ Proxy rate-limit collapse fixed:
app.jscallsapp.set('trust proxy', 1)soreq.ipreflects the real client behind the proxy. - ✅ Eager
Logpayload removed:SessionController.getAll/getOne(and shared variants) no longerincludethe fullLogarray. They call the newaggregateSummaries()(oneGROUP BYper request) and return lightweightstartDate/endDate/duration/maxSpeed/maxRpm. Paged telemetry stays onGET /api/sessions/:id/telemetry. - ✅ CSRF protection added:
middleware/csrfGuard.jsvalidates theOriginheader on all state-changing/apirequests against the expected origin and theCORS_ORIGINSallowlist (OWASP-recommended for JSON SPAs). ThepublicOriginoption handles deployments where nginx terminates HTTPS but forwards HTTP to the backend.
Medium priority
- SSRF guard has a DNS-rebinding TOCTOU.
lib/ssrfGuard.isSafeUrlresolves the hostname and validates the IP, butUploadControllerthen callsfetch(url)with the original hostname, which re-resolves at connect time (attacker can swap the DNS record to an internal IP between check and fetch). Partially addressed: the guard now validates against a resolved IP snapshot, reducing the window, but a full fix (pin the resolved IP in the request) is still pending. Fix: resolve once, validate, then connect to the validated IP (e.g. pass anURLwith the resolved address, or pin the resolved IP in the request). - ✅
ingestBufferconcurrency race + unbounded live buffer resolved. Aflushingboolean mutex prevents concurrent flush executions, and aMAX_BUFFER_SIZE = 50000hard cap drops oldest rows when exceeded (backpressure). Seeservices/ingestBuffer.js. - Torque PID keys
kc/kdare hardcoded.UploadControllerpromotesvalues.kc→engine_rpmandvalues.kd→vehicle_speed, but PIDs are user-configurable. Atorque-keysmapping table should drive which PIDs map to the promoted columns instead of hardcodingkc/kd.- ⚠️ Key format: Torque stores OBD‑II PIDs as hex keys without leading
zeros — PID 0x0C (RPM) →
kc, PID 0x0D (Speed) →kd. This is the native Torque key format; never usek4/k5(decimal OBD‑II PIDs) ork0c/k0d(zero‑padded hex). - ⚠️ Zero‑safe extraction: always use the pattern
values.key != null ? Number(values.key) : nullinstead ofNumber(values.key) || null. The latter discards legitimate zero values (idle RPM, stopped vehicle speed).
- ⚠️ Key format: Torque stores OBD‑II PIDs as hex keys without leading
zeros — PID 0x0C (RPM) →
Low priority
- Empty
CORS_ORIGINSblocks the cross-origin SPA.app.jsbuilds the CORS origin allowlist fromprocess.env.CORS_ORIGINS. If unset/empty, the allowlist is[]and all cross-origin/apirequests are refused. Must be set in production. - SPA build not served by Express.
app.jsserves the legacypublic/directory;apps/frontend/distis not served. Confirm the deploy topology (separate origin/CDN, or an nginx layer proxying/apito the backend) — both are acceptable, but the choice affects cookie/CSRF handling. log_1mincontinuous aggregate is unused. The 1-minute continuous aggregate exists but no endpoint reads from it. Consider serving dashboard overviews from it to reduce load on the raw hypertable.- ✅
durationnow formatted + stale comments swept.SessionControllerformatsdurationinto a compact human string (e.g."1h 2m 5s") via a nativeformatDuration()helper that replaces the removedmoment-duration-formatdependency; the legacyaddStartEndDatamutation path is gone and stale302/addStartEndDatacomments were removed from backend + frontend.
Follow-up features (post-MVP)
- Upload rate limit is now env-tunable + token-exempt.
routes/api.jscaps/uploadatUPLOAD_RATE_LIMIT_MAX(default 600) perUPLOAD_RATE_LIMIT_WINDOW_MS(default 60000). WhenUPLOAD_API_TOKENis set, a matchingAuthorization: Bearer <token>header (a Torque app feature) bypasses the limiter so the known uploader's reconnect bursts never get429'd. The exemption is keyed on a secret token, not a spoofable query param. - Registration can be disabled. Two layers: the env var
DISABLE_REGISTRATION('true') is a hard kill-switch, and the runtimeSettingssingleton row (disableRegistrationboolean, created byinfra/timescale/settings.sql) is togglable by any logged-in user viaGET/PUT /api/settings.GET /api/settingsORs in the env value so the SPA hides the signup form correctly when the env switch is active.UserController.registerenforces both and returns403JSON. The SPA hides the signup form on/loginand/registerand a new/settingspage exposes the toggle. Operator model: the app is single-operator, so ANY authenticated account may flip the toggle (there is no RBAC). Documented as intended, not a bug. - Upload API Token UI. The
/settingspage additionally lets users generate, view (one-time), copy, and clear the upload Bearer token. The token is stored in theSettingsDB row; when theUPLOAD_API_TOKENenv var is set, the UI reports the token as env-managed and disables the generate/clear buttons.GET /api/settingsreturnshasUploadApiToken/tokenFromEnvbooleans, andPOST /api/settings/upload-tokengenerates a new random hex token. - PID Decode + Multi-series Overlay Chart. The ReplayDashboard now features
a single
OverlayChart.tsx(replaces the old dualTimeSeriesChart.tsx) that renders all selected telemetry sources on a shared time axis with per-unit-group y-axes. APidTogglePanellets users search, filter by category, and toggle metrics on/off. A collapsibleDecodedMetricsTableshows min/max/avg/last for every PID. ThepidDecode.tsengine auto-discovers PID sources from thevaluesJSONB column using embedded Torque metadata (userFullName*/userUnit*/defaultUnit*) with a curated fallback map for standard OBD-II PIDs. A pre-existingRangeErrorfrom spread-into-Math.maxat ~10k frames has also been fixed. The oldTimeSeriesChart.tsxwas deleted. - ✅ react-router v8 upgrade (Plans 045 + 047). The app is on
react-router8.3.0 (exact pin, imports from thereact-routerpackage). Plan 045 migrated fromreact-router-dom6 (resolving GHSA-wrjc-x8rr-h8h6 and GHSA-337j-9hxr-rhxg on the 6.x line), and Plan 047 completed the v8 upgrade, which also resolves GHSA-qwww-vcr4-c8h2 (high, RSC-mode CSRF, react-router 7.12.0–8.2.0 — fixed in 8.3.0).
11. Status
- Core features complete: ingestion, TimescaleDB migration, paged telemetry, React replay dashboard (overlay chart + imperative Leaflet marker), CSV export, session management, BYOK AI analysis.
- Auth contract resolved and re-reviewed PASS.
- Verification: frontend via
npm run build(tsc --noEmit && vite build), backend vianode -csyntax checks. - Additional features implemented:
- Env-tunable upload rate limit with token-based burst exemption (a matching Bearer token bypasses the limiter).
- Runtime-toggleable registration (
Settingssingleton +DISABLE_REGISTRATIONenv kill-switch + SPA/settingstoggle). - Upload API Token UI on the
/settingspage (generate, view once, copy, clear; env override respected). - PID Decode + Multi-series Overlay Chart:
pidDecode.tsauto-discovers all OBD-II PIDs from thevaluesJSONB;OverlayChart.tsxrenders multiple series with per-unit-group y-axes;PidTogglePanelprovides search, category filtering, and selection management;DecodedMetricsTableshows per-PID aggregates. RangeErroron large datasets fixed (safeMaxreduce replaces spread-into-Math.max).- BYOK AI analysis — connect any OpenAI-compatible LLM provider for per-session diagnostic insights. SSE streaming, cost confirmation dialog, syntax-highlighted markdown output.
- DeepSeek first-class —
deepseek-v4-flash/deepseek-v4-prowith toggleable Thinking Mode and configurable reasoning effort (High / Max). Migration 006 addsllmThinkingModeandllmReasoningEffortcolumns. - LLM API keys encrypted at rest with AES-256-GCM via
LLM_ENCRYPTION_KEY. - SSRF guard (
lib/ssrfGuard.js) validates custom LLM endpoints. - Docker-based deployment with GHCR images (
docker-compose.yml). - Non-root backend container (
appuser), unprivileged nginx frontend.
- Session list pagination + vehicle filtering —
GET /api/sessionsacceptslimit,offset, andvehicleIdquery params (returns{ sessions, total, limit, offset }withvehicleId/vehicleNameper session). The frontendSessionBrowserpaginates via a "Load More" button and provides a vehicle filter dropdown. - Dev tooling: ESLint 10 (
eslint.config.js), husky 9 + lint-staged 17 (pre-commit lint + syntax check), CI pipeline (.github/workflows/ci.yml) running on push/PR todevelopment(Node 22), and automated semver version bump (.github/workflows/version-bump.yml) on push tomaster. - Session Notes (Plan 040) —
notesTEXT column on Sessions,PATCH /api/sessions/notes/:sessionIdendpoint, auto-save textarea in the replay dashboard. Migration:008_add_session_notes.sql. - Multi-Vehicle Support (Plan 041) — full
Vehiclemodel (name, make, model, year, engineCc, isDefault) with userId FK. Sessions gain nullablevehicleIdFK. CRUD endpoints at/api/vehicles/*, session reassign viaPATCH /api/sessions/:sessionId/vehicle. UploadController resolves Torque'svparam to a vehicle. Frontend:VehicleManagerin Settings with add/edit/delete/default, vehicle filter in session list, vehicle column in session table, reassign dialog in replay dashboard. Migration:009_add_vehicles.sql. - Improved LLM Analysis Prompt (Plan 042) —
lib/llmPrompt.jswas rewritten with five exported functions (up from two):computeSummaryStats()pre-computes min/max/mean/median per PID with null-safe filtering;resampleTelemetry()uniformly resamples across the full timeline replacing head/tail slicing;buildTelemetryCsv()outputs token-efficient CSV instead of Markdown tables;buildContext()andbuildAnalysisPrompt()were cleaned up and now include pre-calculated statistical aggregates, four diagnostic guardrails, dynamic engine size injection, and five specific analysis categories. See section 8 for full details. - Configurable LLM token limit + provider status (Plan 043) —
llmMaxTokenssetting (INTEGER, default 16384, range 2048–32768; migration010_add_llm_max_tokens.sql) replaces the hardcoded 8192max_tokensacross all LLM providers. Settings UI gains a general "Max Output Tokens" input and an expanded provider status display (human-readable provider name, Model, DeepSeek Thinking/Effort, Max tokens). Validation inUserController.updateSettings(400 on out-of-range); 7 new cases intest/settingsValidation.test.js. See section 8.4. - Dependabot fixes (Plan 044) —
react-router-domexact-pinned to 6.30.4 (transitivereact-router6.30.4,@remix-run/router1.23.3) andpostcss8.5.25, resolving 4 of 6 alerts. The remaining two react-router advisories were then resolved by the v7 migration (Plan 045). - react-router v7 migration (Plan 045) —
react-router-dom6.30.4 replaced withreact-router7.18.2 (exact pin); all 8 frontend files now import fromreact-router. Resolves GHSA-wrjc-x8rr-h8h6 and GHSA-337j-9hxr-rhxg. The v8 upgrade was completed in Plan 047. - Configurable Data Retention Policy (Plan 046) —
retentionEnabled(BOOLEAN, default false — opt-in) andretentionDays(INTEGER, default 365, range 90–365) on the Settings singleton (migration011_add_retention_settings.sql).PUT /api/settingsvalidates both fields (400 on non-boolean / non-integer / out-of-range) and applies a TimescaleDBadd_retention_policy/remove_retention_policyon theLogshypertable using a remove-then-add idempotent pattern; the response includesretentionPolicyApplied. Frontend Settings page gains a "Data Retention" card (enable Switch + 90/120/180/365-day select, local error state, rollback on save failure). Validation mirrored intest/settingsValidation.test.js(10 new cases; suite now 61 tests). - Bleeding-edge dependency upgrade (Plan 047) — backend (root
package.json):joi18.2.3,express-rate-limit8.6.2,pg8.22.0,cors2.8.6,express-session1.19.0,nodemon3.1.14,globals17.9.0,lint-staged17.3.0. Frontend:react/react-dom19.2.8,react-router8.3.0 (exact pin — replacesreact-router-dom, resolves GHSA-qwww-vcr4-c8h2),vite8.2.0,@vitejs/plugin-react5.2.0,typescript7.0.2,tailwindcss4.3.3 +@tailwindcss/vite4.3.3,zustand5.0.14,@tanstack/react-query5.101.4,react-markdown10.1.0,react-leaflet5.0.0,@types/react19.2.18,@types/react-dom19.2.4. Infra: both Dockerfiles onnode:22-bookworm-slim, CI workflows on Node 22,timescale/timescaledb:2.29.1-pg16in compose. - Tremor replaced with native Tailwind (Plan 049) — the
@tremor/reactdependency was removed; every Tremor component was reimplemented with plain Tailwind utilities, including a new accessibleToggleswitch (components/ui/Toggle.tsx, sr-only label).index.cssdropped the Tremor safelist directives and typography tokens. Bundle shrunk ~65 kB; all chunks now <400 kB (largest ~380 kB echarts) via RolldowncodeSplittinggroups invite.config.ts. Seedocs/architecture.md§3.8. - Remaining open issues: SSRF TOCTOU (partially addressed — see section 10 above).
- Cross-vehicle analysis history —
GET /api/analyseslists all analyses across sessions (paginated, optional vehicle filter);GET /api/analyses/exportstreams all analyses as a Markdown file;GET /api/sessions/:sessionId/analyses/:analysisIdreturns a single analysis with full response/reasoning. - Extracted controller helpers —
SessionControllernow usesloadOwnedSession(),decorateWithSummaries(), andaggregateSummaries()to eliminate duplicate code and the N+1 query pattern. - Joi validation schemas —
lib/validators.jscentralises input validation with Joi schemas for session operations (rename, notes, cut, filter, copy, join, addLocation) and vehicle CRUD (create/update), plus pure validation helpers for LLM settings.
12. Alternative Setup Methods
The sections below cover building from source and manual (non-Docker) setup. For
most users, the Docker quick start in the README or the full deployment guide
(docs/deployment.md) is sufficient.
Build from source
git clone https://github.com/moesix/torque-dash-next.git
cd torque-dash-next
# **Required:** generate session keys (app crashes on startup if missing)
export SESSION_KEYS="$(openssl rand -hex 24)"
# **Required for production (2026 baseline):** upload API token for Torque
# Pro authentication. Alternatively generate from the Settings UI after first
# login (an env-set token overrides and locks the UI).
export UPLOAD_API_TOKEN="$(openssl rand -hex 24)"
docker compose up -d --build
Then open http://localhost:8080.
- On first boot the backend creates the database tables, turns the
Logstable into a TimescaleDB hypertable, and seeds theSettingsrow. Data is persisted in thepgdatavolume. Any unique indexes on the hypertable must include the partition column (timestamp) — the migration creates these automatically. - Register the first account at the sign-up page, then sign in.
- For Torque Pro uploads, set
UPLOAD_API_TOKEN(below) and point the app athttps://<host>/api/uploadwith the matching bearer token. - After adding all user accounts, disable public registration via the Settings
UI or set
DISABLE_REGISTRATION=trueto prevent unauthorized sign-ups.
Production note:
SESSION_KEYSandDATABASE_URLare required (the app crashes on startup if missing). SetCOOKIE_SECURE=truebehind a TLS-terminating proxy. The compose defaults are for local/http use.
Manual setup (without Docker)
Backend
npm install
createdb torquedash
export DATABASE_URL=postgres://user:pass@localhost:5432/torquedash
export SESSION_KEYS="$(openssl rand -hex 24)" # Required — app crashes without it
node scripts/migrate.js # creates tables + hypertable + Settings row
npm start # or: node app.js
Frontend
cd apps/frontend
npm install
npm run dev # dev server with HMR, proxies /api -> http://localhost:3000
# production build:
npm run build # outputs apps/frontend/dist
For a production SPA, serve apps/frontend/dist behind a reverse proxy that
forwards /api to the backend (the included apps/frontend/nginx.conf does this).
Existing data: PID column backfill
If you have existing sessions uploaded before July 2026, their
engine_rpmandvehicle_speedcolumns may contain stale or incorrect values because Torque stores the PID keys askc(RPM) andkd(Speed) — not the legacyk4/k5that the previous code expected. Run the backfill migration to repair existing data:-- infra/timescale/migrations/002_backfill_pid_columns.sql UPDATE "Logs" SET engine_rpm = CASE WHEN (values->>'kc') ~ '^-?\d+(\.\d+)?$' THEN (values->>'kc')::numeric ELSE NULL END, vehicle_speed = CASE WHEN (values->>'kd') ~ '^-?\d+(\.\d+)?$' THEN (values->>'kd')::numeric ELSE NULL END WHERE values ? 'kc' AND values ? 'kd';Apply it via your database console or include it in your migration run. It is idempotent — safe to re-run.
Express 5 migration (future)
The app uses Express 4.22.x. Express 5 (5.2.x) is the current major. Known breaking changes relevant here:
app.use('*', ...)wildcard must becomeapp.use((req, res) => ...)(no path argument) orapp.use('/*splat', ...).- Async error propagation is built-in (no need for try/catch wrappers in route handlers — Express 5 catches rejected promises automatically).
req.queryreturns a plain object (no prototype).
Migration is deferred until integration tests exist (plan 050/069). The
wildcard at app.js:92 is the only breaking pattern.