import { Router, Request, Response } from 'express'; import crypto from 'node:crypto'; import { env } from '../config/env.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 router = Router(); const TESLA_STATE_COOKIE = 'tesla_oauth_state'; const STATE_TTL_S = 10 * 60; // ─── Domain verification (PUBLIC — Tesla fetches anonymously) ─────────────── router.get('/.well-known/appspecific/com.tesla.3p.public-key.pem', (_req, res) => { if (!env.tesla.publicKey) { log.warn('Tesla public key requested but TESLA_FLEET_PUBLIC_KEY is empty'); res.status(404).type('text/plain').send('Public key not configured'); return; } res.type('application/x-pem-file'); res.set('Cache-Control', 'public, max-age=300'); res.send(env.tesla.publicKey); }); // ─── Connect status (PUBLIC — used by client to decide UI state) ──────────── // Reveals only whether the integration is configured / connected; never // emits any credentials or vehicle data. 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) => { const { code, state, error } = req.query as Record; const cookieState = (req as any).cookies?.[TESLA_STATE_COOKIE]; // Always clear the state cookie — single-use. res.clearCookie(TESLA_STATE_COOKIE, { path: '/' }); if (error) { log.warn({ error }, 'Tesla OAuth error'); res.redirect(`/?tesla_error=${encodeURIComponent(error)}`); return; } if (!code || !state) { res.status(400).type('text/plain').send('Missing code or state'); return; } if (!cookieState || typeof cookieState !== 'string' || cookieState.length !== state.length || !crypto.timingSafeEqual(Buffer.from(cookieState), Buffer.from(state))) { 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; } const ownerId = ownerIdFromRequest(req); if (!ownerId) { // Owner cookie expired or the user opened the callback in a different // browser. Refuse rather than fall back to a global identity. res.status(401).type('text/plain').send('Owner session expired — log in and reconnect'); return; } 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')}`); } }); // ─── Disconnect ───────────────────────────────────────────────────────────── router.post('/api/tesla/disconnect', requireOwner, async (req, res) => { const ownerId = ownerIdFromRequest(req)!; 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) { res.status(503).json({ connected: false, reason: 'pending_partner_approval' }); return; } const ownerId = ownerIdFromRequest(req)!; const tokens = await getAccessToken(ownerId); if (!tokens) { 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;