From f793b526aad4499199674a28fc97538348bc39a1 Mon Sep 17 00:00:00 2001 From: Tony James Date: Sun, 31 May 2026 22:32:22 +0100 Subject: [PATCH] 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. --- client/src/lib/tesla.ts | 46 +++++++-- client/src/pages/TeslaTripPlanner.tsx | 109 ++++++++++++++++++++- server/config/env.ts | 4 + server/index.ts | 8 +- server/lib/ownerAuth.ts | 70 ++++++++++++++ server/lib/teslaTokenStore.ts | 15 ++- server/routes/owner.ts | 38 ++++++++ server/routes/tesla.ts | 131 ++++++++++++++------------ 8 files changed, 348 insertions(+), 73 deletions(-) create mode 100644 server/lib/ownerAuth.ts create mode 100644 server/routes/owner.ts diff --git a/client/src/lib/tesla.ts b/client/src/lib/tesla.ts index 3ba20f0..b8dc157 100644 --- a/client/src/lib/tesla.ts +++ b/client/src/lib/tesla.ts @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react'; export interface TeslaStatus { available: boolean; connected: boolean; + ownerAuthenticated?: boolean; connectedAt?: number | null; vehicleId?: string | null; vin?: string | null; @@ -11,6 +12,30 @@ export interface TeslaStatus { reason?: string; } +export interface OwnerStatus { + authenticated: boolean; + required: boolean; +} + +export async function fetchOwnerStatus(): Promise { + 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 { + 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 { + await fetch('/api/auth/owner/logout', { method: 'POST' }); +} + export interface TeslaState { connected: boolean; asleep?: boolean; @@ -75,23 +100,25 @@ export async function sendToTeslaNav(args: { lat: number; lng: number; name?: st /** Hook: subscribe to Tesla status + live state. Polls every 60s while connected. */ export function useTesla() { const [status, setStatus] = useState(null); + const [owner, setOwner] = useState(null); const [state, setState] = useState(null); const [stateLoading, setStateLoading] = useState(false); const pollRef = useRef | null>(null); - // Initial status fetch + react to ?tesla_connected=1 on return from OAuth. useEffect(() => { let cancelled = false; (async () => { - const s = await fetchTeslaStatus(); - if (!cancelled) setStatus(s); + const [s, o] = await Promise.all([fetchTeslaStatus(), fetchOwnerStatus()]); + if (!cancelled) { setStatus(s); setOwner(o); } })(); return () => { cancelled = true; }; }, []); - // Fetch live state whenever we become connected, then poll. + // 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) { + if (!status?.connected || !owner?.authenticated) { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } setState(null); return; @@ -108,9 +135,12 @@ export function useTesla() { tick(); pollRef.current = setInterval(tick, 60_000); return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } }; - }, [status?.connected]); + }, [status?.connected, owner?.authenticated]); - const refreshStatus = async () => setStatus(await fetchTeslaStatus()); + const refreshStatus = async () => { + const [s, o] = await Promise.all([fetchTeslaStatus(), fetchOwnerStatus()]); + setStatus(s); setOwner(o); + }; - return { status, state, stateLoading, refreshStatus }; + return { status, state, stateLoading, owner, refreshStatus }; } diff --git a/client/src/pages/TeslaTripPlanner.tsx b/client/src/pages/TeslaTripPlanner.tsx index b1cf367..e9c4c13 100644 --- a/client/src/pages/TeslaTripPlanner.tsx +++ b/client/src/pages/TeslaTripPlanner.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { toast } from 'sonner'; import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap } from 'react-leaflet'; -import { useTesla, startTeslaConnect, disconnectTesla, sendToTeslaNav, wakeTesla } from '../lib/tesla'; +import { useTesla, startTeslaConnect, disconnectTesla, sendToTeslaNav, wakeTesla, loginOwner } from '../lib/tesla'; import { detectInCar } from '../lib/incar'; import L from 'leaflet'; import { @@ -1686,6 +1686,9 @@ export default function TeslaTripPlanner() { const [travelDates, setTravelDates] = useState({ outbound: null, return: null, travellers: 2 }); const [datePickerOpen, setDatePickerOpen] = useState(false); const [dateAnchor, setDateAnchor] = useState(null); + const [ownerLoginOpen, setOwnerLoginOpen] = useState(false); + // After successful owner login, fire this pending action. + const ownerLoginThenRef = React.useRef void)>(null); const lastODSent = React.useRef<{ from: string; to: string } | null>(null); const [variants, setVariants] = useState([]); const [selectedVariant, setSelectedVariant] = useState('fast'); @@ -2189,6 +2192,14 @@ export default function TeslaTripPlanner() { teslaStatus={tesla.status} teslaState={tesla.state} onConnectTesla={async () => { + if (tesla.owner?.required && !tesla.owner.authenticated) { + ownerLoginThenRef.current = async () => { + try { await startTeslaConnect(); } + catch { toast.error('Could not start Tesla OAuth'); } + }; + setOwnerLoginOpen(true); + return; + } try { await startTeslaConnect(); } catch { toast.error('Could not start Tesla OAuth'); } }} @@ -2636,10 +2647,106 @@ export default function TeslaTripPlanner() { } }} /> + + setOwnerLoginOpen(false)} + onSuccess={async () => { + setOwnerLoginOpen(false); + await tesla.refreshStatus(); + const fn = ownerLoginThenRef.current; + ownerLoginThenRef.current = null; + if (fn) fn(); + }} + /> ); } +function OwnerLoginModal({ open, onClose, onSuccess }: { + open: boolean; onClose: () => void; onSuccess: () => void; +}) { + const [secret, setSecret] = React.useState(''); + const [pending, setPending] = React.useState(false); + React.useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, onClose]); + if (!open) return null; + const submit = async () => { + if (!secret) return; + setPending(true); + try { + const ok = await loginOwner(secret); + if (ok) { + toast.success('Logged in as owner'); + setSecret(''); + onSuccess(); + } else { + toast.error('Invalid owner secret'); + } + } finally { + setPending(false); + } + }; + return ( +
+
e.stopPropagation()} + className="w-[440px] max-w-full overflow-hidden" + style={{ + background: 'var(--gd-bg-2)', + border: '1px solid var(--gd-border-2)', + borderRadius: 16, + boxShadow: '0 24px 60px rgba(0,0,0,0.55)', + }} + > +
+
Owner login required
+
+ The Tesla integration is restricted to the deploying user. Enter the OWNER_SECRET set in the deploy environment. +
+
+
+ setSecret(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') submit(); }} + placeholder="Owner secret" + className="w-full text-[13.5px] px-3 py-2.5 rounded-lg outline-none" + style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)' }} + /> +
+
+ + +
+
+
+ ); +} + // ─── Modal shell ───────────────────────────────────────────────────────────── function ModalShell({ onClose, width = 720, title, subtitle, footer, children, diff --git a/server/config/env.ts b/server/config/env.ts index f012709..0fe8fc0 100644 --- a/server/config/env.ts +++ b/server/config/env.ts @@ -25,6 +25,10 @@ export const env = { grokEnabled: process.env.GROK_ENABLED !== 'false', 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: { // Public key served at /.well-known/appspecific/com.tesla.3p.public-key.pem diff --git a/server/index.ts b/server/index.ts index 4eeb101..d09a9f5 100644 --- a/server/index.ts +++ b/server/index.ts @@ -11,6 +11,8 @@ import { logger } from './lib/logger.js'; import chatRoutes from './routes/chat.js'; import tripsRoutes from './routes/trips.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'; const app = express(); @@ -38,8 +40,10 @@ if (auth) { logger.info('Auth disabled — set AUTH_SECRET to enable user accounts'); } -// Tesla integration: serves the partner public key + OAuth callback. Mounted -// at the app root because Tesla's well-known path is fixed. +// Owner auth + Tesla integration. Tesla routes are owner-gated except the +// public .well-known partner-key path. Owner routes handle login/logout. +warnOwnerAuth(); +app.use(ownerRoutes); app.use(teslaRoutes); app.use('/api', chatRoutes); diff --git a/server/lib/ownerAuth.ts b/server/lib/ownerAuth.ts new file mode 100644 index 0000000..dd5212a --- /dev/null +++ b/server/lib/ownerAuth.ts @@ -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.'); + } +} diff --git a/server/lib/teslaTokenStore.ts b/server/lib/teslaTokenStore.ts index ab3a000..f00ca78 100644 --- a/server/lib/teslaTokenStore.ts +++ b/server/lib/teslaTokenStore.ts @@ -38,7 +38,8 @@ let writeLock: Promise = Promise.resolve(); async function load(): Promise { if (loaded) return; try { - await fs.mkdir(dataDir, { recursive: true }); + 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'); @@ -50,11 +51,17 @@ async function load(): Promise { } async function persist(): Promise { - // Serialise writes so concurrent set/remove calls don't race. + // 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 }); - await fs.writeFile(tokenFile, JSON.stringify(cache, null, 2)); + 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; diff --git a/server/routes/owner.ts b/server/routes/owner.ts new file mode 100644 index 0000000..c5d8efa --- /dev/null +++ b/server/routes/owner.ts @@ -0,0 +1,38 @@ +import { Router } from 'express'; +import { env } from '../config/env.js'; +import { + clearOwnerCookie, + isOwnerAuthenticated, + setOwnerCookie, + verifyOwnerSecret, +} from '../lib/ownerAuth.js'; + +const router = Router(); + +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 }); +}); + +export default router; diff --git a/server/routes/tesla.ts b/server/routes/tesla.ts index f23229f..04e060c 100644 --- a/server/routes/tesla.ts +++ b/server/routes/tesla.ts @@ -3,6 +3,7 @@ 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, @@ -18,14 +19,10 @@ import { const log = createLogger('tesla'); const router = Router(); -// Until auth.tony.codes is wired in, fall back to a single anonymous "owner" -// identity so the integration works for the deploying user. Replace with -// req.auth!.userId once the auth middleware is mounted in front of this. -function userIdFor(req: Request): string { - return (req as any).auth?.userId || 'owner'; -} +const TESLA_STATE_COOKIE = 'tesla_oauth_state'; +const STATE_TTL_S = 10 * 60; -// ─── Domain verification ──────────────────────────────────────────────────── +// ─── 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'); @@ -37,49 +34,64 @@ router.get('/.well-known/appspecific/com.tesla.3p.public-key.pem', (_req, res) = res.send(env.tesla.publicKey); }); -// ─── Connect status ───────────────────────────────────────────────────────── +// ─── 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 tokens = await teslaTokenStore.get(userIdFor(req)); + 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 ?? null, + vin: tokens?.vin ? `…${tokens.vin.slice(-4)}` : null, // masked carType: tokens?.carType ?? null, trimBadging: tokens?.trimBadging ?? null, }); }); -// ─── Start the OAuth dance ────────────────────────────────────────────────── -const pendingStates = new Map(); -const STATE_TTL = 10 * 60 * 1000; +// ─── Everything below requires owner auth ────────────────────────────────── -router.get('/api/auth/tesla/start', (req, res) => { +// ─── 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; } - // Clean expired states. - const now = Date.now(); - for (const [k, v] of pendingStates) { - if (now - v.createdAt > STATE_TTL) pendingStates.delete(k); - } + // 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'); - pendingStates.set(state, { userId: userIdFor(req), createdAt: now }); + 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, 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, state }, 'Tesla OAuth error'); + log.warn({ error }, 'Tesla OAuth error'); res.redirect(`/?tesla_error=${encodeURIComponent(error)}`); return; } @@ -87,17 +99,23 @@ router.get('/api/auth/tesla/callback', async (req, res) => { res.status(400).type('text/plain').send('Missing code or state'); return; } - const pending = pendingStates.get(state); - if (!pending) { - res.status(400).type('text/plain').send('Unknown or expired OAuth state — restart from /api/auth/tesla/start'); + 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; } - pendingStates.delete(state); try { const tokens = await exchangeCodeForTokens(code); - - // Discover the user's vehicle so we can stash its id_s and config. let vehicleId: string | undefined; let vin: string | undefined; let carType: string | undefined; @@ -110,8 +128,6 @@ router.get('/api/auth/tesla/callback', async (req, res) => { vin = first.vin; } if (vehicleId) { - // vehicle_config can fail if the car is asleep — that's fine, we'll - // fill it in next time the user fetches /api/tesla/state. try { const data = await getVehicleData(tokens.access_token, vehicleId); carType = data?.vehicle_config?.car_type; @@ -121,10 +137,10 @@ router.get('/api/auth/tesla/callback', async (req, res) => { } } } catch (e) { - log.warn({ err: String(e) }, 'listVehicles failed at connect — continuing without vehicle id'); + log.warn({ err: String(e) }, 'listVehicles failed at connect'); } - await teslaTokenStore.set(pending.userId, { + await teslaTokenStore.set(ownerId, { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt: Date.now() + tokens.expires_in * 1000, @@ -136,7 +152,7 @@ router.get('/api/auth/tesla/callback', async (req, res) => { connectedAt: Date.now(), }); - log.info({ userId: pending.userId, vehicleId, vin: vin?.slice(-6) }, 'Tesla connected'); + 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'); @@ -145,18 +161,20 @@ router.get('/api/auth/tesla/callback', async (req, res) => { }); // ─── Disconnect ───────────────────────────────────────────────────────────── -router.post('/api/tesla/disconnect', async (req, res) => { - await teslaTokenStore.remove(userIdFor(req)); +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', async (req, res) => { +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 tokens = await getAccessToken(userIdFor(req)); + const ownerId = ownerIdFromRequest(req)!; + const tokens = await getAccessToken(ownerId); if (!tokens) { res.status(401).json({ connected: false, reason: 'not_connected' }); return; @@ -176,29 +194,26 @@ router.get('/api/tesla/state', async (req, res) => { connected: true, asleep: false, battery: typeof cs.battery_level === 'number' ? cs.battery_level : null, - // Convert miles → km. Tesla returns rated range in miles regardless of region settings. 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, // Charging | Disconnected | Stopped | Complete + 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, // hours + 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, // P | R | N | D + 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 ?? null, + vin: tokens.vin ? `…${tokens.vin.slice(-4)}` : null, vehicleName: vs.vehicle_name ?? null, softwareVersion: vs.car_version ?? null, fetchedAt: Date.now(), }); } catch (err) { const msg = String(err); - // 408 from Tesla typically means the car is asleep — caller can prompt - // for a wake. if (/408/.test(msg) || /asleep/i.test(msg)) { res.status(202).json({ connected: true, asleep: true, vehicleId: tokens.vehicleId }); return; @@ -208,9 +223,9 @@ router.get('/api/tesla/state', async (req, res) => { } }); -// ─── Wake the vehicle ─────────────────────────────────────────────────────── -router.post('/api/tesla/wake', async (req, res) => { - const tokens = await getAccessToken(userIdFor(req)); +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; @@ -224,15 +239,16 @@ router.post('/api/tesla/wake', async (req, res) => { } }); -// ─── Send navigation destination to the car ───────────────────────────────── -router.post('/api/tesla/send-to-nav', async (req: Request, res: Response) => { - const tokens = await getAccessToken(userIdFor(req)); +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') { + 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; } @@ -245,10 +261,9 @@ router.post('/api/tesla/send-to-nav', async (req: Request, res: Response) => { } }); -// ─── One-shot partner-account registration ───────────────────────────────── -// Visit this once after partner approval to register our domain + public key -// with Tesla so command signing works. Subsequent calls are no-ops. -router.post('/api/tesla/register-partner', async (_req, res) => { +// ─── 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; @@ -256,11 +271,11 @@ router.post('/api/tesla/register-partner', async (_req, res) => { try { const appToken = await getAppToken(); const domain = new URL(env.tesla.redirectUri).hostname; - const out = await registerPartnerAccount(appToken, domain); - res.json({ ok: true, response: out }); + 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', detail: String(err).slice(0, 200) }); + res.status(502).json({ ok: false, reason: 'partner_register_failed' }); } });