Compare commits

...

17 Commits

Author SHA1 Message Date
tony ff39601303 fix(tesla): skip OAuth on car after pair-code if desktop already linked Tesla
The pair-code flow always called startTeslaConnect() afterwards, which
redirected the car browser to auth.tesla.com even when the server already
held tokens from the desktop OAuth. Now connectIfNeeded() re-fetches
status post-login and short-circuits when connected: true. The car flow
becomes: enter 6-digit code → 'Tesla connected · Carried over from your
other device — ready to go.' No second OAuth.
2026-06-07 00:34:37 +01:00
tony 9f489d1fb6 fix(security): tighten pair-code defences per follow-up review
- Per-code failure threshold dropped 5 → 3. After 3 wrong guesses
  during a code's 5-min TTL, the code is burned and the owner must
  re-mint. Still tolerates the occasional typo while collapsing the
  brute-force window further.
- Cap concurrent active codes to 1: minting a new code clears any
  prior code. Single-user app, no need to juggle multiple.
- Add a global brute-force lockout: after 100 failed attempts in
  any 5-minute window, the redeem endpoint returns 429 endpoint_locked
  for the next 10 minutes, regardless of source IP. Logged at error
  level so the operator notices.
2026-06-01 10:21:03 +01:00
tony 36118b4a4f fix(security): harden owner pair-code against brute-force
- Set express trust proxy=1 so req.ip is the real client (one Dokku
  nginx hop). Drop the X-Forwarded-For fallback — it was client-spoofable.
- Add a global redeem rate limit (30/min across all IPs) so rotating
  proxies can't outpace the per-IP limiter.
- Every failed redeem now decrements life from all active pair codes
  via a shared failures counter. After 5 wrong guesses during a code's
  5-minute TTL, the owner's real code gets burned and they must re-mint.
  This makes the 20-bit entropy of a 6-digit code defensible: an
  attacker doesn't know which code is real and can't probe long enough
  to find it before triggering the burn.
2026-06-01 10:16:30 +01:00
tony 90a4459e63 feat: 6-digit pair code login + responsive layout for narrow car browsers
Pair code:
- POST /api/auth/owner/pair-code (owner-gated) mints a single-use,
  5-minute, 6-digit code held in memory.
- POST /api/auth/owner/pair-redeem swaps the code for an owner cookie,
  with basic IP-based rate limiting (max 8 attempts/min).
- OwnerLoginModal gains a tab: "Use 6-digit code" (default) vs the
  long secret. The PIN input is big, monospaced, numeric-only.
- Small smartphone icon next to the connected Tesla chip opens
  OwnerPairCodeDisplay, which shows the code in 56px type with a
  live countdown so it's tappable on the touchscreen of the other
  device while you stare at it.

Responsive layout:
- TopBar wraps when narrower than the chip row. minWidth on OD strip
  drops 380→280 and chat composer drops 360→240 so they stack cleanly.
- Main body switches to flex-col below the lg breakpoint (Tailwind
  1024px), so the map sits on top of the stops rail on Tesla 3/Y
  portrait and any window not maximised. Map gets 40vh min-height
  so it never disappears.
2026-06-01 10:10:30 +01:00
tony 10235aaa32 feat(pwa): in-car PWA notifications use a full-screen ack dialog
Small sonner toasts are unreadable + untappable on a car dash.
Move SW registration into <SwNotifier/> so we have React state:
- Desktop / small-screen: normal sonner toasts (unchanged).
- In-car (detectInCar().isInCar): full-screen modal with 56px-tall
  buttons, big copy, finger-friendly Got it / Reload + Later actions.
- offline-ready dismisses on tap; needs-refresh has Later + Reload.
2026-05-31 23:17:11 +01:00
tony b125f9a723 fix(mock): allow mock Tesla in production builds (already owner-gated for real data) 2026-05-31 23:13:22 +01:00
tony a90c8a9354 feat: battery-aware itinerary nudge + PWA / offline support
Battery nudge:
- lib/batteryPlan: project SoC through the planned stops using current
  Tesla battery (or 80% default), 85% of rated range, and conservative
  per-stop charge rates (Supercharger 1.5%/min, dest charger 0.4%/min).
- LegRow shows "arrive X%" plus a tone (red/amber/transparent) and a
  TIGHT / WON'T REACH chip if the projected arrival is below 15%/5%.
- Top-of-itinerary banner when any leg is danger or warning, naming the
  current SoC source (live Tesla vs assumed 80%).

PWA / offline:
- vite-plugin-pwa with autoUpdate strategy; ServiceWorker registered in
  main.tsx (prod-only).
- Workbox cache strategies:
    map tiles      CacheFirst, 30 days, 800 entries
    nominatim/OSRM NetworkFirst with cached fallback
    /api/*         NetworkFirst, short TTL, last response stays usable
- App shell precached so the planner renders offline; navigateFallback
  ensures deep links serve index.html without a network round trip.
- manifest.webmanifest + theme-color + favicon.svg (replaces dead
  /tesla-icon.svg reference). Installable to home screen.
- Sonner toasts on offline-ready and update-available.
2026-05-31 23:07:34 +01:00
tony 381eb18cd3 feat(tesla): collapse OD strip when connected, sync with in-car nav destination
- Server: include active_route_destination (lat/lng/eta) from drive_state.
- When Tesla is connected, the Origin → Destination strip collapses to a
  single ConnectedTripStrip: "From car · Where to?". The origin is implicit
  (the car's GPS), the manual From input + crosshair button disappear.
- If Tesla nav already has a destination, it auto-fills as the trip
  destination; if the user has typed something else, an inline "Use Tesla
  nav" button offers a one-tap swap.
- Mocks: driving + charging scenarios include an activeRoute so the flow
  is testable end-to-end via ?mockTesla=driving / ?mockTesla=charging.
2026-05-31 22:58:42 +01:00
tony 7265103573 feat(tesla): charging widget + in-car From pill + hide vehicle picker
- ChargingWidget: green topbar pill with live kW + battery + minutes-to-target,
  shown only when state.chargingState === 'Charging'. Animated bolt icon.
- Vehicle picker chip is hidden whenever Tesla is connected (anywhere, not
  just in-car) — we already know the car from vehicle_config.
- When in-car AND Tesla connected, the From input collapses to a static
  pill with a Tesla badge so the driver doesn't have to type on the touch
  keyboard. Destination input remains editable.
2026-05-31 22:52:15 +01:00
tony 25d2779c39 fix(client): add vite/client types reference for import.meta.env 2026-05-31 22:43:44 +01:00
tony 5b97f5b873 feat(tesla): client-side mock + driving-mode card
- teslaMock: ?mockTesla=parked|driving|charging|asleep returns canned
  state, persisted via localStorage. Bypasses Fleet API entirely;
  short-circuited off in import.meta.env.PROD so prod can't enable it.
  Driving scenario animates battery + position so the UI feels alive.
- useTesla polls every 3s when mocking (vs 60s real) so animation works.
- MOCK badge in the top bar cycles scenarios in-place.

- DrivingMode: full-screen takeover when shiftState != P (or ?drivingMode=1).
  Battery + range chip, big speed readout, hero next-stop card with name,
  distance, ETA, "send to Tesla nav" button, charging strip at the bottom
  when plugged in, exit button for debug. Picks nearest planned stop to
  the car's current GPS as the "next stop".
2026-05-31 22:41:24 +01:00
tony f793b526aa fix(security): owner auth gate, OAuth state cookie binding, 0600 token perms
- Add OWNER_SECRET-based session: signed HMAC cookie, /api/auth/owner login,
  requireOwner middleware. All Tesla routes refuse 401 without it.
- Bind OAuth state to a SameSite=Lax httpOnly cookie at /start, validate
  match in /callback with constant-time compare. Refuses unmatched callbacks.
- Token store now mkdir 0700, writeFile + rename atomic, mode 0600 with
  defensive chmod. Owner-only on disk.
- VIN masked to last 4 in responses; partner-register no longer echoes raw
  Tesla body to clients; coord bounds checked on send-to-nav.
- Client: useTesla also tracks owner status; Connect Tesla button opens an
  OwnerLoginModal when not authenticated, then continues to Tesla OAuth.

Conscious deferrals:
- Explicit CSRF tokens on POST routes: mitigated by SameSite=Lax cookies
  + same-origin CORS. Will revisit if cross-origin clients land.
- At-rest token encryption: deferred for single-user app; tokens are on a
  0700 Dokku volume readable only by the app uid. Will add AES-GCM if we
  multi-tenant.
2026-05-31 22:32:22 +01:00
tony d705669dda feat(tesla): real Fleet API integration — OAuth, vehicle state, send-to-nav
Server:
- teslaTokenStore: file-backed token store at /app/data/tesla-tokens.json
- teslaClient: OAuth (authorize/code-exchange/refresh), Fleet API GET/POST,
  listVehicles, getVehicleData, wake, sendNavigationRequest, getAppToken,
  registerPartnerAccount; auto-rotates refresh tokens 60s before expiry
- /api/tesla/status, /api/auth/tesla/start, /api/auth/tesla/callback,
  /api/tesla/state, /api/tesla/wake, /api/tesla/send-to-nav,
  /api/tesla/disconnect, /api/tesla/register-partner
- State includes battery, range (mi→km), charging power/eta, GPS,
  shift_state, model/trim auto-detected from vehicle_config

Client:
- useTesla hook: auto-fetches status, polls live state every 60s when connected
- Connect Tesla chip in TopBar; on connect shows battery% + range
- Per-stop "Send to Tesla nav" button (only when Tesla connected)
- "Use my location" button prefers vehicle GPS over browser geolocation
- Auto-detects model/trim from Tesla and updates the vehicle picker
- When in-car AND Tesla connected: auto-fills origin from car's GPS,
  hides the vehicle chip (we know the car), hides GPX export and Share
2026-05-31 22:23:38 +01:00
tony d27381cae3 fix(server): use regex catch-all (Express 5 wildcard syntax change) 2026-05-31 22:00:38 +01:00
tony 58074a0afb fix(types): allow style prop on IconComponent 2026-05-31 21:58:39 +01:00
tony 8913d2f341 fix(types): loosen icon component type for lucide-react compat 2026-05-31 21:57:42 +01:00
tony fbea75fac6 fix(docker): drop shared npm cache mount and skip husky postinstall 2026-05-31 21:55:55 +01:00
21 changed files with 6712 additions and 213 deletions
Binary file not shown.
+3 -3
View File
@@ -9,11 +9,11 @@ WORKDIR /app
# Root deps # Root deps
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund RUN npm ci --no-audit --no-fund --ignore-scripts
# Client deps # Client deps
COPY client/package.json client/package-lock.json* ./client/ COPY client/package.json client/package-lock.json* ./client/
RUN --mount=type=cache,target=/root/.npm npm --prefix client ci --no-audit --no-fund RUN npm --prefix client ci --no-audit --no-fund --ignore-scripts
# Source # Source
COPY tsconfig.json ./ COPY tsconfig.json ./
@@ -33,7 +33,7 @@ WORKDIR /app
# Only ship the prod deps + built artefacts # Only ship the prod deps + built artefacts
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev --no-audit --no-fund \ RUN npm ci --omit=dev --no-audit --no-fund --ignore-scripts \
&& npm cache clean --force && npm cache clean --force
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
+3 -1
View File
@@ -2,7 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/tesla-icon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<meta name="theme-color" content="#0a0a0c" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tesla Trip Planner • Grok</title> <title>Tesla Trip Planner • Grok</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
+4334 -89
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@tonycodes/auth-react": "^1.4.0", "@tonycodes/auth-react": "^1.4.0",
"dompurify": "^3.2.4",
"framer-motion": "^11.0.0", "framer-motion": "^11.0.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.303.0", "lucide-react": "^0.303.0",
@@ -20,10 +21,10 @@
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"react-router-dom": "^6.21.1", "react-router-dom": "^6.21.1",
"sonner": "^1.4.0", "sonner": "^1.4.0",
"tailwind-merge": "^2.2.0", "tailwind-merge": "^2.2.0"
"dompurify": "^3.2.4"
}, },
"devDependencies": { "devDependencies": {
"@types/dompurify": "^3.0.5",
"@types/leaflet": "^1.9.14", "@types/leaflet": "^1.9.14",
"@types/node": "^20.10.0", "@types/node": "^20.10.0",
"@types/react": "^18.2.47", "@types/react": "^18.2.47",
@@ -34,6 +35,7 @@
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"typescript": "^5.3.2", "typescript": "^5.3.2",
"vite": "^5.0.8", "vite": "^5.0.8",
"@types/dompurify": "^3.0.5" "vite-plugin-pwa": "^1.3.0",
"workbox-window": "^7.4.1"
} }
} }
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#e31937"/>
<path d="M9 21l5.5-10h3L23 21l-2.7-1.5L17 14l-3.5 5.5L9 21z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 200 B

+103
View File
@@ -0,0 +1,103 @@
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { registerSW } from 'virtual:pwa-register';
import { detectInCar } from '../lib/incar';
type Kind = 'offline-ready' | 'needs-refresh';
export function SwNotifier() {
const [event, setEvent] = useState<Kind | null>(null);
const [updateSW, setUpdateSW] = useState<((reload?: boolean) => void) | null>(null);
const inCar = detectInCar().isInCar;
useEffect(() => {
if (!('serviceWorker' in navigator)) return;
const fn = registerSW({
immediate: true,
onNeedRefresh() { setEvent('needs-refresh'); },
onOfflineReady() { setEvent('offline-ready'); },
});
setUpdateSW(() => fn);
}, []);
// Desktop / small-screen UX: use a normal toast.
useEffect(() => {
if (!event || inCar) return;
if (event === 'offline-ready') {
toast.success('Ready to use offline', {
description: 'Your planned trip + map tiles will be available without signal.',
});
setEvent(null);
} else if (event === 'needs-refresh') {
toast('New version available', {
action: { label: 'Reload', onClick: () => updateSW?.(true) },
duration: Infinity,
});
setEvent(null);
}
}, [event, inCar, updateSW]);
if (!inCar || !event) return null;
// In-car: render a big full-screen acknowledgement with finger-friendly button.
const isUpdate = event === 'needs-refresh';
const dismiss = () => setEvent(null);
const action = () => {
if (isUpdate) updateSW?.(true);
else dismiss();
};
return (
<div
onClick={dismiss}
className="fixed inset-0 grid place-items-center p-8"
style={{ zIndex: 9999, background: 'rgba(5,5,8,0.82)' }}
>
<div
onClick={(e) => e.stopPropagation()}
className="w-full max-w-[560px] overflow-hidden"
style={{
background: 'var(--gd-bg-2)',
border: '1px solid var(--gd-border-2)',
borderRadius: 20,
boxShadow: '0 24px 60px rgba(0,0,0,0.55)',
}}
>
<div className="px-8 py-7" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<div className="text-[12px] uppercase tracking-[0.2em] mb-3"
style={{ color: isUpdate ? 'var(--gd-amber)' : 'var(--gd-green)' }}>
{isUpdate ? 'New version available' : 'Ready to use offline'}
</div>
<div className="text-[22px] font-semibold leading-[1.25]">
{isUpdate
? 'Reload to get the latest planner update'
: 'Your trip will be available without signal'}
</div>
<div className="text-[14px] mt-3" style={{ color: 'var(--gd-text-2)' }}>
{isUpdate
? 'Your current plan stays the same. We just refresh the app.'
: 'Planned route, map tiles, and last-known itinerary are saved on this device. You can keep using the planner if LTE drops.'}
</div>
</div>
<div className="px-8 py-5 flex items-center gap-3" style={{ borderTop: '1px solid var(--gd-border)' }}>
{isUpdate && (
<button
onClick={dismiss}
className="flex-1 h-14 text-[16px] rounded-2xl"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
>
Later
</button>
)}
<button
onClick={action}
className="flex-[2] h-14 text-[16px] rounded-2xl font-semibold"
style={{ background: 'var(--gd-red)', color: '#fff', boxShadow: '0 6px 24px rgba(227,25,55,0.35)' }}
>
{isUpdate ? 'Reload' : 'Got it'}
</button>
</div>
</div>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
// Simple charge-aware battery projection through an itinerary.
//
// Inputs:
// - startSoC starting battery % (Tesla live SoC if connected, else 80)
// - effectiveRangeKm the realistic full-charge range (~85% of rated)
// - stops + legs in planned order
//
// Output: per-stop { arriveSoC, leaveSoC, risk }, plus a flag if any leg
// would arrive below the danger threshold.
// Structural shapes — we only need the fields we use, so we don't drag the
// full Stop/Leg types out of TeslaTripPlanner.
interface BPStop {
id: string;
type: string;
chargeMinutes?: number;
}
interface BPLeg {
fromId: string;
distanceKm: number | null;
}
export type BatteryRisk = 'ok' | 'warn' | 'danger';
export interface BatteryAtStop {
stopId: string;
arriveSoC: number; // % on arrival (can dip negative if range exhausted)
leaveSoC: number; // % after planned charging
risk: BatteryRisk;
}
export interface BatteryPlan {
perStop: BatteryAtStop[];
hasDanger: boolean; // any stop with arriveSoC < DANGER_THRESHOLD
hasWarning: boolean; // any stop with arriveSoC < WARN_THRESHOLD
}
const WARN_THRESHOLD = 15;
const DANGER_THRESHOLD = 5;
// Rough charge rates per stop type. Realistic, conservative.
function chargeRatePctPerMin(type: string): number {
switch (type) {
case 'supercharger': return 1.5; // ~250 kW peak, average ~1.5%/min
case 'destination-charger': return 0.4; // 11-22 kW
case 'hotel': return 0.4; // assume destination charging
default: return 0;
}
}
export function computeBatteryPlan(
stops: BPStop[],
legs: BPLeg[],
startSoC: number,
effectiveRangeKm: number,
): BatteryPlan {
const legByFromId = new Map<string, BPLeg>();
for (const l of legs) legByFromId.set(l.fromId, l);
const perStop: BatteryAtStop[] = [];
let current = startSoC;
let hasDanger = false;
let hasWarning = false;
for (let i = 0; i < stops.length; i++) {
const stop = stops[i]!;
let arrive = current;
if (i > 0) {
const prev = stops[i - 1]!;
const leg = legByFromId.get(prev.id);
const km = leg?.distanceKm ?? 0;
const drop = effectiveRangeKm > 0 ? (km / effectiveRangeKm) * 100 : 0;
arrive = current - drop;
}
const risk: BatteryRisk = arrive < DANGER_THRESHOLD ? 'danger'
: arrive < WARN_THRESHOLD ? 'warn'
: 'ok';
if (risk === 'danger') hasDanger = true;
if (risk !== 'ok') hasWarning = true;
// Apply planned charging at this stop. Cap at 100, ignore if no charge planned.
const chargeMin = stop.chargeMinutes ?? 0;
const rate = chargeRatePctPerMin(stop.type);
const leave = Math.min(100, Math.max(arrive, arrive + chargeMin * rate));
perStop.push({ stopId: stop.id, arriveSoC: arrive, leaveSoC: leave, risk });
current = leave;
}
return { perStop, hasDanger, hasWarning };
}
+193
View File
@@ -0,0 +1,193 @@
import { useEffect, useRef, useState } from 'react';
import { isMockEnabled, getMockStatus, getMockState } from './teslaMock';
export interface TeslaStatus {
available: boolean;
connected: boolean;
ownerAuthenticated?: boolean;
connectedAt?: number | null;
vehicleId?: string | null;
vin?: string | null;
carType?: string | null;
trimBadging?: string | null;
reason?: string;
}
export interface OwnerStatus {
authenticated: boolean;
required: boolean;
}
export async function fetchOwnerStatus(): Promise<OwnerStatus> {
if (isMockEnabled()) return { authenticated: true, required: true };
const res = await fetch('/api/auth/owner/status');
if (!res.ok) return { authenticated: false, required: false };
return res.json();
}
export async function loginOwner(secret: string): Promise<boolean> {
const res = await fetch('/api/auth/owner', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret }),
});
return res.ok;
}
export async function logoutOwner(): Promise<void> {
await fetch('/api/auth/owner/logout', { method: 'POST' });
}
export interface OwnerPairCode {
code: string;
expiresInS: number;
}
export async function mintOwnerPairCode(): Promise<OwnerPairCode | null> {
const res = await fetch('/api/auth/owner/pair-code', { method: 'POST' });
if (!res.ok) return null;
const data = await res.json();
if (!data?.code) return null;
return { code: data.code, expiresInS: data.expiresInS ?? 300 };
}
export async function redeemOwnerPairCode(code: string): Promise<boolean> {
const res = await fetch('/api/auth/owner/pair-redeem', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
return res.ok;
}
export interface TeslaActiveRoute {
destination: string;
lat: number | null;
lng: number | null;
kmToArrival: number | null;
minutesToArrival: number | null;
trafficMinutesDelay: number;
}
export interface TeslaState {
connected: boolean;
asleep?: boolean;
battery: number | null;
rangeKm: number | null;
idealRangeKm?: number | null;
chargingState: string | null;
chargerPowerKw: number | null;
timeToFullCharge: number | null;
lat: number | null;
lng: number | null;
heading: number | null;
speedKmh: number | null;
shiftState: 'P' | 'R' | 'N' | 'D' | null;
odometerKm?: number | null;
carType?: string | null;
trimBadging?: string | null;
vin?: string | null;
vehicleName?: string | null;
softwareVersion?: string | null;
activeRoute?: TeslaActiveRoute | null;
fetchedAt: number;
}
export async function fetchTeslaStatus(): Promise<TeslaStatus> {
if (isMockEnabled()) return getMockStatus();
const res = await fetch('/api/tesla/status');
if (!res.ok) return { available: false, connected: false };
return res.json();
}
export async function fetchTeslaState(): Promise<TeslaState | null> {
if (isMockEnabled()) return getMockState();
const res = await fetch('/api/tesla/state');
if (res.status === 202) return null; // asleep
if (!res.ok) return null;
return res.json();
}
export async function wakeTesla(): Promise<boolean> {
if (isMockEnabled()) return true;
const res = await fetch('/api/tesla/wake', { method: 'POST' });
return res.ok;
}
export async function startTeslaConnect(): Promise<void> {
if (isMockEnabled()) {
window.location.href = `${window.location.pathname}?tesla_connected=1`;
return;
}
const res = await fetch('/api/auth/tesla/start');
if (!res.ok) throw new Error('Could not start Tesla OAuth');
const data = await res.json();
window.location.href = data.authorizeUrl;
}
export async function disconnectTesla(): Promise<void> {
if (isMockEnabled()) return;
await fetch('/api/tesla/disconnect', { method: 'POST' });
}
export async function sendToTeslaNav(args: { lat: number; lng: number; name?: string }): Promise<boolean> {
if (isMockEnabled()) {
console.log('[mock] sendToTeslaNav', args);
return true;
}
const res = await fetch('/api/tesla/send-to-nav', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
});
return res.ok;
}
/** Hook: subscribe to Tesla status + live state. Polls every 60s while connected. */
export function useTesla() {
const [status, setStatus] = useState<TeslaStatus | null>(null);
const [owner, setOwner] = useState<OwnerStatus | null>(null);
const [state, setState] = useState<TeslaState | null>(null);
const [stateLoading, setStateLoading] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const [s, o] = await Promise.all([fetchTeslaStatus(), fetchOwnerStatus()]);
if (!cancelled) { setStatus(s); setOwner(o); }
})();
return () => { cancelled = true; };
}, []);
// Live state polling. Only runs when the user is owner-authenticated AND
// the Tesla account is connected — anything else returns 401 and the poll
// would spam the log.
useEffect(() => {
if (!status?.connected || !owner?.authenticated) {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
setState(null);
return;
}
const tick = async () => {
setStateLoading(true);
try {
const s = await fetchTeslaState();
setState(s);
} finally {
setStateLoading(false);
}
};
tick();
// Poll faster when mocking so the driving scenario animates visibly.
pollRef.current = setInterval(tick, isMockEnabled() ? 3_000 : 60_000);
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } };
}, [status?.connected, owner?.authenticated]);
const refreshStatus = async () => {
const [s, o] = await Promise.all([fetchTeslaStatus(), fetchOwnerStatus()]);
setStatus(s); setOwner(o);
};
return { status, state, stateLoading, owner, refreshStatus };
}
+175
View File
@@ -0,0 +1,175 @@
// Client-side mock for the Tesla integration so we can iterate on UI without
// burning Fleet API calls (or sitting in the car).
//
// Activate via URL:
// ?mockTesla=parked parked, 65% battery, in Milton Keynes
// ?mockTesla=driving moving at 110 km/h, battery slowly draining
// ?mockTesla=charging plugged in at a Supercharger, 132 kW
// ?mockTesla=asleep connected but car asleep (state returns null)
// ?mockTesla=off disable mock (delete the stored flag)
//
// The flag persists in localStorage so reloads keep the scenario.
// Dev-only: import.meta.env.PROD short-circuits this off in the bundled build,
// so production users can't enable mock mode.
import type { TeslaStatus, TeslaState } from './tesla';
export type MockScenario = 'parked' | 'driving' | 'charging' | 'asleep';
const STORAGE_KEY = 'mockTesla';
export function isMockEnabled(): boolean {
return !!getMockScenario();
}
export function getMockScenario(): MockScenario | null {
if (typeof window === 'undefined') return null;
// Mock is allowed in production too: it only paints the UI with fake data,
// it never reaches the (owner-gated) Fleet API. The visible MOCK chip in
// the top bar makes the state obvious.
const urlParam = new URLSearchParams(window.location.search).get('mockTesla');
if (urlParam === 'off') {
window.localStorage.removeItem(STORAGE_KEY);
return null;
}
if (urlParam && isValidScenario(urlParam)) {
window.localStorage.setItem(STORAGE_KEY, urlParam);
return urlParam;
}
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored && isValidScenario(stored) ? stored : null;
}
function isValidScenario(s: string): s is MockScenario {
return s === 'parked' || s === 'driving' || s === 'charging' || s === 'asleep';
}
export function setMockScenario(s: MockScenario | null): void {
if (typeof window === 'undefined') return;
if (s == null) window.localStorage.removeItem(STORAGE_KEY);
else window.localStorage.setItem(STORAGE_KEY, s);
}
// ─── Canned vehicle identity ─────────────────────────────────────────────────
const MOCK_VEHICLE = {
vin: '5YJ3E1EA1KF0MOCKED',
carType: 'modely',
trimBadging: 'lrawd',
vehicleName: 'Mocky',
softwareVersion: '2026.5.14',
};
// Base location: Milton Keynes, MK7 8PJ
const BASE_LAT = 52.0186;
const BASE_LNG = -0.7064;
// Drift state for the "driving" scenario — battery slowly drains, position
// shuffles a bit so map markers move.
let driveBattery = 64;
let driveTickCount = 0;
export function getMockStatus(): TeslaStatus {
const s = getMockScenario();
if (!s) return { available: true, connected: false, ownerAuthenticated: true };
return {
available: true,
connected: true,
ownerAuthenticated: true,
connectedAt: Date.now() - 60 * 60 * 1000,
vehicleId: 'mock-1',
vin: `${MOCK_VEHICLE.vin.slice(-4)}`,
carType: MOCK_VEHICLE.carType,
trimBadging: MOCK_VEHICLE.trimBadging,
};
}
export function getMockState(): TeslaState | null {
const s = getMockScenario();
if (!s || s === 'asleep') return null;
const common = {
connected: true,
asleep: false,
carType: MOCK_VEHICLE.carType,
trimBadging: MOCK_VEHICLE.trimBadging,
vin: `${MOCK_VEHICLE.vin.slice(-4)}`,
vehicleName: MOCK_VEHICLE.vehicleName,
softwareVersion: MOCK_VEHICLE.softwareVersion,
odometerKm: 24_500,
heading: s === 'driving' ? 92 : 0,
fetchedAt: Date.now(),
};
if (s === 'parked') {
return {
...common,
battery: 65,
rangeKm: 320,
idealRangeKm: 330,
chargingState: 'Disconnected',
chargerPowerKw: null,
timeToFullCharge: null,
lat: BASE_LAT,
lng: BASE_LNG,
speedKmh: 0,
shiftState: 'P' as const,
};
}
if (s === 'charging') {
return {
...common,
battery: 47,
rangeKm: 240,
idealRangeKm: 250,
chargingState: 'Charging',
chargerPowerKw: 132,
timeToFullCharge: 0.55, // ~33 min
lat: 52.0461, // Baldock services
lng: -0.2024,
speedKmh: 0,
shiftState: 'P' as const,
activeRoute: {
destination: 'Eurotunnel Folkestone',
lat: 51.0901,
lng: 1.1340,
kmToArrival: 178,
minutesToArrival: 132,
trafficMinutesDelay: 8,
},
};
}
// driving — animate battery + position with each tick
driveTickCount++;
driveBattery = Math.max(8, driveBattery - 0.18);
return {
...common,
battery: Math.round(driveBattery),
rangeKm: Math.round(driveBattery * 4.9),
idealRangeKm: Math.round(driveBattery * 5.1),
chargingState: 'Disconnected',
chargerPowerKw: null,
timeToFullCharge: null,
// Crude eastbound drift across the East Anglian flatlands.
lat: BASE_LAT - 0.002 * driveTickCount,
lng: BASE_LNG + 0.008 * driveTickCount,
speedKmh: 112,
shiftState: 'D' as const,
activeRoute: {
destination: 'Cambridge Services M11',
lat: 52.1389,
lng: 0.1832,
kmToArrival: Math.max(0, 78 - driveTickCount * 0.6),
minutesToArrival: Math.max(0, Math.round((78 - driveTickCount * 0.6) / 110 * 60)),
trafficMinutesDelay: 0,
},
};
}
/** Reset the running driving simulation back to start. */
export function resetMockDrive(): void {
driveBattery = 64;
driveTickCount = 0;
}
+2
View File
@@ -5,6 +5,7 @@ import { Toaster } from 'sonner';
import App from './App'; import App from './App';
import './styles/globals.css'; import './styles/globals.css';
import { detectInCar, applyInCarClass } from './lib/incar'; import { detectInCar, applyInCarClass } from './lib/incar';
import { SwNotifier } from './components/SwNotifier';
applyInCarClass(detectInCar()); applyInCarClass(detectInCar());
@@ -13,6 +14,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<BrowserRouter> <BrowserRouter>
<App /> <App />
<Toaster position="top-center" richColors closeButton /> <Toaster position="top-center" richColors closeButton />
<SwNotifier />
</BrowserRouter> </BrowserRouter>
</React.StrictMode> </React.StrictMode>
); );
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />
+70 -1
View File
@@ -1,9 +1,78 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { resolve } from 'path'; import { resolve } from 'path';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png'],
manifest: {
name: 'Grok Drive',
short_name: 'GrokDrive',
description: 'AI-assisted Tesla road trip planner',
theme_color: '#0a0a0c',
background_color: '#0a0a0c',
display: 'standalone',
orientation: 'any',
start_url: '/',
scope: '/',
icons: [
{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' },
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,ico,woff,woff2}'],
globIgnores: ['**/*.map'],
navigateFallback: '/index.html',
navigateFallbackDenylist: [/^\/api\//, /^\/\.well-known\//],
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
runtimeCaching: [
// Map tiles — cache-first for offline viewing of the planned route.
{
urlPattern: ({ url }: { url: URL }) =>
url.hostname.endsWith('tile.openstreetmap.org')
|| url.hostname.endsWith('basemaps.cartocdn.com')
|| /tile/i.test(url.hostname),
handler: 'CacheFirst',
options: {
cacheName: 'map-tiles',
expiration: { maxEntries: 800, maxAgeSeconds: 60 * 60 * 24 * 30 },
cacheableResponse: { statuses: [0, 200] },
},
},
// Geocoding + routing APIs — network-first, with cached fallback.
{
urlPattern: ({ url }: { url: URL }) =>
url.hostname.endsWith('nominatim.openstreetmap.org')
|| url.hostname.endsWith('router.project-osrm.org'),
handler: 'NetworkFirst',
options: {
cacheName: 'geo-api',
networkTimeoutSeconds: 4,
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 7 },
cacheableResponse: { statuses: [0, 200] },
},
},
// Our own API — network-first, cached short-term so the last good
// response stays visible when LTE drops.
{
urlPattern: ({ url }: { url: URL }) => url.pathname.startsWith('/api/'),
handler: 'NetworkFirst',
options: {
cacheName: 'app-api',
networkTimeoutSeconds: 3,
expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
],
},
devOptions: { enabled: false },
}),
],
resolve: { resolve: {
alias: { alias: {
'@': resolve(__dirname, './src'), '@': resolve(__dirname, './src'),
+4
View File
@@ -25,6 +25,10 @@ export const env = {
grokEnabled: process.env.GROK_ENABLED !== 'false', grokEnabled: process.env.GROK_ENABLED !== 'false',
forceXaiApi: process.env.FORCE_XAI_API === 'true', forceXaiApi: process.env.FORCE_XAI_API === 'true',
// Owner auth — single-user gate for the Tesla integration until
// auth.tony.codes is wired in. Set OWNER_SECRET to a long random string.
ownerSecret: process.env.OWNER_SECRET || '',
// Tesla Fleet API // Tesla Fleet API
tesla: { tesla: {
// Public key served at /.well-known/appspecific/com.tesla.3p.public-key.pem // Public key served at /.well-known/appspecific/com.tesla.3p.public-key.pem
+12 -3
View File
@@ -11,10 +11,17 @@ import { logger } from './lib/logger.js';
import chatRoutes from './routes/chat.js'; import chatRoutes from './routes/chat.js';
import tripsRoutes from './routes/trips.js'; import tripsRoutes from './routes/trips.js';
import teslaRoutes from './routes/tesla.js'; import teslaRoutes from './routes/tesla.js';
import ownerRoutes from './routes/owner.js';
import { warnIfMisconfigured as warnOwnerAuth } from './lib/ownerAuth.js';
import { createOptionalAuth } from './lib/auth.js'; import { createOptionalAuth } from './lib/auth.js';
const app = express(); const app = express();
// We sit behind one Nginx hop (Dokku's per-app vhost). Trust exactly one
// proxy so req.ip reflects the real client and the rate limiters below
// can't be bypassed via a spoofed X-Forwarded-For header.
app.set('trust proxy', 1);
app.use(helmet({ contentSecurityPolicy: false })); app.use(helmet({ contentSecurityPolicy: false }));
app.use(cors({ origin: env.appUrl, credentials: true })); app.use(cors({ origin: env.appUrl, credentials: true }));
app.use(express.json({ limit: '2mb' })); app.use(express.json({ limit: '2mb' }));
@@ -38,8 +45,10 @@ if (auth) {
logger.info('Auth disabled — set AUTH_SECRET to enable user accounts'); logger.info('Auth disabled — set AUTH_SECRET to enable user accounts');
} }
// Tesla integration: serves the partner public key + OAuth callback. Mounted // Owner auth + Tesla integration. Tesla routes are owner-gated except the
// at the app root because Tesla's well-known path is fixed. // public .well-known partner-key path. Owner routes handle login/logout.
warnOwnerAuth();
app.use(ownerRoutes);
app.use(teslaRoutes); app.use(teslaRoutes);
app.use('/api', chatRoutes); app.use('/api', chatRoutes);
@@ -53,7 +62,7 @@ const __dirname = path.dirname(__filename);
const clientDist = path.resolve(__dirname, '../../client/dist'); const clientDist = path.resolve(__dirname, '../../client/dist');
if (existsSync(clientDist)) { if (existsSync(clientDist)) {
app.use(express.static(clientDist, { index: false, maxAge: '1h' })); app.use(express.static(clientDist, { index: false, maxAge: '1h' }));
app.get('*', (req, res, next) => { app.get(/.*/, (req, res, next) => {
// Don't shadow API or well-known paths. // Don't shadow API or well-known paths.
if (req.path.startsWith('/api') || req.path.startsWith('/.well-known')) return next(); if (req.path.startsWith('/api') || req.path.startsWith('/.well-known')) return next();
res.sendFile(path.join(clientDist, 'index.html')); res.sendFile(path.join(clientDist, 'index.html'));
+70
View File
@@ -0,0 +1,70 @@
import crypto from 'node:crypto';
import type { Request, Response, NextFunction } from 'express';
import { env } from '../config/env.js';
import { createLogger } from './logger.js';
const log = createLogger('owner-auth');
const COOKIE_NAME = 'owner_session';
const COOKIE_MAX_AGE_MS = 30 * 24 * 3600 * 1000;
const OWNER_ID = 'owner';
function expectedCookieValue(): string {
if (!env.ownerSecret) return '';
return crypto.createHmac('sha256', env.ownerSecret).update('owner').digest('hex');
}
export function setOwnerCookie(res: Response): void {
res.cookie(COOKIE_NAME, expectedCookieValue(), {
httpOnly: true,
secure: env.nodeEnv === 'production',
sameSite: 'lax',
maxAge: COOKIE_MAX_AGE_MS,
path: '/',
});
}
export function clearOwnerCookie(res: Response): void {
res.clearCookie(COOKIE_NAME, { path: '/' });
}
/** True when the request carries a valid owner session cookie. */
export function isOwnerAuthenticated(req: Request): boolean {
if (!env.ownerSecret) return false;
const got = (req as any).cookies?.[COOKIE_NAME];
if (typeof got !== 'string' || got.length === 0) return false;
const expected = expectedCookieValue();
if (got.length !== expected.length) return false;
try {
return crypto.timingSafeEqual(Buffer.from(got, 'hex'), Buffer.from(expected, 'hex'));
} catch {
return false;
}
}
/** Returns the authenticated owner id or null. Always a single 'owner' id today. */
export function ownerIdFromRequest(req: Request): string | null {
return isOwnerAuthenticated(req) ? OWNER_ID : null;
}
/** Express middleware: 401 if the request is not owner-authenticated. */
export function requireOwner(req: Request, res: Response, next: NextFunction): void {
if (isOwnerAuthenticated(req)) return next();
res.status(401).json({ error: 'auth_required' });
}
/** Validates an owner secret against env.ownerSecret in constant time. */
export function verifyOwnerSecret(input: unknown): boolean {
if (!env.ownerSecret || typeof input !== 'string') return false;
const a = Buffer.from(input);
const b = Buffer.from(env.ownerSecret);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
/** Logs a warning if owner auth isn't configured — Tesla routes will return 401. */
export function warnIfMisconfigured(): void {
if (!env.ownerSecret) {
log.warn('OWNER_SECRET is not set — Tesla routes will refuse all requests with 401. Set it to enable owner login.');
}
}
+187
View File
@@ -0,0 +1,187 @@
import { env } from '../config/env.js';
import { createLogger } from './logger.js';
import { teslaTokenStore, TeslaTokens } from './teslaTokenStore.js';
const log = createLogger('tesla-client');
// ─── Endpoints ──────────────────────────────────────────────────────────────
export const TESLA_AUTH_BASE = 'https://auth.tesla.com';
export const TESLA_FLEET_BASE = env.tesla.region === 'eu'
? 'https://fleet-api.prd.eu.vn.cloud.tesla.com'
: 'https://fleet-api.prd.na.vn.cloud.tesla.com';
export const TESLA_SCOPES = [
'openid',
'offline_access',
'vehicle_device_data',
'vehicle_location',
'vehicle_cmds',
'vehicle_charging_cmds',
].join(' ');
// ─── OAuth ──────────────────────────────────────────────────────────────────
export function buildAuthorizeUrl(state: string): string {
const params = new URLSearchParams({
response_type: 'code',
client_id: env.tesla.clientId,
redirect_uri: env.tesla.redirectUri,
scope: TESLA_SCOPES,
state,
prompt: 'login',
locale: 'en-GB',
});
return `${TESLA_AUTH_BASE}/oauth2/v3/authorize?${params.toString()}`;
}
export interface TokenResponse {
access_token: string;
refresh_token: string;
id_token?: string;
expires_in: number; // seconds
token_type: string;
scope?: string;
}
async function tokenRequest(form: URLSearchParams): Promise<TokenResponse> {
const res = await fetch(`${TESLA_AUTH_BASE}/oauth2/v3/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: form,
});
if (!res.ok) {
const text = await res.text();
log.error({ status: res.status, body: text.slice(0, 400) }, 'Tesla token request failed');
throw new Error(`Tesla OAuth ${res.status}: ${text.slice(0, 200)}`);
}
return res.json() as Promise<TokenResponse>;
}
export async function exchangeCodeForTokens(code: string): Promise<TokenResponse> {
return tokenRequest(new URLSearchParams({
grant_type: 'authorization_code',
client_id: env.tesla.clientId,
client_secret: env.tesla.clientSecret,
code,
redirect_uri: env.tesla.redirectUri,
audience: TESLA_FLEET_BASE,
}));
}
export async function refreshTokens(refreshToken: string): Promise<TokenResponse> {
return tokenRequest(new URLSearchParams({
grant_type: 'refresh_token',
client_id: env.tesla.clientId,
refresh_token: refreshToken,
scope: TESLA_SCOPES,
}));
}
// ─── Token helper ───────────────────────────────────────────────────────────
// Returns a valid access token for the user, refreshing if it's within 60s of
// expiry. Saves the rotated tokens back to the store.
export async function getAccessToken(userId: string): Promise<TeslaTokens | null> {
const stored = await teslaTokenStore.get(userId);
if (!stored) return null;
if (stored.expiresAt - Date.now() > 60_000) return stored;
try {
const fresh = await refreshTokens(stored.refreshToken);
const updated: TeslaTokens = {
...stored,
accessToken: fresh.access_token,
refreshToken: fresh.refresh_token || stored.refreshToken,
expiresAt: Date.now() + fresh.expires_in * 1000,
scope: fresh.scope || stored.scope,
};
await teslaTokenStore.set(userId, updated);
return updated;
} catch (err) {
log.warn({ userId, err: String(err) }, 'Token refresh failed — user must reconnect');
return null;
}
}
// ─── Fleet API calls ────────────────────────────────────────────────────────
async function fleetGet(token: string, path: string): Promise<any> {
const res = await fetch(`${TESLA_FLEET_BASE}${path}`, {
headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json' },
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Fleet GET ${path}${res.status}: ${text.slice(0, 200)}`);
}
return res.json();
}
async function fleetPost(token: string, path: string, body: unknown): Promise<any> {
const res = await fetch(`${TESLA_FLEET_BASE}${path}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(body || {}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Fleet POST ${path}${res.status}: ${text.slice(0, 200)}`);
}
return res.json();
}
export async function listVehicles(token: string): Promise<any[]> {
const out = await fleetGet(token, '/api/1/vehicles');
return Array.isArray(out?.response) ? out.response : [];
}
export async function getVehicleData(token: string, vehicleId: string): Promise<any> {
// include all the endpoint groups we care about
const endpoints = ['charge_state', 'drive_state', 'vehicle_state', 'vehicle_config'].join(';');
const out = await fleetGet(token, `/api/1/vehicles/${vehicleId}/vehicle_data?endpoints=${endpoints}`);
return out?.response || null;
}
export async function wakeVehicle(token: string, vehicleId: string): Promise<any> {
return fleetPost(token, `/api/1/vehicles/${vehicleId}/wake_up`, {});
}
/** Send a navigation destination to the car's in-built nav. */
export async function sendNavigationRequest(
token: string,
vehicleId: string,
args: { lat: number; lng: number; name?: string },
): Promise<any> {
return fleetPost(token, `/api/1/vehicles/${vehicleId}/command/navigation_gps_request`, {
lat: args.lat,
lon: args.lng,
order: 1,
});
}
// ─── Partner key registration ───────────────────────────────────────────────
// One-shot setup — after partner approval, call this once to register our
// public key with Tesla so command signing works. Idempotent: safe to call.
export async function registerPartnerAccount(token: string, domain: string): Promise<any> {
return fleetPost(token, '/api/1/partner_accounts', { domain });
}
// ─── App-level access token (no user) ───────────────────────────────────────
// Some Fleet API setup calls (register partner account, fetch public key) use
// a token issued via client_credentials, not a user's auth code.
export async function getAppToken(): Promise<string> {
const res = await tokenRequest(new URLSearchParams({
grant_type: 'client_credentials',
client_id: env.tesla.clientId,
client_secret: env.tesla.clientSecret,
scope: 'openid offline_access vehicle_device_data vehicle_location vehicle_cmds vehicle_charging_cmds',
audience: TESLA_FLEET_BASE,
}));
return res.access_token;
}
+85
View File
@@ -0,0 +1,85 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createLogger } from './logger.js';
const log = createLogger('tesla-token-store');
export interface TeslaTokens {
accessToken: string;
refreshToken: string;
expiresAt: number; // ms epoch
scope: string;
// Optional cached vehicle metadata so we don't have to refetch on every call.
vehicleId?: string; // numeric id_s used in Fleet API URLs
vin?: string;
carType?: string; // e.g. modely
trimBadging?: string; // e.g. lrawd
connectedAt: number;
}
interface Store {
get(userId: string): Promise<TeslaTokens | null>;
set(userId: string, tokens: TeslaTokens): Promise<void>;
remove(userId: string): Promise<void>;
}
// File-backed store. Falls back to /app/data when running under Dokku (we
// will mount a Dokku storage volume on /app/data), or to ./data in dev.
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = process.env.TESLA_TOKEN_DIR
|| (process.env.NODE_ENV === 'production' ? '/app/data' : path.resolve(__dirname, '../../data'));
const tokenFile = path.join(dataDir, 'tesla-tokens.json');
let cache: Record<string, TeslaTokens> = {};
let loaded = false;
let writeLock: Promise<unknown> = Promise.resolve();
async function load(): Promise<void> {
if (loaded) return;
try {
await fs.mkdir(dataDir, { recursive: true, mode: 0o700 });
await fs.chmod(dataDir, 0o700).catch(() => {}); // tighten if it already existed
const raw = await fs.readFile(tokenFile, 'utf8');
cache = JSON.parse(raw);
log.info({ file: tokenFile, users: Object.keys(cache).length }, 'Loaded Tesla tokens');
} catch (err: any) {
if (err.code !== 'ENOENT') log.warn({ err: String(err) }, 'Failed to load Tesla tokens — starting empty');
cache = {};
}
loaded = true;
}
async function persist(): Promise<void> {
// Serialise writes so concurrent set/remove calls don't race. Write to a
// tmpfile then rename for atomicity; owner-only perms via mode + chmod.
writeLock = writeLock
.then(async () => {
await fs.mkdir(dataDir, { recursive: true, mode: 0o700 });
await fs.chmod(dataDir, 0o700).catch(() => {});
const tmp = `${tokenFile}.${process.pid}.tmp`;
await fs.writeFile(tmp, JSON.stringify(cache, null, 2), { mode: 0o600 });
await fs.chmod(tmp, 0o600).catch(() => {});
await fs.rename(tmp, tokenFile);
await fs.chmod(tokenFile, 0o600).catch(() => {});
})
.catch(err => log.error({ err: String(err) }, 'Failed to persist Tesla tokens'));
await writeLock;
}
export const teslaTokenStore: Store = {
async get(userId) {
await load();
return cache[userId] || null;
},
async set(userId, tokens) {
await load();
cache[userId] = tokens;
await persist();
},
async remove(userId) {
await load();
delete cache[userId];
await persist();
},
};
+159
View File
@@ -0,0 +1,159 @@
import { Router } from 'express';
import crypto from 'node:crypto';
import { env } from '../config/env.js';
import { createLogger } from '../lib/logger.js';
import {
clearOwnerCookie,
isOwnerAuthenticated,
setOwnerCookie,
verifyOwnerSecret,
} from '../lib/ownerAuth.js';
const log = createLogger('owner');
const router = Router();
// ─── Device pair-code store (in-memory) ─────────────────────────────────────
// 6-digit short codes minted by an already-logged-in owner session so a
// touchscreen device (the Tesla in-car browser) can log in without retyping
// the long OWNER_SECRET.
//
// Defence in depth — 6 digits is only ~20 bits of entropy on its own:
// - single-use (delete on success)
// - 5-minute TTL
// - per-code failure cap: a code self-destructs after 5 wrong guesses
// - per-IP rate limit (req.ip via 'trust proxy' = 1, not spoofable)
// - global rate limit across all IPs (defeats parallel-IP brute force)
const PAIR_CODE_TTL_MS = 5 * 60 * 1000;
const MAX_FAILED_PER_CODE = 3; // tighter — was 5
const MAX_REDEEM_PER_IP_PER_MIN = 8;
const MAX_REDEEM_GLOBAL_PER_MIN = 30;
const LOCKOUT_TRIP_FAILURES_5MIN = 100; // after this many failures…
const LOCKOUT_DURATION_MS = 10 * 60 * 1000; // …block the endpoint for 10 min
interface PairCodeEntry { expiresAt: number; failures: number }
const pairCodes = new Map<string, PairCodeEntry>();
const redeemAttemptsByIp = new Map<string, number[]>();
let redeemAttemptsGlobal: number[] = [];
let failuresLast5Min: number[] = [];
let lockedUntil = 0;
function cleanPairCodes(): void {
const now = Date.now();
for (const [k, v] of pairCodes) if (v.expiresAt < now) pairCodes.delete(k);
}
function recordAttempt(ip: string): { ipLimited: boolean; globalLimited: boolean } {
const now = Date.now();
const ipArr = (redeemAttemptsByIp.get(ip) || []).filter(t => now - t < 60_000);
ipArr.push(now);
redeemAttemptsByIp.set(ip, ipArr);
redeemAttemptsGlobal = redeemAttemptsGlobal.filter(t => now - t < 60_000);
redeemAttemptsGlobal.push(now);
return {
ipLimited: ipArr.length > MAX_REDEEM_PER_IP_PER_MIN,
globalLimited: redeemAttemptsGlobal.length > MAX_REDEEM_GLOBAL_PER_MIN,
};
}
router.get('/api/auth/owner/status', (req, res) => {
res.json({
authenticated: isOwnerAuthenticated(req),
required: !!env.ownerSecret,
});
});
router.post('/api/auth/owner', (req, res) => {
if (!env.ownerSecret) {
res.status(503).json({ ok: false, reason: 'owner_auth_not_configured' });
return;
}
const { secret } = (req.body || {}) as { secret?: unknown };
if (!verifyOwnerSecret(secret)) {
res.status(401).json({ ok: false, reason: 'invalid_secret' });
return;
}
setOwnerCookie(res);
res.json({ ok: true });
});
router.post('/api/auth/owner/logout', (_req, res) => {
clearOwnerCookie(res);
res.json({ ok: true });
});
// ─── Pair code: mint one (requires owner session) ──────────────────────────
router.post('/api/auth/owner/pair-code', (req, res) => {
if (!isOwnerAuthenticated(req)) {
res.status(401).json({ ok: false, reason: 'auth_required' });
return;
}
cleanPairCodes();
// Single-tenant app: only one active pair code at a time. Minting a new
// one burns any prior code (its window is what owner-attacker brute-force
// would target).
pairCodes.clear();
const code = String(crypto.randomInt(100_000, 1_000_000));
pairCodes.set(code, { expiresAt: Date.now() + PAIR_CODE_TTL_MS, failures: 0 });
res.json({ ok: true, code, expiresInS: Math.floor(PAIR_CODE_TTL_MS / 1000) });
});
// Mark a failed redeem against every currently-active code. With 6-digit
// entropy, an attacker brute-forcing the keyspace doesn't know which code
// the owner minted — but every wrong guess they make burns life from the
// real code. Three misses during a code's TTL is enough to invalidate it
// and force the owner to re-mint, which defeats parallel-IP brute force.
// Also record the failure on a global 5-minute window for endpoint lockout.
function markFailureAgainstActiveCodes(): void {
for (const [code, entry] of pairCodes) {
entry.failures += 1;
if (entry.failures > MAX_FAILED_PER_CODE) {
pairCodes.delete(code);
}
}
const now = Date.now();
failuresLast5Min = failuresLast5Min.filter(t => now - t < 5 * 60_000);
failuresLast5Min.push(now);
if (failuresLast5Min.length > LOCKOUT_TRIP_FAILURES_5MIN) {
lockedUntil = now + LOCKOUT_DURATION_MS;
failuresLast5Min = [];
log.error({ until: new Date(lockedUntil).toISOString() }, 'pair-redeem endpoint locked due to brute force');
}
}
// ─── Pair code: redeem on a new device ──────────────────────────────────────
router.post('/api/auth/owner/pair-redeem', (req, res) => {
// Hard endpoint lockout after a brute-force storm.
if (Date.now() < lockedUntil) {
res.status(429).json({ ok: false, reason: 'endpoint_locked' });
return;
}
// req.ip is trustworthy because we set 'trust proxy' = 1 in server/index.ts.
// Do NOT fall back to the X-Forwarded-For header — it's client-controlled.
const ip = req.ip || 'unknown';
const { ipLimited, globalLimited } = recordAttempt(ip);
if (ipLimited || globalLimited) {
log.warn({ ip, ipLimited, globalLimited }, 'pair-redeem rate limited');
res.status(429).json({ ok: false, reason: 'rate_limited' });
return;
}
const { code } = (req.body || {}) as { code?: unknown };
if (typeof code !== 'string' || !/^\d{6}$/.test(code)) {
markFailureAgainstActiveCodes();
res.status(400).json({ ok: false, reason: 'bad_code' });
return;
}
cleanPairCodes();
const entry = pairCodes.get(code);
if (!entry) {
markFailureAgainstActiveCodes();
res.status(401).json({ ok: false, reason: 'invalid_or_expired' });
return;
}
// Success: consume the code, set the cookie.
pairCodes.delete(code);
setOwnerCookie(res);
log.info({ ip }, 'Device paired via owner pair code');
res.json({ ok: true });
});
export default router;
+259 -34
View File
@@ -1,18 +1,28 @@
import { Router } from 'express'; import { Router, Request, Response } from 'express';
import crypto from 'node:crypto';
import { env } from '../config/env.js'; import { env } from '../config/env.js';
import { createLogger } from '../lib/logger.js'; import { createLogger } from '../lib/logger.js';
import { teslaTokenStore } from '../lib/teslaTokenStore.js';
import { ownerIdFromRequest, requireOwner } from '../lib/ownerAuth.js';
import {
buildAuthorizeUrl,
exchangeCodeForTokens,
getAccessToken,
getVehicleData,
listVehicles,
sendNavigationRequest,
wakeVehicle,
registerPartnerAccount,
getAppToken,
} from '../lib/teslaClient.js';
const log = createLogger('tesla'); const log = createLogger('tesla');
const router = Router(); const router = Router();
// ─── Domain verification ──────────────────────────────────────────────────── const TESLA_STATE_COOKIE = 'tesla_oauth_state';
// Tesla fetches this path to confirm you own the domain registered with the const STATE_TTL_S = 10 * 60;
// Fleet API partner account. The body must be the EXACT PEM the partner key
// is registered with (the EC public key from your prime256v1 keypair). // ─── Domain verification (PUBLIC — Tesla fetches anonymously) ───────────────
//
// Set TESLA_FLEET_PUBLIC_KEY in Dokku config to the full PEM contents —
// including the BEGIN/END lines. Multi-line env vars work fine with Dokku
// when set via `dokku config:set roadtrip TESLA_FLEET_PUBLIC_KEY="$(cat key.pem)"`.
router.get('/.well-known/appspecific/com.tesla.3p.public-key.pem', (_req, res) => { router.get('/.well-known/appspecific/com.tesla.3p.public-key.pem', (_req, res) => {
if (!env.tesla.publicKey) { if (!env.tesla.publicKey) {
log.warn('Tesla public key requested but TESLA_FLEET_PUBLIC_KEY is empty'); log.warn('Tesla public key requested but TESLA_FLEET_PUBLIC_KEY is empty');
@@ -24,49 +34,264 @@ router.get('/.well-known/appspecific/com.tesla.3p.public-key.pem', (_req, res) =
res.send(env.tesla.publicKey); res.send(env.tesla.publicKey);
}); });
// ─── OAuth callback (stub) ────────────────────────────────────────────────── // ─── Connect status (PUBLIC — used by client to decide UI state) ────────────
// Tesla redirects here with ?code=… after the user grants access. We exchange // Reveals only whether the integration is configured / connected; never
// the code for a refresh token and store it against the logged-in user. // emits any credentials or vehicle data.
// Full implementation lands once partner approval is granted. router.get('/api/tesla/status', async (req, res) => {
if (!env.tesla.clientId || !env.tesla.clientSecret) {
res.json({ available: false, reason: 'pending_partner_approval' });
return;
}
const ownerId = ownerIdFromRequest(req);
const tokens = ownerId ? await teslaTokenStore.get(ownerId) : null;
res.json({
available: true,
connected: !!tokens,
ownerAuthenticated: !!ownerId,
connectedAt: tokens?.connectedAt ?? null,
vehicleId: tokens?.vehicleId ?? null,
vin: tokens?.vin ? `${tokens.vin.slice(-4)}` : null, // masked
carType: tokens?.carType ?? null,
trimBadging: tokens?.trimBadging ?? null,
});
});
// ─── Everything below requires owner auth ──────────────────────────────────
// ─── Start the OAuth dance ──────────────────────────────────────────────────
router.get('/api/auth/tesla/start', requireOwner, (req, res) => {
if (!env.tesla.clientId || !env.tesla.clientSecret) {
res.status(503).json({ error: 'tesla_not_configured' });
return;
}
// Random state — bound to the browser via an httpOnly cookie so a CSRF
// attacker can't pre-seed a state that lands tokens in our account.
const state = crypto.randomBytes(16).toString('hex');
res.cookie(TESLA_STATE_COOKIE, state, {
httpOnly: true,
secure: env.nodeEnv === 'production',
sameSite: 'lax',
maxAge: STATE_TTL_S * 1000,
path: '/',
});
const url = buildAuthorizeUrl(state);
res.json({ authorizeUrl: url });
});
// ─── OAuth callback ─────────────────────────────────────────────────────────
// Tesla redirects the browser here after the user authorises. The owner cookie
// from when /start was called must still be present (SameSite=Lax allows it
// through on the top-level navigation back from Tesla), and the state in the
// cookie must equal the state Tesla bounced back.
router.get('/api/auth/tesla/callback', async (req, res) => { router.get('/api/auth/tesla/callback', async (req, res) => {
const { code, state, error } = req.query as Record<string, string | undefined>; const { code, state, error } = req.query as Record<string, string | undefined>;
const cookieState = (req as any).cookies?.[TESLA_STATE_COOKIE];
// Always clear the state cookie — single-use.
res.clearCookie(TESLA_STATE_COOKIE, { path: '/' });
if (error) { if (error) {
log.warn({ error, state }, 'Tesla OAuth error returned to callback'); log.warn({ error }, 'Tesla OAuth error');
res.redirect(`/?tesla_error=${encodeURIComponent(error)}`); res.redirect(`/?tesla_error=${encodeURIComponent(error)}`);
return; return;
} }
if (!code) { if (!code || !state) {
res.status(400).type('text/plain').send('Missing ?code from Tesla'); res.status(400).type('text/plain').send('Missing code or state');
return; return;
} }
if (!env.tesla.clientId || !env.tesla.clientSecret) { if (!cookieState || typeof cookieState !== 'string' || cookieState.length !== state.length
log.warn('Tesla OAuth callback hit but client credentials not configured'); || !crypto.timingSafeEqual(Buffer.from(cookieState), Buffer.from(state))) {
res.status(503).type('text/plain').send('Tesla integration not yet configured'); log.warn({ haveCookie: !!cookieState }, 'OAuth state cookie missing or mismatched — rejecting');
res.status(400).type('text/plain').send('OAuth state mismatch — restart from the planner');
return; return;
} }
// TODO once Tesla approves partner registration: const ownerId = ownerIdFromRequest(req);
// 1. POST to https://auth.tesla.com/oauth2/v3/token with grant_type=authorization_code if (!ownerId) {
// 2. Decode the id_token, persist refresh_token against req.auth.userId // Owner cookie expired or the user opened the callback in a different
// 3. Optional: enrol the vehicle via /api/1/partner_accounts/public_key // browser. Refuse rather than fall back to a global identity.
// 4. Redirect to / with a success flag res.status(401).type('text/plain').send('Owner session expired — log in and reconnect');
log.info({ codeLen: code.length, state }, 'Tesla OAuth callback received (stub)'); return;
res.redirect('/?tesla_connected=pending'); }
try {
const tokens = await exchangeCodeForTokens(code);
let vehicleId: string | undefined;
let vin: string | undefined;
let carType: string | undefined;
let trimBadging: string | undefined;
try {
const vehicles = await listVehicles(tokens.access_token);
const first = vehicles[0];
if (first) {
vehicleId = String(first.id_s ?? first.id ?? '');
vin = first.vin;
}
if (vehicleId) {
try {
const data = await getVehicleData(tokens.access_token, vehicleId);
carType = data?.vehicle_config?.car_type;
trimBadging = data?.vehicle_config?.trim_badging;
} catch (e) {
log.info({ err: String(e) }, 'Vehicle asleep at connect — config will populate later');
}
}
} catch (e) {
log.warn({ err: String(e) }, 'listVehicles failed at connect');
}
await teslaTokenStore.set(ownerId, {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + tokens.expires_in * 1000,
scope: tokens.scope || '',
vehicleId,
vin,
carType,
trimBadging,
connectedAt: Date.now(),
});
log.info({ ownerId, vehicleId, vin: vin?.slice(-4) }, 'Tesla connected');
res.redirect('/?tesla_connected=1');
} catch (err) {
log.error({ err: String(err) }, 'Tesla OAuth callback failed');
res.redirect(`/?tesla_error=${encodeURIComponent('token_exchange_failed')}`);
}
}); });
// ─── Vehicle state (stub) ─────────────────────────────────────────────────── // ─── Disconnect ─────────────────────────────────────────────────────────────
// Returns the current battery %, range and location for the connected vehicle. router.post('/api/tesla/disconnect', requireOwner, async (req, res) => {
// Until partner approval, returns 503 so the client can hide the integration UI. const ownerId = ownerIdFromRequest(req)!;
router.get('/api/tesla/state', async (_req, res) => { await teslaTokenStore.remove(ownerId);
res.json({ ok: true });
});
// ─── Vehicle state (battery, range, location) ───────────────────────────────
router.get('/api/tesla/state', requireOwner, async (req, res) => {
if (!env.tesla.clientId) { if (!env.tesla.clientId) {
res.status(503).json({ connected: false, reason: 'pending_partner_approval' }); res.status(503).json({ connected: false, reason: 'pending_partner_approval' });
return; return;
} }
// TODO: look up the user's stored refresh token, exchange for access token, const ownerId = ownerIdFromRequest(req)!;
// call https://fleet-api.prd.eu.vn.cloud.tesla.com/api/1/vehicles/{id}/vehicle_data, const tokens = await getAccessToken(ownerId);
// and shape the response into { battery, range, lat, lng, state, etc. }. if (!tokens) {
res.status(501).json({ connected: false, reason: 'not_implemented' }); res.status(401).json({ connected: false, reason: 'not_connected' });
return;
}
if (!tokens.vehicleId) {
res.status(404).json({ connected: true, reason: 'no_vehicles' });
return;
}
try {
const data = await getVehicleData(tokens.accessToken, tokens.vehicleId);
const cs = data?.charge_state || {};
const ds = data?.drive_state || {};
const vs = data?.vehicle_state || {};
const vc = data?.vehicle_config || {};
res.json({
connected: true,
asleep: false,
battery: typeof cs.battery_level === 'number' ? cs.battery_level : null,
rangeKm: typeof cs.battery_range === 'number' ? Math.round(cs.battery_range * 1.60934) : null,
idealRangeKm: typeof cs.ideal_battery_range === 'number' ? Math.round(cs.ideal_battery_range * 1.60934) : null,
chargingState: cs.charging_state ?? null,
chargerPowerKw: typeof cs.charger_power === 'number' ? cs.charger_power : null,
timeToFullCharge: typeof cs.time_to_full_charge === 'number' ? cs.time_to_full_charge : null,
lat: typeof ds.latitude === 'number' ? ds.latitude : null,
lng: typeof ds.longitude === 'number' ? ds.longitude : null,
heading: typeof ds.heading === 'number' ? ds.heading : null,
speedKmh: typeof ds.speed === 'number' ? Math.round(ds.speed * 1.60934) : null,
shiftState: ds.shift_state ?? null,
odometerKm: typeof vs.odometer === 'number' ? Math.round(vs.odometer * 1.60934) : null,
carType: vc.car_type ?? tokens.carType ?? null,
trimBadging: vc.trim_badging ?? tokens.trimBadging ?? null,
vin: tokens.vin ? `${tokens.vin.slice(-4)}` : null,
vehicleName: vs.vehicle_name ?? null,
softwareVersion: vs.car_version ?? null,
// Active in-car nav destination (set in Tesla nav by the driver, or by
// our own send-to-nav). Useful as a default trip destination.
activeRoute: (typeof ds.active_route_destination === 'string' && ds.active_route_destination)
? {
destination: ds.active_route_destination,
lat: typeof ds.active_route_latitude === 'number' ? ds.active_route_latitude : null,
lng: typeof ds.active_route_longitude === 'number' ? ds.active_route_longitude : null,
kmToArrival: typeof ds.active_route_miles_to_arrival === 'number'
? Math.round(ds.active_route_miles_to_arrival * 1.60934) : null,
minutesToArrival: typeof ds.active_route_minutes_to_arrival === 'number'
? Math.round(ds.active_route_minutes_to_arrival) : null,
trafficMinutesDelay: typeof ds.active_route_traffic_minutes_delay === 'number'
? Math.round(ds.active_route_traffic_minutes_delay) : 0,
}
: null,
fetchedAt: Date.now(),
});
} catch (err) {
const msg = String(err);
if (/408/.test(msg) || /asleep/i.test(msg)) {
res.status(202).json({ connected: true, asleep: true, vehicleId: tokens.vehicleId });
return;
}
log.error({ err: msg }, 'Failed to fetch vehicle state');
res.status(502).json({ connected: true, error: 'fleet_api_error' });
}
});
router.post('/api/tesla/wake', requireOwner, async (req, res) => {
const ownerId = ownerIdFromRequest(req)!;
const tokens = await getAccessToken(ownerId);
if (!tokens || !tokens.vehicleId) {
res.status(401).json({ ok: false, reason: 'not_connected' });
return;
}
try {
await wakeVehicle(tokens.accessToken, tokens.vehicleId);
res.json({ ok: true });
} catch (err) {
log.error({ err: String(err) }, 'Wake failed');
res.status(502).json({ ok: false, reason: 'wake_failed' });
}
});
router.post('/api/tesla/send-to-nav', requireOwner, async (req: Request, res: Response) => {
const ownerId = ownerIdFromRequest(req)!;
const tokens = await getAccessToken(ownerId);
if (!tokens || !tokens.vehicleId) {
res.status(401).json({ ok: false, reason: 'not_connected' });
return;
}
const { lat, lng, name } = req.body || {};
if (typeof lat !== 'number' || typeof lng !== 'number'
|| lat < -90 || lat > 90 || lng < -180 || lng > 180) {
res.status(400).json({ ok: false, reason: 'bad_coords' });
return;
}
try {
await sendNavigationRequest(tokens.accessToken, tokens.vehicleId, { lat, lng, name });
res.json({ ok: true });
} catch (err) {
log.error({ err: String(err) }, 'send-to-nav failed');
res.status(502).json({ ok: false, reason: 'fleet_api_error' });
}
});
// ─── Partner-account registration ─────────────────────────────────────────
// One-shot. Owner-gated. Does NOT echo Tesla's response body to the client.
router.post('/api/tesla/register-partner', requireOwner, async (_req, res) => {
if (!env.tesla.clientId || !env.tesla.clientSecret) {
res.status(503).json({ ok: false, reason: 'tesla_not_configured' });
return;
}
try {
const appToken = await getAppToken();
const domain = new URL(env.tesla.redirectUri).hostname;
await registerPartnerAccount(appToken, domain);
res.json({ ok: true });
} catch (err) {
log.error({ err: String(err) }, 'partner registration failed');
res.status(502).json({ ok: false, reason: 'partner_register_failed' });
}
}); });
export default router; export default router;