← back to Charge And Explore

backend/src/server.ts

1611 lines

// Charge & Explore — minimal zero-dependency HTTP server (node:http).
// Serves the public/ landing page + a live demo of the recommendation core.
// No Tesla credentials are ever touched on this public surface.

import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join, normalize, extname } from "node:path";
import { randomBytes, createHash, sign as ecdsaSign } from "node:crypto";
import { connect as tlsConnect } from "node:tls";
import { recommendStops, type StopCandidate } from "./recommend.ts";
import { defaultChargeCurve, estimateChargeRange } from "./core/charge-estimate.ts";
import { NrelStationProvider } from "./providers/nrel-station-provider.ts";
import { catalogForPicker, vehicleById } from "./core/vehicles.ts";
import { detectChargingSessions } from "./core/charging-sessions.ts";
import { detectParkedPeriods, summarizeParked } from "./core/parked-periods.ts";
import { appendHistoryPoint, type HistoryPoint } from "./core/history.ts";
import { activitiesNear } from "./providers/overpass-places.ts";
import { SmartcarProvider, type OAuthTokens, type VehicleCharge } from "./providers/telematics.ts";
import { StripeBilling } from "./providers/stripe-billing.ts";
import {
  TIERS, PAID_TIERS, entitlementsFor, isPaidTier, normalizeTier,
  clampHistoryHours, clampHistoryDays, vehicleLimit, tierFromStripeObject, type Tier,
} from "./core/tiers.ts";

const __dirname = dirname(fileURLToPath(import.meta.url));
const PUBLIC_DIR = join(__dirname, "../../public");
const KEYS_DIR = join(__dirname, "../../keys");
const PORT = Number(process.env.PORT ?? 9820);

// Load server-side Tesla creds from .env (never shipped to the client).
try { process.loadEnvFile(join(__dirname, "../../.env")); } catch { /* no .env in this env */ }
const TESLA_CLIENT_ID = process.env.TESLA_CLIENT_ID ?? "";
const TESLA_CLIENT_SECRET = process.env.TESLA_CLIENT_SECRET ?? "";
const TESLA_AUDIENCE = process.env.TESLA_AUDIENCE ?? "https://fleet-api.prd.na.vn.cloud.tesla.com";
const REDIRECT_URI = process.env.TESLA_REDIRECT_URI ?? "https://chargeandexplore.agentabrams.com/auth/tesla/callback";
const ADMIN_USER = process.env.ADMIN_USER ?? "admin";
const ADMIN_PASS = process.env.ADMIN_PASS ?? "DW2024!";
const GOOGLE_CLIENT_ID = process.env.GOOGLE_OAUTH_CLIENT_ID ?? "";
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_OAUTH_CLIENT_SECRET ?? "";
const GOOGLE_REDIRECT_URI = process.env.GOOGLE_REDIRECT_URI ?? "https://chargeandexplore.agentabrams.com/auth/google/callback";

// Sign in with Apple (web) — App Store Guideline 4.8 privacy-forward option.
// Needs a Services ID + a .p8 sign-in key from the Apple Developer console.
const APPLE_SERVICES_ID = process.env.APPLE_SERVICES_ID ?? "";           // e.g. com.abrams.chargeandexplore.signin
const APPLE_TEAM_ID = process.env.APPLE_TEAM_ID ?? "3VAV3KMNZK";
const APPLE_KEY_ID = process.env.APPLE_KEY_ID ?? "";
// .p8 PEM: prefer the env var; else read keys/AuthKey_<KEYID>.p8 (gitignored, scp'd
// to the host) so the private key never lives in .env or a shell command.
function loadApplePrivateKey(): string {
  const env = (process.env.APPLE_PRIVATE_KEY ?? "").replace(/\\n/g, "\n");
  if (env.includes("BEGIN PRIVATE KEY")) return env;
  if (APPLE_KEY_ID) {
    try { return readFileSync(join(KEYS_DIR, `AuthKey_${APPLE_KEY_ID}.p8`), "utf8"); } catch { /* not present */ }
  }
  return "";
}
const APPLE_PRIVATE_KEY = loadApplePrivateKey();
// Passwordless email sign-in (magic link) over SMTP (e.g. Purelymail). If SMTP
// is unset, /auth/email/request returns the link in the response (dev fallback).
const SMTP_HOST = process.env.SMTP_HOST ?? "";
const SMTP_PORT = Number(process.env.SMTP_PORT ?? 465);
const SMTP_USER = process.env.SMTP_USER ?? "";
const SMTP_PASS = process.env.SMTP_PASS ?? "";
const MAIL_FROM = process.env.MAIL_FROM ?? "Charge & Explore <info@chargeandexplore.com>";
function appleRedirectFor(req: any): string {
  return process.env.APPLE_REDIRECT_URI ?? `${oauthBase(req)}/auth/apple/callback`;
}

// Canonical domain is chargeandexplore.com. The OAuth callback is derived from
// the incoming request host (allowlisted) so both the .com and the legacy
// agentabrams subdomain self-serve their OWN callback — no cross-domain session
// bounce. An explicit *_REDIRECT_URI env still hard-overrides if ever needed.
const CANONICAL_HOST = process.env.CANONICAL_HOST ?? "chargeandexplore.com";
const ALLOWED_OAUTH_HOSTS = new Set<string>([
  "chargeandexplore.com",
  "www.chargeandexplore.com",
  "chargeandexplore.agentabrams.com",
]);
function oauthBase(req: any): string {
  const raw = (String(req.headers["x-forwarded-host"] ?? req.headers.host ?? "")
    .split(",")[0] ?? "").trim().toLowerCase();
  const host = ALLOWED_OAUTH_HOSTS.has(raw) ? raw : CANONICAL_HOST;
  return `https://${host}`;
}
function googleRedirectFor(req: any): string {
  return process.env.GOOGLE_REDIRECT_URI ?? `${oauthBase(req)}/auth/google/callback`;
}
function teslaRedirectFor(req: any): string {
  return process.env.TESLA_REDIRECT_URI ?? `${oauthBase(req)}/auth/tesla/callback`;
}
// Smartcar telematics ("My Garage") — one OAuth, ~40 brands. Inert (configured=false)
// until SMARTCAR_CLIENT_ID/SECRET are set, so routes degrade to a "coming soon" state.
const telematics = new SmartcarProvider();
function smartcarRedirectFor(req: any): string {
  return process.env.SMARTCAR_REDIRECT_URI ?? `${oauthBase(req)}/auth/smartcar/callback`;
}
// Stripe billing (TEST MODE ONLY, TK-10227) — same inert pattern as Smartcar:
// configured=false (BILLING_ENABLED unset, or no sk_test_ key) means every
// /api/billing route answers {configured:false}, no UI renders, and the
// history/summary endpoints behave exactly as they always have (full access).
const billing = new StripeBilling();

// In-memory stores (prod: PKCE in Redis, tokens in a KMS-encrypted vault).
// redirectUri is captured at login so the callback token-exchange reuses the
// EXACT same value (OAuth requires an exact match).
const pkceStore = new Map<string, { verifier: string; exp: number; redirectUri?: string; app?: boolean }>();
// Tesla vehicle-link sessions. Persisted to disk (data/tesla-sessions.json,
// gitignored + rsync-excluded) so the link survives pm2 restarts, and
// auto-refreshed with the refresh_token so it survives the 8h access-token
// expiry. lastKnown caches the last good charge read for the asleep-car case.
// stats = the fuller read Steve asked for (Dust2026 2026-08-04): charge limit,
// charger power, time-to-full, energy added, est range, temps, odometer, lock/
// sentry, software version — all from the SAME vehicle_data call (no extra cost).
interface TeslaStats {
  usableSoc?: number | null; estRangeMiles?: number | null; chargeLimit?: number | null;
  chargeRateMph?: number | null; chargerPowerKw?: number | null; minutesToFull?: number | null;
  energyAddedKWh?: number | null; insideTempC?: number | null; outsideTempC?: number | null;
  odometerMiles?: number | null; locked?: boolean | null; sentry?: boolean | null; version?: string | null;
}
interface TeslaLastKnown { vin: string; name: string | null; soc: number | null; chargingState: string | null; rangeMiles: number | null; at: number; stats?: TeslaStats }
// Multi-car (TK-10227): lastKnown stays the DEFAULT (first) vehicle's cache so
// every pre-multi-car code path + persisted session file keeps working;
// lastKnownByVin adds a per-vehicle cache alongside it (additive, so old
// data/tesla-sessions.json files load unchanged).
// demo=true marks a synthetic reviewer session (App Store 2.1.0 demo mode): its
// tokens are fake, so every Fleet-API path SHORT-CIRCUITS to the seeded cache
// instead of calling Tesla (no 10s waits, no fake-token outbound, no sampler use).
interface TeslaSession { tokens: any; exp: number; demo?: boolean; lastKnown?: TeslaLastKnown; lastKnownByVin?: Record<string, TeslaLastKnown> }
const sessions = new Map<string, TeslaSession>();
const TESLA_SESSIONS_FILE = join(__dirname, "../../data/tesla-sessions.json");
const TESLA_SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000;

async function saveTeslaSessions(): Promise<void> {
  try {
    await mkdir(dirname(TESLA_SESSIONS_FILE), { recursive: true });
    const live = Object.fromEntries([...sessions].filter(([, s]) => s.exp > Date.now()));
    await writeFile(TESLA_SESSIONS_FILE, JSON.stringify(live));
  } catch (e) { console.error("saveTeslaSessions:", String(e)); }
}
async function loadTeslaSessions(): Promise<void> {
  try {
    const raw = await readFile(TESLA_SESSIONS_FILE, "utf8");
    const obj = JSON.parse(raw) as Record<string, TeslaSession>;
    for (const [sid, s] of Object.entries(obj)) if (s?.tokens && s.exp > Date.now()) sessions.set(sid, s);
    if (sessions.size) console.log(`Restored ${sessions.size} Tesla session(s) from disk.`);
  } catch { /* first boot — no file yet */ }
}

// --- Charge-level history (keyed per VIN — see core/history.ts for the rules).
// A point is recorded on every successful vehicle_data read (user-driven summary
// + the hourly sampler below). Deduped to ≥5-min spacing unless SOC changed;
// pruned to 90 days per vehicle. Persisted so graphs survive restarts/deploys.
// kw/ea (charger power, session energy added) ride along only while charging —
// they come from the SAME vehicle_data read, and power the charging-session view.
// o (odometer) + s (sentry) ride along on every point — the odometer is what
// lets the parked view prove a SOC drop was phantom drain and not a drive.
const HISTORY_FILE = join(__dirname, "../../data/tesla-history.json");
const history = new Map<string, HistoryPoint[]>();
let historyDirty = false;
async function loadHistory(): Promise<void> {
  try {
    const obj = JSON.parse(await readFile(HISTORY_FILE, "utf8")) as Record<string, HistoryPoint[]>;
    for (const [vin, pts] of Object.entries(obj)) if (Array.isArray(pts)) history.set(vin, pts);
  } catch { /* first boot */ }
}
async function saveHistory(): Promise<void> {
  if (!historyDirty) return;
  historyDirty = false;
  try {
    await mkdir(dirname(HISTORY_FILE), { recursive: true });
    await writeFile(HISTORY_FILE, JSON.stringify(Object.fromEntries(history)));
  } catch (e) { console.error("saveHistory:", String(e)); }
}
function recordHistory(lk: TeslaLastKnown): void {
  if (appendHistoryPoint(history, lk)) {
    historyDirty = true;
    void saveHistory();
  }
}

// Cache a fresh vehicle_data read on the session: always per-vin, and ALSO as
// the legacy default `lastKnown` when it's the account's FIRST vehicle (so
// un-parameterized endpoints keep their exact pre-multi-car behavior).
function cacheLastKnown(sess: TeslaSession, lk: TeslaLastKnown, firstVin: string | undefined): void {
  sess.lastKnownByVin = { ...(sess.lastKnownByVin ?? {}), [lk.vin]: lk };
  if (!firstVin || lk.vin === firstVin) sess.lastKnown = lk;
}
// Per-vin cached read: the per-vin map first, the legacy default as fallback.
function lastKnownFor(sess: TeslaSession, vin: string): TeslaLastKnown | undefined {
  if (!vin) return sess.lastKnown;
  return sess.lastKnownByVin?.[vin] ?? (sess.lastKnown?.vin === vin ? sess.lastKnown : undefined);
}

// --- Per-user preferences, keyed by Google sub → "remember settings on signin".
// Holds UI settings (sort/density/network filter) + the saved EV, restored into
// the session at every Google sign-in. Persisted to disk (gitignored).
interface UserPrefs { settings?: Record<string, string>; vehicle?: UserVehicle; tier?: Tier }
const PREFS_FILE = join(__dirname, "../../data/user-prefs.json");
const userPrefs = new Map<string, UserPrefs>();
async function loadPrefs(): Promise<void> {
  try {
    const obj = JSON.parse(await readFile(PREFS_FILE, "utf8")) as Record<string, UserPrefs>;
    for (const [sub, p] of Object.entries(obj)) userPrefs.set(sub, p);
  } catch { /* first boot */ }
}
async function savePrefs(): Promise<void> {
  try {
    await mkdir(dirname(PREFS_FILE), { recursive: true });
    await writeFile(PREFS_FILE, JSON.stringify(Object.fromEntries(userPrefs)));
  } catch (e) { console.error("savePrefs:", String(e)); }
}

// Resolve the request's Tesla session, transparently refreshing the access
// token when it's expired/near expiry. Tesla ROTATES refresh tokens: the new
// one must be persisted immediately or the next refresh fails.
async function getTeslaSession(req: IncomingMessage) {
  const sid = cookies(req).ce_sess;
  const s = sid ? sessions.get(sid) : undefined;
  if (!s || s.exp <= Date.now()) return undefined;
  await ensureFreshTeslaTokens(s);
  return s;
}
async function ensureFreshTeslaTokens(s: { tokens: any }): Promise<void> {
  const tok = s.tokens ?? {};
  const expiresAt = Number(tok.__expiresAt ?? 0);
  if (Date.now() > expiresAt - 60_000 && tok.refresh_token) {
    try {
      const body = new URLSearchParams({
        grant_type: "refresh_token", client_id: TESLA_CLIENT_ID, client_secret: TESLA_CLIENT_SECRET,
        refresh_token: tok.refresh_token,
      });
      const tr = await fetch("https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token", {
        method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body,
        signal: AbortSignal.timeout(10_000),
      });
      const nt = (await tr.json()) as any;
      if (nt.access_token) {
        nt.__expiresAt = Date.now() + Number(nt.expires_in ?? 28_800) * 1000;
        if (!nt.refresh_token) nt.refresh_token = tok.refresh_token;
        s.tokens = nt;
        void saveTeslaSessions();
      }
    } catch (e) { console.error("tesla refresh failed:", String(e)); }
  }
}
// Signed-in end users (Google). Separate cookie from the Tesla vehicle link —
// a user can be signed in without connecting a car, and vice versa.
interface GoogleUser { sub: string; email: string; name: string; picture: string }
interface UserVehicle { id: string; make: string; model: string; usableBatteryKWh: number; maxDcKw: number }
// "My Garage" — telematics-connected vehicles (Smartcar) with last-known charge.
interface GarageVehicle { id: string; make: string; model: string; year: number | null; provider: string; charge?: VehicleCharge }
interface Garage { tokens?: OAuthTokens; vehicles: GarageVehicle[] }
// tier rides the persisted session record (data/user-sessions.json shape,
// additive — old files load unchanged); absent = "free" once billing is on.
interface UserSessionRec { user: GoogleUser; exp: number; vehicle?: UserVehicle; garage?: Garage; tier?: Tier }
const userSessions = new Map<string, UserSessionRec>();
// Persisted like Tesla sessions — otherwise every deploy/restart wipes the map
// and signs everyone out (the Dust2026 "sign-in button broken" report: Steve's
// session died mid-deploy). File is gitignored + rsync-excluded like its siblings.
const USER_SESSIONS_FILE = join(__dirname, "../../data/user-sessions.json");
async function saveUserSessions(): Promise<void> {
  try {
    await mkdir(dirname(USER_SESSIONS_FILE), { recursive: true });
    const live = Object.fromEntries([...userSessions].filter(([, s]) => s.exp > Date.now()));
    await writeFile(USER_SESSIONS_FILE, JSON.stringify(live));
  } catch (e) { console.error("saveUserSessions:", String(e)); }
}
async function loadUserSessions(): Promise<void> {
  try {
    const raw = await readFile(USER_SESSIONS_FILE, "utf8");
    const obj = JSON.parse(raw) as Record<string, UserSessionRec>;
    for (const [sid, s] of Object.entries(obj)) if (s?.user?.sub && s.exp > Date.now()) userSessions.set(sid, s);
    if (userSessions.size) console.log(`Restored ${userSessions.size} user session(s) from disk.`);
  } catch { /* first boot — no file yet */ }
}
// --- Shared end-user sign-in (Google / Apple / email all land here) -------
// One-time session-handoff tokens for the native app. OAuth providers reject
// embedded WebViews (Google's `disallowed_useragent`, Tesla's auth page), so the
// app runs sign-in in the SYSTEM browser (ASWebAuthenticationSession) — but that
// lands the session cookie in Safari's jar, not the app's WebView. Fix: the
// callback stashes the exact Set-Cookie under a short-lived token, deep-links it
// back to the app, and the app replays it at /auth/handoff (a request the WebView
// itself makes) so the cookie lands in the shared WebView jar. Single-use, 5 min.
const APP_SCHEME = "chargeandexplore";
const HANDOFF_TTL_MS = 5 * 60 * 1000;
// setCookie is one cookie header value, or an array of them (the demo mints two:
// ce_user AND ce_sess). The /auth/handoff replay passes it straight to writeHead,
// which accepts a string or a string[] for set-cookie either way.
const handoffTokens = new Map<string, { setCookie: string | string[]; location: string; exp: number }>();
// Per-IP counter for the demo-login mint guard (see /auth/demo/login).
const demoRate = new Map<string, { at: number; n: number }>();

// Finish a login: web → set the cookie(s) + 302 to `location`; native app (appFlow)
// → stash the cookie under a one-time token and 302 to the app's deep link, which
// ASWebAuthenticationSession captures and hands back for the /auth/handoff replay.
function completeLogin(res: ServerResponse, setCookie: string | string[], appFlow: boolean, location = "/", extra: Record<string, string> = {}): void {
  if (appFlow) {
    const token = randomBytes(24).toString("hex");
    handoffTokens.set(token, { setCookie, location, exp: Date.now() + HANDOFF_TTL_MS });
    res.writeHead(302, { ...extra, location: `${APP_SCHEME}://auth?token=${token}` });
    res.end();
    return;
  }
  res.writeHead(302, { ...extra, "set-cookie": setCookie, location });
  res.end();
}

// Mints the ce_user session cookie + restores saved vehicle/tier, exactly like
// the Google callback, then completes the login (web cookie or native handoff).
// Sub namespaces the provider so an Apple and a Google account with the same
// email stay distinct records.
function signInUser(res: ServerResponse, user: GoogleUser, appFlow = false): void {
  const sid = randomBytes(24).toString("hex");
  const savedVehicle = userPrefs.get(user.sub)?.vehicle;
  const savedTier = userPrefs.get(user.sub)?.tier;
  userSessions.set(sid, { user, exp: Date.now() + 30 * 24 * 60 * 60 * 1000, vehicle: savedVehicle, tier: savedTier });
  void saveUserSessions();
  completeLogin(res, `ce_user=${sid}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${30 * 24 * 60 * 60}`, appFlow);
}
const b64u = (s: string | Buffer) => Buffer.from(s).toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");

// Apple client_secret: a short-lived ES256 JWT signed with the .p8 sign-in key.
function appleClientSecret(): string {
  const now = Math.floor(Date.now() / 1000);
  const head = b64u(JSON.stringify({ alg: "ES256", kid: APPLE_KEY_ID }));
  const body = b64u(JSON.stringify({ iss: APPLE_TEAM_ID, iat: now, exp: now + 60 * 30, aud: "https://appleid.apple.com", sub: APPLE_SERVICES_ID }));
  const sig = ecdsaSign("sha256", Buffer.from(`${head}.${body}`), { key: APPLE_PRIVATE_KEY, dsaEncoding: "ieee-p1363" });
  return `${head}.${body}.${b64u(sig)}`;
}

// Magic-link tokens for passwordless email sign-in (token -> email, 24h TTL).
// Persisted like user sessions — the map alone dies on every deploy/restart, so
// a link emailed minutes earlier bounced with "expired" (Dust2026 8/5 report);
// the old 15-min TTL did the same to anyone reading email later. Links stay
// single-use, so the longer window only widens when, not how often, one works.
const MAGIC_LINK_TTL_MS = 24 * 60 * 60 * 1000;
const magicLinks = new Map<string, { email: string; exp: number }>();
const MAGIC_LINKS_FILE = join(__dirname, "../../data/magic-links.json");
async function saveMagicLinks(): Promise<void> {
  try {
    await mkdir(dirname(MAGIC_LINKS_FILE), { recursive: true });
    const live = Object.fromEntries([...magicLinks].filter(([, r]) => r.exp > Date.now()));
    await writeFile(MAGIC_LINKS_FILE, JSON.stringify(live));
  } catch (e) { console.error("saveMagicLinks:", String(e)); }
}
async function loadMagicLinks(): Promise<void> {
  try {
    const raw = await readFile(MAGIC_LINKS_FILE, "utf8");
    const obj = JSON.parse(raw) as Record<string, { email: string; exp: number }>;
    for (const [t, r] of Object.entries(obj)) if (r?.email && r.exp > Date.now()) magicLinks.set(t, r);
  } catch { /* first boot — no file yet */ }
}

// Minimal zero-dependency SMTP-over-implicit-TLS sender (port 465). Best-effort;
// callers fall back to surfacing the link directly if this rejects.
function smtpSend(to: string, subject: string, html: string): Promise<void> {
  return new Promise((resolve, reject) => {
    if (!SMTP_HOST || !SMTP_USER) return reject(new Error("SMTP not configured"));
    const from = MAIL_FROM.match(/<(.+)>/)?.[1] ?? MAIL_FROM;
    const data =
      `From: ${MAIL_FROM}\r\nTo: <${to}>\r\nSubject: ${subject}\r\n` +
      `MIME-Version: 1.0\r\nContent-Type: text/html; charset=utf-8\r\n\r\n${html}`;
    const steps = [
      "EHLO chargeandexplore.com", "AUTH LOGIN",
      Buffer.from(SMTP_USER).toString("base64"), Buffer.from(SMTP_PASS).toString("base64"),
      `MAIL FROM:<${from}>`, `RCPT TO:<${to}>`, "DATA",
      data.replace(/\r\n\./g, "\r\n..") + "\r\n.", "QUIT",
    ];
    const sock = tlsConnect(SMTP_PORT, SMTP_HOST, { servername: SMTP_HOST });
    const fail = (e: unknown) => { try { sock.destroy(); } catch { /* */ } reject(e instanceof Error ? e : new Error(String(e))); };
    let i = -1, buf = "";
    sock.setTimeout(15000, () => fail(new Error("smtp timeout")));
    sock.on("error", fail);
    sock.on("data", (d) => {
      buf += d.toString();
      if (!buf.endsWith("\r\n")) return;                 // await a complete reply
      const last = buf.trim().split("\r\n").pop() ?? "";
      if (last[3] === "-") { buf = ""; return; }          // multi-line reply, keep reading
      const code = Number(last.slice(0, 3)); buf = "";
      if (code >= 400) return fail(new Error(`smtp ${last}`));
      if (++i >= steps.length) { resolve(); return sock.end() as unknown as void; }
      sock.write(steps[i] + "\r\n");
      if (steps[i] === "QUIT") { resolve(); sock.end(); }
    });
  });
}

// Smartcar OAuth state → which signed-in session initiated it (+ the exact redirect_uri used).
const scStateStore = new Map<string, { sid: string; exp: number; redirectUri: string }>();
// Resolved Places photo URLs (ref → googleusercontent URI), 12h TTL — see /api/places/photo.
const photoUriCache = new Map<string, { at: number; uri: string }>();

function cookies(req: IncomingMessage): Record<string, string> {
  const out: Record<string, string> = {};
  (req.headers.cookie ?? "").split(";").forEach((c) => {
    const i = c.indexOf("="); if (i > 0) out[c.slice(0, i).trim()] = c.slice(i + 1).trim();
  });
  return out;
}
function getSession(req: IncomingMessage) {
  const sid = cookies(req).ce_sess;
  const s = sid ? sessions.get(sid) : undefined;
  if (s && s.exp > Date.now()) return s;
  return undefined;
}
function getUser(req: IncomingMessage): GoogleUser | undefined {
  const sid = cookies(req).ce_user;
  const s = sid ? userSessions.get(sid) : undefined;
  if (s && s.exp > Date.now()) return s.user;
  return undefined;
}
function getUserSession(req: IncomingMessage) {
  const sid = cookies(req).ce_user;
  const s = sid ? userSessions.get(sid) : undefined;
  if (sid && s && s.exp > Date.now()) return { sid, rec: s };
  return undefined;
}
async function readJsonBody(req: IncomingMessage): Promise<any> {
  return new Promise((resolve) => {
    let d = ""; req.on("data", (c) => (d += c)); req.on("end", () => { try { resolve(JSON.parse(d || "{}")); } catch { resolve({}); } });
  });
}
// Raw (unparsed) body — Stripe webhook signatures verify over the exact bytes.
async function readRawBody(req: IncomingMessage): Promise<string> {
  return new Promise((resolve) => {
    let d = ""; req.on("data", (c) => (d += c)); req.on("end", () => resolve(d));
  });
}

// --- Billing tier plumbing (TK-10227) ---------------------------------------
// Persist a webhook-granted tier onto every live session for that Google user
// (data/user-sessions.json) AND the durable per-user prefs, so the tier
// survives session expiry and is restored at the next Google sign-in.
function setTierForUser(sub: string, tier: Tier): void {
  let touched = false;
  for (const rec of userSessions.values()) {
    if (rec.user.sub === sub && rec.tier !== tier) { rec.tier = tier; touched = true; }
  }
  if (touched) void saveUserSessions();
  const p = userPrefs.get(sub) ?? {};
  if (p.tier !== tier) { p.tier = tier; userPrefs.set(sub, p); void savePrefs(); }
}
// The tier to ENFORCE for this request. null = billing is off → no gating at
// all (every endpoint behaves exactly as it did before billing existed).
function effectiveTier(req: IncomingMessage): Tier | null {
  if (!billing.configured) return null;
  return normalizeTier(getUserSession(req)?.rec.tier);
}
function checkBasicAuth(req: IncomingMessage): boolean {
  const h = req.headers.authorization ?? "";
  if (!h.startsWith("Basic ")) return false;
  const [u, p] = Buffer.from(h.slice(6), "base64").toString().split(":");
  return u === ADMIN_USER && p === ADMIN_PASS;
}

const MIME: Record<string, string> = {
  ".html": "text/html; charset=utf-8",
  ".css": "text/css; charset=utf-8",
  ".js": "text/javascript; charset=utf-8",
  ".svg": "image/svg+xml",
  ".json": "application/json; charset=utf-8",
  ".webmanifest": "application/manifest+json; charset=utf-8",
  ".png": "image/png",
};

// --- demo dataset: synthetic stops so the public page shows real ranked output ---
function demoCandidates(): StopCandidate[] {
  const charge = {
    arrivalSocPercent: 20,
    targetSocPercent: 80,
    usableBatteryKWh: 80,
    vehicleMaxDcKw: 250,
    stationMaxKw: 120,
    chargingCurve: defaultChargeCurve,
    environmentFactor: 1,
    conversionEfficiency: 0.92,
    connectionOverheadMinutes: 2,
  };
  const returnAt = new Date(Date.now() + 60 * 60 * 1000);
  const act = (placeId: string, visit: number, walk: number, pref: number) => ({
    placeId, distanceMeters: Math.round(walk * 80), walkOutMinutes: walk, walkBackMinutes: walk,
    suggestedVisitMinutes: visit, openThrough: null, preferenceScore: pref, qualityScore: 80,
    accessibilityScore: 80, weatherFitScore: 80, dataFreshnessScore: 80,
  });
  return [
    {
      stationId: "demo-1", name: "Riverside Town Center", detourMinutes: 3,
      charger: { power: 92, stalls: 85, reliability: 88, dynamicAvailability: 75 },
      amenity: 88, nighttime: 80, walkability: 85, basicNeeds: 95, dataCompleteness: 0.9,
      charge, activities: [act("Blue Barn Café", 15, 4, 90), act("Riverwalk Park", 20, 5, 82)],
      expectedReturnAt: returnAt,
    },
    {
      stationId: "demo-2", name: "Highway 5 Rest Plaza", detourMinutes: 1,
      charger: { power: 78, stalls: 60, reliability: 70, dynamicAvailability: 50 },
      amenity: 55, nighttime: 90, walkability: 50, basicNeeds: 90, dataCompleteness: 0.6,
      charge, activities: [act("Plaza Deli", 18, 3, 70)],
      expectedReturnAt: returnAt,
    },
    {
      stationId: "demo-3", name: "Outlet Mall Garage", detourMinutes: 14,
      charger: { power: 60, stalls: 40, reliability: 60, dynamicAvailability: 0 },
      amenity: 92, nighttime: 55, walkability: 70, basicNeeds: 80, dataCompleteness: 0.5,
      charge, activities: [act("Food Court", 25, 6, 78), act("Bookstore", 20, 7, 65)],
      expectedReturnAt: returnAt,
    },
  ];
}

// Synthetic raw Fleet-API vehicle_data payload for the demo session — mirrors
// the real /api/1/vehicles/<vin>/vehicle_data shape so the Dashboard "all specs"
// view renders every section with NO real Tesla call. Built from the seeded
// lastKnown so the numbers match the summary card + Stats grid.
function demoVehicleData(lk?: TeslaLastKnown): any {
  const s = lk?.stats ?? {};
  return {
    response: {
      vin: lk?.vin ?? "5YJ3DEMO000REVIEW",
      display_name: lk?.name ?? "Demo Model 3",
      state: "online",
      charge_state: {
        battery_level: lk?.soc ?? 62,
        usable_battery_level: s.usableSoc ?? 62,
        battery_range: lk?.rangeMiles ?? 168,
        est_battery_range: s.estRangeMiles ?? 162,
        charging_state: lk?.chargingState ?? "Charging",
        charge_limit_soc: s.chargeLimit ?? 80,
        charge_rate: s.chargeRateMph ?? 34,
        charger_power: s.chargerPowerKw ?? 48,
        charger_voltage: 396, charger_actual_current: 121,
        minutes_to_full_charge: s.minutesToFull ?? 27,
        charge_energy_added: s.energyAddedKWh ?? 19.4,
        time_to_full_charge: 0.45, fast_charger_present: true, fast_charger_type: "Tesla",
      },
      climate_state: {
        inside_temp: s.insideTempC ?? 21, outside_temp: s.outsideTempC ?? 17,
        is_climate_on: false, driver_temp_setting: 21, passenger_temp_setting: 21,
      },
      drive_state: {
        latitude: 34.0522, longitude: -118.2437, heading: 0, speed: null, shift_state: null,
        active_route_destination: null,
      },
      vehicle_state: {
        odometer: s.odometerMiles ?? 14238, locked: s.locked ?? true, sentry_mode: s.sentry ?? true,
        car_version: `${s.version ?? "2026.20.6"} demo`, vehicle_name: lk?.name ?? "Demo Model 3",
        software_update: { status: "", download_perc: 0, install_perc: 0 },
    },
      vehicle_config: { car_type: "model3", trim_badging: "long range", exterior_color: "MidnightSilver", wheel_type: "Stiletto19" },
      gui_settings: { gui_distance_units: "mi/hr", gui_temperature_units: "F", gui_range_display: "Rated" },
    },
  };
}

// ---- full EV-charger dataset (populated from NREL on boot; served by viewport) ----
interface CompactStation { id: number; name: string; lat: number; lon: number; net: string | null; dc: number | null; conn: string[] | null; addr: string | null }
const STATIONS_FILE = join(__dirname, "../../data/stations.json");
let allStations: CompactStation[] = [];
const geoCache = new Map<string, { at: number; hits: Array<{ name: string; lat: number; lon: number }> }>();
const trafficCache = new Map<string, { at: number; data: unknown }>();

async function populateStations(): Promise<void> {
  try {
    const raw = await readFile(STATIONS_FILE, "utf8");
    allStations = JSON.parse(raw);
    console.log(`Loaded ${allStations.length} EV chargers from cache.`);
    return;
  } catch { /* no cache yet — fetch below */ }
  try {
    const key = process.env.NREL_API_KEY ?? "DEMO_KEY";
    console.log("Populating ALL EV chargers from NREL (US)…");
    // developer.nrel.gov DNS was retired 2026-08-01; the API lives on at developer.nlr.gov (same paths/keys).
    const r = await fetch(`https://developer.nlr.gov/api/alt-fuel-stations/v1.json?api_key=${key}&fuel_type=ELEC&country=US&limit=all`);
    if (!r.ok) { console.error(`NREL populate failed: ${r.status}`); return; }
    const j = (await r.json()) as { fuel_stations?: any[] };
    allStations = (j.fuel_stations ?? []).map((s) => ({
      id: s.id, name: s.station_name, lat: s.latitude, lon: s.longitude,
      net: s.ev_network, dc: s.ev_dc_fast_num, conn: s.ev_connector_types,
      addr: [s.street_address, s.city, s.state].filter(Boolean).join(", ") || null,
    }));
    await mkdir(dirname(STATIONS_FILE), { recursive: true });
    await writeFile(STATIONS_FILE, JSON.stringify(allStations));
    console.log(`Populated ${allStations.length} EV chargers.`);
  } catch (e) { console.error("populateStations error:", String(e)); }
}

function haversineKm(aLat: number, aLon: number, bLat: number, bLon: number): number {
  const R = 6371, toRad = (d: number) => (d * Math.PI) / 180;
  const dLat = toRad(bLat - aLat), dLon = toRad(bLon - aLon);
  const s = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2;
  return R * 2 * Math.asin(Math.sqrt(s));
}
// Coarse charger power from connector/DC signals (NREL gives no kW).
function chargerKw(s: CompactStation): number {
  const c = s.conn ?? [];
  const isTesla = c.includes("TESLA") || c.includes("J3400");
  const dc = s.dc ?? 0;
  // Distinguish DC-fast from Level 2: a Tesla site with DC ports is a Supercharger
  // (250 kW); a Tesla site with NO DC ports is a Destination charger (Level 2, ~11 kW).
  if (isTesla && dc > 0) return 250;              // Supercharger
  if (isTesla) return 11;                          // Tesla Destination (Level 2)
  if (c.includes("J1772COMBO") && dc > 0) return 150; // CCS DC fast
  if (dc > 0) return 50;                           // other DC fast
  return 7;                                         // Level 2 / unknown
}

// Map a raw Fleet-API vehicle_data payload onto our compact card + stats shape.
function lastKnownFrom(v: any, dj: any): TeslaLastKnown {
  const cs = dj.response?.charge_state ?? {};
  const cl = dj.response?.climate_state ?? {};
  const vs = dj.response?.vehicle_state ?? {};
  const num = (x: unknown) => (typeof x === "number" ? x : null);
  return {
    vin: v.vin, name: dj.response?.display_name ?? v.display_name ?? null,
    soc: num(cs.battery_level),
    chargingState: cs.charging_state ?? null,
    rangeMiles: num(cs.battery_range) != null ? Math.round(cs.battery_range) : null,
    at: Date.now(),
    stats: {
      usableSoc: num(cs.usable_battery_level),
      estRangeMiles: num(cs.est_battery_range) != null ? Math.round(cs.est_battery_range) : null,
      chargeLimit: num(cs.charge_limit_soc),
      chargeRateMph: num(cs.charge_rate),
      chargerPowerKw: num(cs.charger_power),
      minutesToFull: num(cs.minutes_to_full_charge),
      energyAddedKWh: num(cs.charge_energy_added),
      insideTempC: num(cl.inside_temp),
      outsideTempC: num(cl.outside_temp),
      odometerMiles: num(vs.odometer) != null ? Math.round(vs.odometer) : null,
      locked: typeof vs.locked === "boolean" ? vs.locked : null,
      sentry: typeof vs.sentry_mode === "boolean" ? vs.sentry_mode : null,
      version: typeof vs.car_version === "string" ? vs.car_version.split(" ")[0] : null,
    },
  };
}

// Hourly background sampler → keeps the graphs fed between visits. Multi-car
// (TK-10227): samples EVERY vehicle on the account, each behind its own per-vin
// freshness gate (<55 min = skip), and NEVER wakes a sleeping car (a 408 is
// left alone). Read budget: ≤24 vehicle_data calls/day PER CAR + ≤24 cheap
// vehicle-list calls/day/session (same list call the single-car version made) —
// still comfortably inside Tesla's free Fleet-API monthly credit.
async function sampleTeslaHistory(): Promise<void> {
  const fresh = (vin: string) => {
    const pts = history.get(vin) ?? [];
    const last = pts[pts.length - 1];
    return !!last && Date.now() - last.t < 55 * 60 * 1000;
  };
  for (const sess of sessions.values()) {
    if (sess.exp <= Date.now()) continue;
    if (sess.demo) continue; // synthetic reviewer session — never call Tesla with fake tokens
    // If every vehicle we've ever seen on this session is fresh, skip even the
    // list call (matches the old single-car short-circuit).
    const knownVins = new Set<string>([
      ...(sess.lastKnown?.vin ? [sess.lastKnown.vin] : []),
      ...Object.keys(sess.lastKnownByVin ?? {}),
    ]);
    if (knownVins.size && [...knownVins].every(fresh)) continue;
    try {
      await ensureFreshTeslaTokens(sess);
      const auth = { authorization: `Bearer ${sess.tokens.access_token}` };
      const vr = await fetch(`${TESLA_AUDIENCE}/api/1/vehicles`, { headers: auth, signal: AbortSignal.timeout(10_000) });
      if (!vr.ok) continue;
      const list = ((await vr.json()) as { response?: any[] }).response ?? [];
      const firstVin: string | undefined = list[0]?.vin;
      let sampled = false;
      for (const v of list) {
        if (!v?.vin || fresh(v.vin)) continue;
        const dr = await fetch(`${TESLA_AUDIENCE}/api/1/vehicles/${v.vin}/vehicle_data`, { headers: auth, signal: AbortSignal.timeout(12_000) });
        if (!dr.ok) continue; // 408 = asleep; leave it be
        const lk = lastKnownFrom(v, await dr.json());
        cacheLastKnown(sess, lk, firstVin);
        sampled = true;
        recordHistory(lk);
      }
      if (sampled) void saveTeslaSessions();
    } catch { /* transient — next tick */ }
  }
}

function json(res: import("node:http").ServerResponse, code: number, body: unknown) {
  const s = JSON.stringify(body);
  res.writeHead(code, { "content-type": "application/json; charset=utf-8", "content-length": Buffer.byteLength(s) });
  res.end(s);
}

const server = createServer(async (req, res) => {
  const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
  const path = url.pathname;

  // Unauthenticated health check (fleet keepalive + deploy smoke test).
  if (path === "/healthz") return json(res, 200, { ok: true, service: "charge-and-explore" });

  if (path === "/api/recommend/demo") {
    const cands = demoCandidates();
    const detourById = new Map(cands.map((c) => [c.stationId, c.detourMinutes]));
    const stops = recommendStops(cands).map((r) => ({
      ...r,
      detourMinutes: detourById.get(r.stationId) ?? 0,
    }));
    return json(res, 200, { stops });
  }

  // All populated chargers — count, and viewport (bbox) query for the map.
  if (path === "/api/stations/count") {
    return json(res, 200, { count: allStations.length });
  }
  if (path === "/api/stations/bbox") {
    const q = url.searchParams;
    const minLat = Number(q.get("minLat")), maxLat = Number(q.get("maxLat"));
    const minLon = Number(q.get("minLon")), maxLon = Number(q.get("maxLon"));
    if (![minLat, maxLat, minLon, maxLon].every(Number.isFinite)) return json(res, 400, { error: "minLat,maxLat,minLon,maxLon required" });
    const net = (q.get("network") ?? "all").toLowerCase(); // all | tesla | standard
    const isTesla = (s: CompactStation) => /tesla/i.test(s.net ?? "") || (s.conn ?? []).some((c) => c === "TESLA" || c === "J3400");
    let hits = allStations.filter((s) => s.lat >= minLat && s.lat <= maxLat && s.lon >= minLon && s.lon <= maxLon);
    if (net === "tesla") hits = hits.filter(isTesla);
    else if (net === "standard") hits = hits.filter((s) => !isTesla(s));
    return json(res, 200, { total: allStations.length, shown: Math.min(hits.length, 800), network: net, stations: hits.slice(0, 800) });
  }

  // "Stuff to do" near a charger (free OSM Overpass).
  if (path === "/api/stations/activities") {
    const lat = Number(url.searchParams.get("lat")), lon = Number(url.searchParams.get("lon"));
    if (!Number.isFinite(lat) || !Number.isFinite(lon)) return json(res, 400, { error: "lat & lon required" });
    try {
      const activities = await activitiesNear({ latitude: lat, longitude: lon }, 800);
      return json(res, 200, { count: activities.length, activities });
    } catch { return json(res, 200, { count: 0, activities: [], unavailable: true }); }
  }

  // Photo for a place chip's popup card. Resolves a Google Places photo resource
  // name to its short-lived googleusercontent URL server-side (API key never
  // reaches the client) and 302-redirects the browser to it. Cached 12h per ref
  // so repeat card opens don't re-bill (~$7/1k photo resolutions).
  if (path === "/api/places/photo") {
    const ref = url.searchParams.get("ref") ?? "";
    const key = process.env.GOOGLE_PLACES_API_KEY;
    if (!key || !/^places\/[\w-]+\/photos\/[\w-]+$/.test(ref)) { res.writeHead(404); return res.end(); }
    const hit = photoUriCache.get(ref);
    if (hit && Date.now() - hit.at < 12 * 60 * 60 * 1000) {
      res.writeHead(302, { Location: hit.uri, "Cache-Control": "public, max-age=43200" }); return res.end();
    }
    try {
      const r = await fetch(`https://places.googleapis.com/v1/${ref}/media?maxWidthPx=560&skipHttpRedirect=true&key=${key}`, { signal: AbortSignal.timeout(8000) });
      if (!r.ok) throw new Error(String(r.status));
      const { photoUri } = (await r.json()) as { photoUri?: string };
      if (!photoUri) throw new Error("no photoUri");
      photoUriCache.set(ref, { at: Date.now(), uri: photoUri });
      res.writeHead(302, { Location: photoUri, "Cache-Control": "public, max-age=43200" }); return res.end();
    } catch { res.writeHead(404); return res.end(); }
  }

  // Auto-pick the single best-ranked reachable stop for the user right now, with
  // location, charge time, directions + things to do. Powers the phone "on open" hero.
  if (path === "/api/best-stop") {
    const lat = Number(url.searchParams.get("lat")), lon = Number(url.searchParams.get("lon"));
    if (!Number.isFinite(lat) || !Number.isFinite(lon)) return json(res, 400, { error: "lat & lon required" });
    const soc = Math.max(0, Math.min(100, Number(url.searchParams.get("soc") ?? 30)));
    const veh = vehicleById(url.searchParams.get("vehicleId") ?? "");
    const battery = veh?.usableBatteryKWh ?? 75, maxkw = veh?.maxDcKw ?? 250, curve = veh?.curve ?? defaultChargeCurve;
    const vehicleLabel = veh ? `${veh.make} ${veh.model}` : "default profile";
    // REACHABILITY GATE: never recommend a charger the car can't safely reach.
    // usable range ≈ battery·SOC·5 km/kWh, discounted ×0.7 for driving detour
    // (~1.3× straight-line) + a safety reserve. Cap at 40 km for the local view.
    const safeRangeKm = Math.round(battery * (soc / 100) * 5 * 0.7);
    const reachKm = Math.min(40, safeRangeKm);
    const ranked = allStations
      .map((s) => ({ s, km: haversineKm(lat, lon, s.lat, s.lon) }))
      .filter((x) => x.km <= reachKm)
      .map((x) => ({ ...x, kw: chargerKw(x.s) }))
      // transparent score: charger power (55%) + proximity decay (45%)
      .map((x) => ({ ...x, score: 0.55 * Math.min(1, x.kw / 250) * 100 + 0.45 * (100 * Math.exp(-x.km / 12)) }))
      .sort((a, b) => b.score - a.score);
    if (!ranked.length) return json(res, 200, {
      stops: [], bestStop: null, reachable: false, safeRangeKm,
      message: `At ${soc}% you have only ~${safeRangeKm} km of safe range — no charger that close. Charge before heading to farther stops.`,
    });
    // Return the ranked LIST (top 8) so the UI can page Next/Back. Activities are
    // fetched per-stop on demand via /api/stations/activities (keeps this fast).
    const stops = ranked.slice(0, 8).map((x) => ({
      id: x.s.id, name: x.s.name, network: x.s.net, addr: x.s.addr, lat: x.s.lat, lon: x.s.lon, maxKw: x.kw,
      stalls: x.s.dc ?? null, // real DC-fast port count (NREL ev_dc_fast_num); live occupancy needs a network API
      distanceKm: Math.round(x.km * 10) / 10, distanceMiles: Math.round(x.km * 0.621371 * 10) / 10,
      score: Math.round(x.score), vehicle: vehicleLabel,
      chargeMinutes: estimateChargeRange({
        arrivalSocPercent: soc, targetSocPercent: 80, usableBatteryKWh: battery, vehicleMaxDcKw: maxkw,
        stationMaxKw: Math.min(maxkw, x.kw), chargingCurve: curve, environmentFactor: 1, conversionEfficiency: 0.92, connectionOverheadMinutes: 2,
      }),
      directions: `https://www.google.com/maps/dir/?api=1&origin=${lat},${lon}&destination=${x.s.lat},${x.s.lon}`,
    }));
    return json(res, 200, { vehicle: vehicleLabel, count: stops.length, stops, bestStop: stops[0] });
  }

  // Geocode a destination (address/place -> coords) via Nominatim (reachable from Kamatera).
  if (path === "/api/geocode") {
    const q = (url.searchParams.get("q") ?? "").trim();
    if (q.length < 2) return json(res, 400, { error: "q required" });
    const key = q.toLowerCase();
    const cached = geoCache.get(key);
    if (cached && Date.now() - cached.at < 24 * 3600 * 1000) return json(res, 200, { results: cached.hits });
    try {
      const r = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=5&countrycodes=us,ca`, {
        headers: { "user-agent": "ChargeAndExplore/1.0 (chargeandexplore.agentabrams.com)" }, signal: AbortSignal.timeout(8000),
      });
      const arr = (await r.json()) as any[];
      const hits = (arr ?? []).map((a) => ({ name: a.display_name as string, lat: Number(a.lat), lon: Number(a.lon) })).slice(0, 5);
      geoCache.set(key, { at: Date.now(), hits });
      return json(res, 200, { results: hits });
    } catch { return json(res, 200, { results: [], unavailable: true }); }
  }

  // Real-time traffic-aware drive time to a charger via Google Routes API.
  // On-demand + 10-min cache (traffic shifts) so cost stays near $0. Server-side
  // key only. Returns live ETA, free-flow ETA, and the traffic delay/level.
  if (path === "/api/traffic-eta") {
    const fromLat = Number(url.searchParams.get("fromLat")), fromLon = Number(url.searchParams.get("fromLon"));
    const toLat = Number(url.searchParams.get("toLat")), toLon = Number(url.searchParams.get("toLon"));
    if (![fromLat, fromLon, toLat, toLon].every(Number.isFinite)) return json(res, 400, { error: "fromLat,fromLon,toLat,toLon required" });
    const gkey = process.env.GOOGLE_PLACES_API_KEY;
    if (!gkey) return json(res, 200, { unavailable: true, reason: "no key" });
    const cacheKey = `${fromLat.toFixed(3)},${fromLon.toFixed(3)}>${toLat.toFixed(4)},${toLon.toFixed(4)}`;
    const hit = trafficCache.get(cacheKey);
    if (hit && Date.now() - hit.at < 10 * 60 * 1000) return json(res, 200, hit.data as object);
    try {
      const r = await fetch("https://routes.googleapis.com/directions/v2:computeRoutes", {
        method: "POST",
        headers: {
          "content-type": "application/json",
          "X-Goog-Api-Key": gkey,
          "X-Goog-FieldMask": "routes.duration,routes.staticDuration,routes.distanceMeters",
        },
        body: JSON.stringify({
          origin: { location: { latLng: { latitude: fromLat, longitude: fromLon } } },
          destination: { location: { latLng: { latitude: toLat, longitude: toLon } } },
          travelMode: "DRIVE", routingPreference: "TRAFFIC_AWARE",
        }),
        signal: AbortSignal.timeout(8000),
      });
      if (!r.ok) { const detail = await r.text(); return json(res, 200, { unavailable: true, reason: `routes ${r.status}`, detail: detail.slice(0, 200) }); }
      const j = (await r.json()) as { routes?: Array<{ duration?: string; staticDuration?: string; distanceMeters?: number }> };
      const route = j.routes?.[0];
      if (!route?.duration) return json(res, 200, { unavailable: true, reason: "no route" });
      const secs = (v?: string) => (v ? parseInt(v.replace("s", ""), 10) : 0);
      const live = secs(route.duration), free = secs(route.staticDuration) || live;
      const delayMin = Math.max(0, Math.round((live - free) / 60));
      const ratio = free > 0 ? live / free : 1;
      const level = ratio >= 1.5 ? "heavy" : ratio >= 1.2 ? "moderate" : "light";
      const data = {
        minutes: Math.round(live / 60), freeMinutes: Math.round(free / 60), delayMin, level,
        distanceMiles: route.distanceMeters ? Math.round((route.distanceMeters / 1609.34) * 10) / 10 : null,
      };
      trafficCache.set(cacheKey, { at: Date.now(), data });
      return json(res, 200, data);
    } catch (e) { return json(res, 200, { unavailable: true, reason: String(e) }); }
  }

  // Trip to a destination: distance, reachability, full-charge time, chargers near the destination.
  if (path === "/api/trip") {
    const fromLat = Number(url.searchParams.get("fromLat")), fromLon = Number(url.searchParams.get("fromLon"));
    const destLat = Number(url.searchParams.get("destLat")), destLon = Number(url.searchParams.get("destLon"));
    if (![fromLat, fromLon, destLat, destLon].every(Number.isFinite)) return json(res, 400, { error: "fromLat,fromLon,destLat,destLon required" });
    const soc = Math.max(0, Math.min(100, Number(url.searchParams.get("soc") ?? 30)));
    const veh = vehicleById(url.searchParams.get("vehicleId") ?? "");
    const battery = veh?.usableBatteryKWh ?? 75, maxkw = veh?.maxDcKw ?? 250, curve = veh?.curve ?? defaultChargeCurve;
    const distKm = haversineKm(fromLat, fromLon, destLat, destLon);
    const safeRangeKm = battery * (soc / 100) * 5 * 0.7;
    const fullChargeMinutes = estimateChargeRange({
      arrivalSocPercent: 10, targetSocPercent: 100, usableBatteryKWh: battery, vehicleMaxDcKw: maxkw,
      stationMaxKw: maxkw, chargingCurve: curve, environmentFactor: 1, conversionEfficiency: 0.92, connectionOverheadMinutes: 2,
    });
    const chargersNearDestination = allStations
      .map((s) => ({ s, km: haversineKm(destLat, destLon, s.lat, s.lon) }))
      .filter((x) => x.km <= 15)
      .map((x) => ({ ...x, kw: chargerKw(x.s) }))
      .sort((a, b) => b.kw - a.kw || a.km - b.km)
      .slice(0, 5)
      .map((x) => ({
        name: x.s.name, network: x.s.net, addr: x.s.addr, lat: x.s.lat, lon: x.s.lon, maxKw: x.kw,
        distanceMiles: Math.round(x.km * 0.621371 * 10) / 10,
        directions: `https://www.google.com/maps/dir/?api=1&origin=${fromLat},${fromLon}&destination=${x.s.lat},${x.s.lon}`,
      }));
    return json(res, 200, {
      distanceMiles: Math.round(distKm * 0.621371 * 10) / 10, distanceKm: Math.round(distKm * 10) / 10,
      reachable: distKm <= safeRangeKm, safeRangeMiles: Math.round(safeRangeKm * 0.621371),
      fullChargeMinutes, vehicle: veh ? `${veh.make} ${veh.model}` : "default profile",
      directions: `https://www.google.com/maps/dir/?api=1&origin=${fromLat},${fromLon}&destination=${destLat},${destLon}`,
      chargersNearDestination,
    });
  }

  if (path === "/api/stations/near") {
    const lat = Number(url.searchParams.get("lat"));
    const lon = Number(url.searchParams.get("lon"));
    if (!Number.isFinite(lat) || !Number.isFinite(lon)) return json(res, 400, { error: "lat & lon required" });
    try {
      const stations = await new NrelStationProvider().stationsNear({ latitude: lat, longitude: lon }, 8000);
      return json(res, 200, { count: stations.length, stations });
    } catch (e) {
      return json(res, 502, { error: "upstream station lookup failed", detail: String(e) });
    }
  }

  // --- Google sign-in (end users). Standard OAuth code flow + PKCE; the
  //     id_token comes straight from Google's token endpoint over TLS, so its
  //     claims are trusted without a separate signature check. ---

  if (path === "/auth/google/login") {
    if (!GOOGLE_CLIENT_ID) return json(res, 500, { error: "GOOGLE_OAUTH_CLIENT_ID not configured" });
    const state = randomBytes(16).toString("hex");
    const verifier = randomBytes(32).toString("base64url");
    const challenge = createHash("sha256").update(verifier).digest("base64url");
    const gRedirect = googleRedirectFor(req);
    pkceStore.set(`g:${state}`, { verifier, exp: Date.now() + 10 * 60 * 1000, redirectUri: gRedirect, app: url.searchParams.get("app") === "1" });
    const a = new URL("https://accounts.google.com/o/oauth2/v2/auth");
    a.searchParams.set("response_type", "code");
    a.searchParams.set("client_id", GOOGLE_CLIENT_ID);
    a.searchParams.set("redirect_uri", gRedirect);
    a.searchParams.set("scope", "openid email profile");
    a.searchParams.set("state", state);
    a.searchParams.set("code_challenge", challenge);
    a.searchParams.set("code_challenge_method", "S256");
    a.searchParams.set("prompt", "select_account");
    res.writeHead(302, { location: a.toString() });
    return res.end();
  }

  if (path === "/auth/google/callback") {
    const code = url.searchParams.get("code");
    const state = url.searchParams.get("state");
    const pk = state ? pkceStore.get(`g:${state}`) : undefined;
    if (!code || !pk || pk.exp < Date.now()) return json(res, 400, { error: "invalid or expired state" });
    pkceStore.delete(`g:${state}`);
    try {
      const body = new URLSearchParams({
        grant_type: "authorization_code", client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET,
        code, redirect_uri: pk.redirectUri ?? GOOGLE_REDIRECT_URI, code_verifier: pk.verifier,
      });
      const tr = await fetch("https://oauth2.googleapis.com/token", {
        method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body,
      });
      const tok = (await tr.json()) as { id_token?: string };
      if (!tok.id_token) return json(res, 502, { error: "google token exchange failed", detail: tok });
      const claims = JSON.parse(Buffer.from(tok.id_token.split(".")[1] ?? "", "base64url").toString());
      const user: GoogleUser = {
        sub: String(claims.sub ?? ""), email: String(claims.email ?? ""),
        name: String(claims.name ?? claims.email ?? "Explorer"), picture: String(claims.picture ?? ""),
      };
      if (!user.sub) return json(res, 502, { error: "id_token missing subject" });
      const sid = randomBytes(24).toString("hex");
      // Returning user: restore their saved EV so charge estimates match their
      // car from the first render (settings restore client-side via /api/me/settings).
      const savedVehicle = userPrefs.get(user.sub)?.vehicle;
      // …and their paid tier (billing, TK-10227) — a Plus/Pro subscriber keeps
      // their entitlements across sign-ins without waiting for a webhook.
      const savedTier = userPrefs.get(user.sub)?.tier;
      userSessions.set(sid, { user, exp: Date.now() + 30 * 24 * 60 * 60 * 1000, vehicle: savedVehicle, tier: savedTier });
      void saveUserSessions();
      return completeLogin(res, `ce_user=${sid}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${30 * 24 * 60 * 60}`, !!pk.app);
    } catch (e) { return json(res, 502, { error: "google callback failed", detail: String(e) }); }
  }

  // --- Sign in with Apple (App Store 4.8 privacy option). Auth-code + form_post;
  //     the code is exchanged at Apple's token endpoint over TLS, so the returned
  //     id_token is trusted without a separate signature check (same as Google). ---
  if (path === "/auth/apple/login") {
    if (!APPLE_SERVICES_ID) return json(res, 500, { error: "APPLE_SERVICES_ID not configured" });
    const state = randomBytes(16).toString("hex");
    pkceStore.set(`a:${state}`, { verifier: "", exp: Date.now() + 10 * 60 * 1000, redirectUri: appleRedirectFor(req), app: url.searchParams.get("app") === "1" });
    const a = new URL("https://appleid.apple.com/auth/authorize");
    a.searchParams.set("response_type", "code");
    a.searchParams.set("client_id", APPLE_SERVICES_ID);
    a.searchParams.set("redirect_uri", appleRedirectFor(req));
    a.searchParams.set("scope", "name email");
    a.searchParams.set("response_mode", "form_post");
    a.searchParams.set("state", state);
    res.writeHead(302, { location: a.toString() });
    return res.end();
  }

  if (path === "/auth/apple/callback" && req.method === "POST") {
    const form = new URLSearchParams(await readRawBody(req));
    const code = form.get("code"); const state = form.get("state");
    const pk = state ? pkceStore.get(`a:${state}`) : undefined;
    if (!code || !pk || pk.exp < Date.now()) return json(res, 400, { error: "invalid or expired apple state" });
    pkceStore.delete(`a:${state}`);
    try {
      const body = new URLSearchParams({
        grant_type: "authorization_code", code,
        client_id: APPLE_SERVICES_ID, client_secret: appleClientSecret(),
        redirect_uri: pk.redirectUri ?? appleRedirectFor(req),
      });
      const tr = await fetch("https://appleid.apple.com/auth/token", {
        method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body,
      });
      const tok = (await tr.json()) as { id_token?: string };
      if (!tok.id_token) return json(res, 502, { error: "apple token exchange failed", detail: tok });
      const claims = JSON.parse(Buffer.from(tok.id_token.split(".")[1] ?? "", "base64url").toString());
      if (!claims.sub) return json(res, 502, { error: "apple id_token missing subject" });
      const email = String(claims.email ?? "");
      let name = email ? email.split("@")[0]! : "Explorer";
      // Apple sends the human name ONLY on the first authorization, in a `user` field.
      try { const u = form.get("user"); if (u) { const j = JSON.parse(u); const n = `${j?.name?.firstName ?? ""} ${j?.name?.lastName ?? ""}`.trim(); if (n) name = n; } } catch { /* */ }
      return signInUser(res, { sub: `apple:${String(claims.sub)}`, email, name, picture: "" }, !!pk.app);
    } catch (e) { return json(res, 502, { error: "apple callback failed", detail: String(e) }); }
  }

  // --- Passwordless email sign-in (magic link). request → email a link → verify. ---
  if (path === "/auth/email/request" && req.method === "POST") {
    let email = "";
    try { const b = await readJsonBody(req); email = String((b as any)?.email ?? "").trim().toLowerCase(); } catch { /* */ }
    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return json(res, 400, { error: "enter a valid email" });
    const token = randomBytes(24).toString("hex");
    magicLinks.set(token, { email, exp: Date.now() + MAGIC_LINK_TTL_MS });
    void saveMagicLinks();
    const link = `${oauthBase(req)}/auth/email/verify?token=${token}`;
    const html = `<div style="font-family:system-ui,sans-serif;max-width:460px"><h2>Sign in to Charge &amp; Explore</h2><p>Tap the button to finish signing in:</p><p><a href="${link}" style="display:inline-block;background:#0a7d5a;color:#fff;padding:12px 22px;border-radius:999px;text-decoration:none;font-weight:600">Sign in</a></p><p style="color:#666;font-size:13px">This link works once and expires in 24 hours. If you didn't request it, ignore this email.</p></div>`;
    try {
      await smtpSend(email, "Your Charge & Explore sign-in link", html);
      return json(res, 200, { ok: true, sent: true });
    } catch (e) {
      // SMTP not wired yet — in that case surface the link so the flow still works for testing.
      return json(res, 200, { ok: true, sent: false, ...(SMTP_HOST ? { error: String(e) } : { link }) });
    }
  }

  if (path === "/auth/email/verify") {
    const token = url.searchParams.get("token") ?? "";
    const rec = magicLinks.get(token);
    if (!rec || rec.exp < Date.now()) {
      // A person clicking an emailed link lands here in a browser — serve a
      // human page with a way back, not the raw JSON Steve hit on 8/4.
      if (rec) { magicLinks.delete(token); void saveMagicLinks(); }
      res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
      res.end(`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><title>Link expired — Charge &amp; Explore</title><div style="font-family:system-ui,sans-serif;max-width:460px;margin:15vh auto;text-align:center;padding:0 16px"><h2>That sign-in link has expired</h2><p style="color:#555">Links work once and last 24 hours. Head back and we&rsquo;ll email you a fresh one.</p><p><a href="/" style="display:inline-block;background:#0a7d5a;color:#fff;padding:12px 22px;border-radius:999px;text-decoration:none;font-weight:600">Request a new link</a></p></div>`);
      return;
    }
    magicLinks.delete(token);
    void saveMagicLinks();
    return signInUser(res, { sub: `email:${rec.email}`, email: rec.email, name: rec.email.split("@")[0]!, picture: "" }, url.searchParams.get("app") === "1");
  }

  // Native-app session handoff: the app ran sign-in in the system browser and got
  // a one-time token back over the deep link; it now replays that token HERE,
  // inside its own WebView, so the Set-Cookie lands in the WebView's cookie jar.
  if (path === "/auth/handoff") {
    const token = url.searchParams.get("token") ?? "";
    const rec = handoffTokens.get(token);
    if (rec) handoffTokens.delete(token);
    if (!rec || rec.exp < Date.now()) { res.writeHead(302, { location: "/?signin=expired" }); return res.end(); }
    res.writeHead(302, { "set-cookie": rec.setCookie, location: rec.location });
    return res.end();
  }

  // --- Demo mode (App Store 2.1.0 review path). Mints a synthetic signed-in
  //     user AND a synthetic connected-Tesla session (with realistic cached
  //     vehicle_data + seeded history) so a reviewer following the App Review
  //     Notes sees EVERY tab — My Tesla, Stats — populated with NO real login.
  //     Real Google/Tesla/Apple/email login is untouched. Web flow → set both
  //     cookies + 302 to /. App flow (?app=1) → the same handoff-token path the
  //     other providers use, so the cookies land in the native WebView jar. ---
  if (path === "/auth/demo/login") {
    const appFlow = url.searchParams.get("app") === "1";
    const now = Date.now();
    // Light per-IP guard: this GET mints + persists two sessions, so a bot or
    // link-prefetcher hammering it could grow the session maps. Cap ~6/min/IP.
    // IMPORTANT: only short-circuit the WEB flow — an app-flow (?app=1) request
    // comes only from the native ASWebAuthenticationSession, which is waiting for
    // a chargeandexplore:// deep link; returning a plain web 302 there would
    // strand the reviewer (the exact 2.1.0 failure we're fixing). App flow always
    // proceeds to mint (a handful of 24h-expiry demo sessions is harmless).
    const ip = String(req.headers["x-forwarded-for"] ?? "").split(",")[0]!.trim() || req.socket.remoteAddress || "?";
    const g = demoRate.get(ip);
    if (!appFlow && g && now - g.at < 60_000 && g.n >= 6) {
      res.writeHead(302, { "cache-control": "no-store", location: "/?tesla=connected&demo=1" });
      return res.end();
    }
    demoRate.set(ip, g && now - g.at < 60_000 ? { at: g.at, n: g.n + 1 } : { at: now, n: 1 });

    // 1) Synthetic signed-in user (Google-style record).
    const demoUser: GoogleUser = { sub: "demo:reviewer", email: "reviewer@demo.chargeandexplore.com", name: "Demo Reviewer", picture: "" };
    const userSid = randomBytes(24).toString("hex");
    userSessions.set(userSid, { user: demoUser, exp: now + 24 * 60 * 60 * 1000 });
    void saveUserSessions();

    // 2) Synthetic connected-Tesla session — fake tokens (never used against
    //    Tesla; every live Fleet-API call fails and the summary endpoint falls
    //    back to the cached lastKnown we seed here). __expiresAt in the far
    //    future so the refresh path never fires with the bogus refresh token.
    const DEMO_VIN = "5YJ3DEMO000REVIEW";
    const demoLk: TeslaLastKnown = {
      vin: DEMO_VIN, name: "Demo Model 3", soc: 62, chargingState: "Charging", rangeMiles: 168, at: now,
      stats: {
        usableSoc: 62, estRangeMiles: 162, chargeLimit: 80, chargeRateMph: 34, chargerPowerKw: 48,
        minutesToFull: 27, energyAddedKWh: 19.4, insideTempC: 21, outsideTempC: 17,
        odometerMiles: 14238, locked: true, sentry: true, version: "2026.20.6",
      },
    };
    const teslaSid = randomBytes(24).toString("hex");
    const demoSess: TeslaSession = {
      tokens: { access_token: "demo-access", refresh_token: "demo-refresh", __expiresAt: now + 365 * 24 * 60 * 60 * 1000 },
      exp: now + 24 * 60 * 60 * 1000,
      demo: true,
      lastKnown: demoLk,
      lastKnownByVin: { [DEMO_VIN]: demoLk },
    };
    sessions.set(teslaSid, demoSess);
    void saveTeslaSessions();

    // 3) Seed history so the Stats "Levels" graphs + charging sessions render.
    //    A charging ramp over the last ~4h: SOC climbs 44→62, range climbs,
    //    each point ≥6 min apart so appendHistoryPoint keeps them all.
    const seeded: HistoryPoint[] = [];
    for (let i = 24; i >= 0; i--) {
      const t = now - i * 10 * 60 * 1000;               // every 10 min, last 4h
      const soc = Math.round(44 + (62 - 44) * ((24 - i) / 24));
      const range = Math.round(120 + (168 - 120) * ((24 - i) / 24));
      const p: HistoryPoint = { t, soc, range, charging: true, kw: 48, ea: Number((0.8 * (24 - i)).toFixed(1)), o: 14238, s: true };
      seeded.push(p);
    }
    history.set(DEMO_VIN, seeded);
    historyDirty = true;
    void saveHistory();

    // 4) Finish the login: BOTH cookies (ce_user + ce_sess). Web → set both +
    //    302 to /; app → handoff both under one token to the WebView jar.
    const userCookie = `ce_user=${userSid}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${24 * 60 * 60}`;
    const teslaCookie = `ce_sess=${teslaSid}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${24 * 60 * 60}`;
    return completeLogin(res, [userCookie, teslaCookie], appFlow, "/?tesla=connected&demo=1", { "cache-control": "no-store" });
  }

  // Which sign-in methods are actually configured — the landing page hides the
  // Apple/email buttons until their creds are set, so they never show broken.
  if (path === "/api/auth-config") {
    return json(res, 200, { google: !!GOOGLE_CLIENT_ID, apple: !!(APPLE_SERVICES_ID && APPLE_KEY_ID && APPLE_PRIVATE_KEY), email: !!(SMTP_HOST && SMTP_USER) });
  }

  if (path === "/api/me") {
    const s = getUserSession(req);
    // "who am I" — anonymous is 200 {user:null}, NOT a 401 (avoids console errors on load)
    if (!s) return json(res, 200, { user: null, teslaConnected: false, vehicle: null });
    return json(res, 200, {
      user: { email: s.rec.user.email, name: s.rec.user.name, picture: s.rec.user.picture },
      teslaConnected: !!getSession(req),
      vehicle: s.rec.vehicle ?? null,
    });
  }

  // EV catalog + the signed-in user's chosen vehicle (for non-Tesla drivers).
  if (path === "/api/vehicles/catalog") {
    return json(res, 200, { vehicles: catalogForPicker() });
  }
  if (path === "/api/me/vehicle") {
    const s = getUserSession(req);
    if (!s) return json(res, 401, { error: "sign in first" });
    if (req.method === "POST") {
      const body = await readJsonBody(req);
      const v = vehicleById(String(body.modelId ?? ""));
      if (!v) return json(res, 400, { error: "unknown modelId" });
      s.rec.vehicle = { id: v.id, make: v.make, model: v.model, usableBatteryKWh: v.usableBatteryKWh, maxDcKw: v.maxDcKw };
      void saveUserSessions();
      const p = userPrefs.get(s.rec.user.sub) ?? {};
      p.vehicle = s.rec.vehicle;
      userPrefs.set(s.rec.user.sub, p);
      void savePrefs();
      return json(res, 200, { vehicle: s.rec.vehicle });
    }
    return json(res, 200, { vehicle: s.rec.vehicle ?? null });
  }

  // Remembered UI settings (sort, density, network filter…) per signed-in user.
  // GET returns them for restore-on-signin; POST merges string key/values.
  if (path === "/api/me/settings") {
    const s = getUserSession(req);
    if (!s) return json(res, req.method === "POST" ? 401 : 200, req.method === "POST" ? { error: "sign in first" } : { settings: null });
    const p = userPrefs.get(s.rec.user.sub) ?? {};
    if (req.method === "POST") {
      const body = await readJsonBody(req);
      const clean: Record<string, string> = { ...(p.settings ?? {}) };
      let n = 0;
      for (const [k, v] of Object.entries(body)) {
        if (n >= 30) break; // bound stored size
        if (/^[\w-]{1,32}$/.test(k) && (typeof v === "string" || typeof v === "number")) { clean[k] = String(v).slice(0, 100); n++; }
      }
      p.settings = clean;
      userPrefs.set(s.rec.user.sub, p);
      void savePrefs();
    }
    return json(res, 200, { settings: p.settings ?? null });
  }

  // --- Billing (Stripe, TEST MODE ONLY — TK-10227). Feature-flagged: with
  //     BILLING_ENABLED unset (the default) every route here answers
  //     {configured:false} and the dashboard renders no billing UI at all.

  // Current tier + the plan catalog the upgrade cards render from.
  if (path === "/api/billing/status") {
    if (!billing.configured) return json(res, 200, { configured: false, tier: null });
    const s = getUserSession(req);
    const tier = normalizeTier(s?.rec.tier);
    return json(res, 200, {
      configured: true,
      signedIn: !!s,
      tier,
      entitlements: entitlementsFor(tier),
      plans: PAID_TIERS.map((t) => ({ tier: t, ...TIERS[t] })),
    });
  }

  // Test-mode Checkout Session for the signed-in Google user → {url} to redirect to.
  if (path === "/api/billing/checkout" && req.method === "POST") {
    if (!billing.configured) return json(res, 200, { configured: false });
    const s = getUserSession(req);
    if (!s) return json(res, 401, { error: "sign in first" });
    const body = await readJsonBody(req);
    if (!isPaidTier(body.tier)) return json(res, 400, { error: "tier must be 'plus' or 'pro'" });
    try {
      const checkoutUrl = await billing.createCheckoutSession({
        tier: body.tier, userSub: s.rec.user.sub, email: s.rec.user.email, baseUrl: oauthBase(req),
      });
      if (!checkoutUrl) return json(res, 502, { error: "stripe returned no checkout url" });
      return json(res, 200, { url: checkoutUrl });
    } catch (e) { return json(res, 502, { error: "checkout failed", detail: String(e) }); }
  }

  // Stripe webhook: signature-verified, then the granted tier is persisted onto
  // the user's session record(s) + prefs. checkout.session.completed grants;
  // subscription.updated re-grades (past_due/canceled → free); deleted revokes.
  if (path === "/api/billing/webhook" && req.method === "POST") {
    if (!billing.configured) return json(res, 200, { configured: false });
    const raw = await readRawBody(req);
    const sig = String(req.headers["stripe-signature"] ?? "");
    let event;
    try { event = await billing.verifyWebhook(raw, sig); }
    catch (e) { return json(res, 400, { error: "webhook verification failed", detail: String(e) }); }
    try {
      const obj = (event.data?.object ?? {}) as any;
      const sub = obj.metadata?.ce_user_sub ? String(obj.metadata.ce_user_sub) : "";
      if (event.type === "checkout.session.completed") {
        if (sub) setTierForUser(sub, normalizeTier(obj.metadata?.ce_tier));
      } else if (event.type === "customer.subscription.updated") {
        if (sub) setTierForUser(sub, tierFromStripeObject(obj));
      } else if (event.type === "customer.subscription.deleted") {
        if (sub) setTierForUser(sub, "free");
      }
      return json(res, 200, { received: true });
    } catch (e) { return json(res, 500, { error: "webhook handling failed", detail: String(e) }); }
  }

  if (path === "/auth/logout") {
    const sid = cookies(req).ce_user;
    if (sid) { userSessions.delete(sid); void saveUserSessions(); }
    res.writeHead(302, { "set-cookie": "ce_user=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0", location: "/" });
    return res.end();
  }

  // --- My Garage: connect a vehicle (any of ~40 brands via Smartcar) for live SOC ---
  if (path === "/auth/smartcar/connect") {
    const s = getUserSession(req);
    if (!s) return json(res, 401, { error: "sign in first" });
    if (!telematics.configured) return json(res, 200, { configured: false }); // inert until keys set
    const state = randomBytes(16).toString("hex");
    const redirectUri = smartcarRedirectFor(req);
    scStateStore.set(state, { sid: s.sid, exp: Date.now() + 10 * 60 * 1000, redirectUri });
    res.writeHead(302, { location: telematics.connectUrl(redirectUri, state) });
    return res.end();
  }
  if (path === "/auth/smartcar/callback") {
    const code = url.searchParams.get("code");
    const state = url.searchParams.get("state");
    const st = state ? scStateStore.get(state) : undefined;
    if (!code || !state || !st || st.exp < Date.now()) return json(res, 400, { error: "invalid or expired state" });
    scStateStore.delete(state);
    const rec = userSessions.get(st.sid);
    if (!rec) return json(res, 401, { error: "session expired — sign in again" });
    try {
      const tokens = await telematics.exchangeCode(code, st.redirectUri);
      const vehicles = await telematics.listVehicles(tokens.accessToken);
      const gv: GarageVehicle[] = [];
      for (const v of vehicles) {
        let charge: VehicleCharge | undefined;
        try { charge = await telematics.vehicleCharge(tokens.accessToken, v.id); } catch { /* charge optional */ }
        gv.push({ id: v.id, make: v.make, model: v.model, year: v.year, provider: telematics.id, charge });
      }
      rec.garage = { tokens, vehicles: gv };
      void saveUserSessions();
      res.writeHead(302, { location: "/#garage" });
      return res.end();
    } catch (e) { return json(res, 502, { error: "smartcar connect failed", detail: String(e) }); }
  }
  if (path === "/api/garage") {
    const s = getUserSession(req);
    if (!s) return json(res, 200, { configured: telematics.configured, connected: false, vehicles: [] });
    const g = s.rec.garage;
    return json(res, 200, {
      configured: telematics.configured,
      connected: !!(g && g.vehicles.length),
      vehicles: (g?.vehicles ?? []).map((v) => ({ id: v.id, make: v.make, model: v.model, year: v.year, charge: v.charge ?? null })),
    });
  }
  if (path === "/api/garage/refresh" && req.method === "POST") {
    const s = getUserSession(req);
    if (!s || !s.rec.garage?.tokens) return json(res, 400, { error: "no connected vehicle" });
    const g = s.rec.garage;
    try {
      let tokens = g.tokens!;
      if (tokens.expiresAt < Date.now() + 60_000) { tokens = await telematics.refresh(tokens.refreshToken); g.tokens = tokens; }
      for (const v of g.vehicles) {
        try { v.charge = await telematics.vehicleCharge(tokens.accessToken, v.id); } catch { /* keep last known */ }
      }
      return json(res, 200, { vehicles: g.vehicles.map((v) => ({ id: v.id, make: v.make, model: v.model, year: v.year, charge: v.charge ?? null })) });
    } catch (e) { return json(res, 502, { error: "refresh failed", detail: String(e) }); }
  }
  if (path === "/api/garage/disconnect" && req.method === "POST") {
    const s = getUserSession(req);
    if (s?.rec) delete s.rec.garage;
    return json(res, 200, { ok: true });
  }

  // --- Tesla Fleet API (admin / Steve). READY; completes only once the app is
  //     deployed public + partner-registered (redirect_uri + audience). ---

  // Public key Tesla fetches during partner registration.
  if (path === "/.well-known/appspecific/com.tesla.3p.public-key.pem") {
    try {
      const pem = await readFile(join(KEYS_DIR, "com.tesla.3p.public-key.pem"));
      res.writeHead(200, { "content-type": "application/x-pem-file" });
      return res.end(pem);
    } catch { return json(res, 404, { error: "public key not generated yet" }); }
  }

  if (path === "/auth/tesla/login") {
    if (!TESLA_CLIENT_ID) return json(res, 500, { error: "TESLA_CLIENT_ID not configured" });
    const state = randomBytes(16).toString("hex");
    const verifier = randomBytes(32).toString("base64url");
    const challenge = createHash("sha256").update(verifier).digest("base64url");
    const tRedirect = teslaRedirectFor(req);
    pkceStore.set(state, { verifier, exp: Date.now() + 10 * 60 * 1000, redirectUri: tRedirect, app: url.searchParams.get("app") === "1" });
    const a = new URL("https://auth.tesla.com/oauth2/v3/authorize");
    a.searchParams.set("response_type", "code");
    a.searchParams.set("client_id", TESLA_CLIENT_ID);
    a.searchParams.set("redirect_uri", tRedirect);
    a.searchParams.set("scope", "openid offline_access vehicle_device_data vehicle_location");
    a.searchParams.set("state", state);
    a.searchParams.set("code_challenge", challenge);
    a.searchParams.set("code_challenge_method", "S256");
    res.writeHead(302, { location: a.toString() });
    return res.end();
  }

  if (path === "/auth/tesla/callback") {
    const code = url.searchParams.get("code");
    const state = url.searchParams.get("state");
    const pk = state ? pkceStore.get(state) : undefined;
    if (!code || !state || !pk || pk.exp < Date.now()) return json(res, 400, { error: "invalid or expired state" });
    pkceStore.delete(state);
    try {
      // Docs: code exchange MUST use the fleet-auth domain and pass `audience`
      // (auth.tesla.com works for authorize, but not for server-side /token).
      const body = new URLSearchParams({
        grant_type: "authorization_code", client_id: TESLA_CLIENT_ID, client_secret: TESLA_CLIENT_SECRET,
        code, redirect_uri: pk.redirectUri ?? REDIRECT_URI, code_verifier: pk.verifier, audience: TESLA_AUDIENCE,
      });
      const tr = await fetch("https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token", {
        method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body,
      });
      const tok = (await tr.json()) as any;
      if (!tok.access_token) return json(res, 502, { error: "token exchange failed", detail: tok });
      tok.__expiresAt = Date.now() + Number(tok.expires_in ?? 28_800) * 1000;
      const sid = randomBytes(24).toString("hex");
      // 90-day session; the ~8h access token inside auto-refreshes via refresh_token.
      sessions.set(sid, { tokens: tok, exp: Date.now() + TESLA_SESSION_TTL_MS });
      void saveTeslaSessions();
      // Land users back on the homepage (/admin is Basic-Auth-walled — Steve only —
      // and 401s regular users straight after a successful connect). In the app,
      // this rides the one-time handoff so the ce_sess cookie reaches the WebView.
      return completeLogin(res, `ce_sess=${sid}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${TESLA_SESSION_TTL_MS / 1000}`, !!pk.app, "/?tesla=connected");
    } catch (e) { return json(res, 502, { error: "callback failed", detail: String(e) }); }
  }

  if (path === "/api/tesla/status") {
    return json(res, 200, { connected: !!getSession(req) });
  }

  // Opt-in wake: only fires when the user taps "Wake my car" (waking costs a
  // little range, so the app never calls this on its own).
  if (path === "/api/tesla/wake" && req.method === "POST") {
    const sess = await getTeslaSession(req);
    if (!sess) return json(res, 401, { error: "connect your Tesla first" });
    const auth = { authorization: `Bearer ${sess.tokens.access_token}` };
    try {
      let vin = url.searchParams.get("vin") ?? sess.lastKnown?.vin ?? "";
      if (!vin) {
        const vr = await fetch(`${TESLA_AUDIENCE}/api/1/vehicles`, { headers: auth, signal: AbortSignal.timeout(10_000) });
        const vj = (await vr.json()) as { response?: any[] };
        vin = (vj.response ?? [])[0]?.vin ?? "";
      }
      if (!vin) return json(res, 404, { error: "no vehicle on the account" });
      const wr = await fetch(`${TESLA_AUDIENCE}/api/1/vehicles/${vin}/wake_up`, { method: "POST", headers: auth, signal: AbortSignal.timeout(15_000) });
      const wj = (await wr.json().catch(() => ({}))) as any;
      return json(res, wr.ok ? 200 : wr.status, { vin, state: wj?.response?.state ?? null });
    } catch (e) { return json(res, 502, { error: "wake failed", detail: String(e) }); }
  }

  if (path === "/api/tesla/vehicles") {
    const sess = await getTeslaSession(req);
    if (!sess) return json(res, 401, { error: "connect your Tesla first" });
    try {
      const vr = await fetch(`${TESLA_AUDIENCE}/api/1/vehicles`, { headers: { authorization: `Bearer ${sess.tokens.access_token}` } });
      return json(res, vr.status, await vr.json());
    } catch (e) { return json(res, 502, { error: "vehicle list failed", detail: String(e) }); }
  }

  // ALL the vehicle info Tesla exposes: charge state, climate, drive, location, config, GUI.
  if (path.startsWith("/api/tesla/vehicle/") && path.endsWith("/data")) {
    const sess = await getTeslaSession(req);
    if (!sess) return json(res, 401, { error: "connect your Tesla first" });
    const vin = path.split("/")[4];
    // Demo session → synthetic raw payload so the Dashboard's "all specs" view
    // renders (a live call would 401 on the fake token). No Tesla request made.
    if (sess.demo) return json(res, 200, demoVehicleData(sess.lastKnown ?? undefined));
    try {
      const dr = await fetch(`${TESLA_AUDIENCE}/api/1/vehicles/${vin}/vehicle_data`, { headers: { authorization: `Bearer ${sess.tokens.access_token}` } });
      return json(res, dr.status, await dr.json());
    } catch (e) { return json(res, 502, { error: "vehicle data failed", detail: String(e) }); }
  }

  // Compact card feed for the app UI: one vehicle's name + battery % + charging
  // state + range, PLUS the account's compact vehicle list (multi-car picker,
  // TK-10227 — comes free with the /api/1/vehicles call already made here).
  // ?vin= selects which car; omitted = the first vehicle (pre-multi-car
  // behavior, so existing URLs keep working). A sleeping car (Fleet API 408)
  // serves the cached last-known read — waking the car costs the owner range.
  if (path === "/api/tesla/summary") {
    const sess = await getTeslaSession(req);
    if (!sess) return json(res, 200, { connected: false, vehicle: null });
    // Demo session → serve the seeded cache instantly as a FRESH read (never
    // asleep — the car is "Charging", so an asleep flag would read inconsistent).
    // Short-circuits before any Fleet call so the reviewer sees no 10s wait.
    if (sess.demo && sess.lastKnown) {
      const lk = sess.lastKnown;
      return json(res, 200, {
        connected: true,
        vehicles: [{ vin: lk.vin, name: lk.name, state: "online" }],
        vehicle: { ...lk, asleep: false },
      });
    }
    // Entitlement gate (only when billing is ON): FREE = stats for the
    // account's first vehicle only — ?vin= is ignored and extra cars drop
    // from the picker list. Billing off → maxVehicles is Infinity (no change).
    const sumTier = effectiveTier(req);
    const maxVehicles = sumTier ? vehicleLimit(sumTier) : Infinity;
    const wantVin = maxVehicles === Infinity ? (url.searchParams.get("vin") ?? "") : "";
    const auth = { authorization: `Bearer ${sess.tokens.access_token}` };
    const cached = (extra: object, vehiclesOut?: object[]) => {
      const lk = lastKnownFor(sess, wantVin);
      const base = vehiclesOut ? { vehicles: vehiclesOut } : {};
      return lk
        ? json(res, 200, { connected: true, ...base, vehicle: { ...lk, asleep: true, cachedAt: lk.at, ...extra } })
        : json(res, 200, { connected: true, ...base, vehicle: null, ...extra });
    };
    try {
      const vr = await fetch(`${TESLA_AUDIENCE}/api/1/vehicles`, { headers: auth, signal: AbortSignal.timeout(10_000) });
      if (!vr.ok) return cached({ note: `vehicles ${vr.status}` });
      const vj = (await vr.json()) as { response?: any[] };
      const list = vj.response ?? [];
      const vehiclesOut = list
        .filter((x) => x?.vin)
        .map((x) => ({ vin: x.vin as string, name: (x.display_name ?? null) as string | null, state: (x.state ?? null) as string | null }))
        .slice(0, maxVehicles === Infinity ? list.length : maxVehicles);
      const v = (wantVin ? list.find((x) => x?.vin === wantVin) : undefined) ?? list[0];
      if (!v) return json(res, 200, { connected: true, vehicles: [], vehicle: null });
      const firstVin: string | undefined = list[0]?.vin;
      const dr = await fetch(`${TESLA_AUDIENCE}/api/1/vehicles/${v.vin}/vehicle_data`, { headers: auth, signal: AbortSignal.timeout(12_000) });
      // v.state distinguishes "asleep" (reachable, wakeable) from "offline" (no
      // LTE/WiFi) — the UI branches its guidance + wake button on this.
      if (dr.status === 408) return cached({ vin: v.vin, name: v.display_name ?? lastKnownFor(sess, v.vin)?.name ?? null, state: v.state ?? null }, vehiclesOut);
      if (!dr.ok) return cached({ note: `vehicle_data ${dr.status}` }, vehiclesOut);
      const dj = (await dr.json()) as any;
      const lk = lastKnownFrom(v, dj);
      cacheLastKnown(sess, lk, firstVin);
      void saveTeslaSessions();
      recordHistory(lk);
      return json(res, 200, { connected: true, vehicles: vehiclesOut, vehicle: { ...lk, asleep: false } });
    } catch (e) { return cached({ note: String(e) }); }
  }

  // SOC/range timeline for the graphs. hours=24|168|720 (24h / 7d / 30d).
  if (path === "/api/tesla/history") {
    const sess = await getTeslaSession(req);
    if (!sess) return json(res, 401, { error: "connect your Tesla first" });
    const vin = url.searchParams.get("vin") ?? sess.lastKnown?.vin ?? "";
    const requestedHours = Math.min(2160, Math.max(1, Number(url.searchParams.get("hours") ?? 24)));
    // History depth is tier-gated only when billing is ON (free 7d / plus 90d /
    // pro full) — billing off serves the full request exactly as before.
    const histTier = effectiveTier(req);
    const hours = histTier ? clampHistoryHours(histTier, requestedHours) : requestedHours;
    const since = Date.now() - hours * 3600 * 1000;
    const pts = (history.get(vin) ?? []).filter((p) => p.t >= since);
    return json(res, 200, {
      vin, hours, count: pts.length, points: pts,
      ...(hours < requestedHours ? { limitedByTier: histTier, requestedHours } : {}),
    });
  }

  // Charging sessions derived from the same history: per-session kW curve,
  // energy added, and the client multiplies energy × the user's $/kWh rate.
  // batteryKWh (the user's saved EV) powers the ΔSOC fallback when the sampler
  // never caught charge_energy_added for a session.
  if (path === "/api/tesla/sessions") {
    const sess = await getTeslaSession(req);
    if (!sess) return json(res, 401, { error: "connect your Tesla first" });
    const vin = url.searchParams.get("vin") ?? sess.lastKnown?.vin ?? "";
    const reqSessDays = Math.min(90, Math.max(1, Number(url.searchParams.get("days") ?? 30)));
    const sessTier = effectiveTier(req);
    const days = sessTier ? clampHistoryDays(sessTier, reqSessDays) : reqSessDays;
    const bk = Number(url.searchParams.get("batteryKWh"));
    const since = Date.now() - days * 24 * 3600 * 1000;
    const pts = (history.get(vin) ?? []).filter((p) => p.t >= since);
    const sessionsOut = detectChargingSessions(pts, { batteryKWh: Number.isFinite(bk) && bk > 0 ? bk : null });
    return json(res, 200, { vin, days, count: sessionsOut.length, sessions: sessionsOut });
  }

  // Parked periods derived from the same history (Dust2026: "stats when
  // parked"): phantom-drain rate, range lost, sentry share. Periods only form
  // over points that carry an odometer reading, so the view starts filling in
  // as fresh samples land after this feature ships.
  if (path === "/api/tesla/parked") {
    const sess = await getTeslaSession(req);
    if (!sess) return json(res, 401, { error: "connect your Tesla first" });
    // Parked/phantom-drain analytics is PRO-only — but only when billing is ON.
    const parkTier = effectiveTier(req);
    if (parkTier && !entitlementsFor(parkTier).parkedAnalytics) {
      return json(res, 402, { error: "Parked & phantom-drain analytics is a Pro feature", requiredTier: "pro", tier: parkTier });
    }
    const vin = url.searchParams.get("vin") ?? sess.lastKnown?.vin ?? "";
    const reqParkDays = Math.min(90, Math.max(1, Number(url.searchParams.get("days") ?? 30)));
    const days = parkTier ? clampHistoryDays(parkTier, reqParkDays) : reqParkDays;
    const since = Date.now() - days * 24 * 3600 * 1000;
    const pts = (history.get(vin) ?? []).filter((p) => p.t >= since);
    const periods = detectParkedPeriods(pts);
    return json(res, 200, { vin, days, count: periods.length, summary: summarizeParked(periods), periods });
  }

  if (path === "/admin") {
    if (!checkBasicAuth(req)) { res.writeHead(401, { "www-authenticate": 'Basic realm="Charge & Explore admin"' }); return res.end("auth required"); }
    const connected = !!getSession(req);
    const html = `<!doctype html><meta charset=utf-8><title>Admin · Charge & Explore</title>
<style>body{font:16px system-ui;margin:40px;max-width:720px;color:#0f1720}a.btn{display:inline-block;padding:10px 18px;background:#0a7d5a;color:#fff;border-radius:8px;text-decoration:none}code{background:#f2f5f8;padding:2px 6px;border-radius:4px}</style>
<h1>Admin — Steve</h1>
<p>Tesla account: <b style="color:${connected ? "#0a7d5a" : "#b23b3b"}">${connected ? "CONNECTED" : "not connected"}</b></p>
${connected
  ? `<p><a class=btn href="/api/tesla/vehicles">View my vehicles (JSON)</a></p><p>Then: <code>/api/tesla/vehicle/&lt;vin&gt;/data</code> for full battery/charge/location/climate data.</p>`
  : `<p><a class=btn href="/auth/tesla/login">Connect your Tesla</a></p><p style="color:#b23b3b">Note: completes only once deployed at the registered redirect URI (<code>${REDIRECT_URI}</code>) and the partner account is registered.</p>`}`;
    res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
    return res.end(html);
  }

  // ads.txt — authorizes Google AdSense to serve ads on this domain.
  if (path === "/ads.txt") {
    res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
    return res.end("google.com, pub-5278231299883833, DIRECT, f08c47fec0942fa0\n");
  }

  // Sign in with Apple domain verification. Apple hands you an
  // apple-developer-domain-association.txt when you add a domain to the Services
  // ID; drop it at keys/apple-domain-association.txt (gitignored) and this serves
  // it at the well-known path Apple fetches. 404 until the file is present.
  if (path === "/.well-known/apple-developer-domain-association.txt") {
    try {
      const data = await readFile(join(KEYS_DIR, "apple-domain-association.txt"));
      res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
      return res.end(data);
    } catch {
      return json(res, 404, { error: "apple domain-association file not yet installed" });
    }
  }

  // Static files (default index.html), with a path-traversal guard.
  // /stats is the dedicated full-stats page; /dashboard is the mobile
  // all-specs dashboard (clean URLs, Dust2026).
  const rel = path === "/" ? "/index.html" : path === "/stats" ? "/stats.html" : path === "/dashboard" ? "/dashboard.html" : path;
  const filePath = normalize(join(PUBLIC_DIR, rel));
  if (!filePath.startsWith(PUBLIC_DIR)) return json(res, 403, { error: "forbidden" });
  try {
    const data = await readFile(filePath);
    res.writeHead(200, { "content-type": MIME[extname(filePath)] ?? "application/octet-stream" });
    res.end(data);
  } catch {
    return json(res, 404, { error: "not found" });
  }
});

server.on("error", (err: NodeJS.ErrnoException) => {
  if (err.code === "EADDRINUSE") {
    console.error(`Port ${PORT} is in use — set PORT to a free port.`);
    process.exit(1);
  }
  throw err;
});
server.listen(PORT, () => {
  console.log(`Charge & Explore listening on :${PORT}`);
  void populateStations();
  void loadTeslaSessions().then(() => { setTimeout(() => void sampleTeslaHistory(), 30_000); });
  void loadHistory();
  void loadPrefs();
  void loadUserSessions();
  void loadMagicLinks();
  setInterval(() => void sampleTeslaHistory(), 60 * 60 * 1000);
});