← 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 & 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 & 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’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/<vin>/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);
});