Replace the Express service with a Next.js app on Vercel

The standalone service in server/ was never deployed anywhere, and keeping it
alongside a Vercel app would mean maintaining two implementations of the same
validation rules. It is deleted; web/ is the only ingest path now. Its routes
live at /v1/... rather than under /api, so the Android client's URL building is
unchanged and CONSUMPTION_API_URL stays a bare origin.

Credentials are split in two. INGEST_TOKEN is baked into the published APK and
must be assumed extractable — anyone can unzip an APK and read its strings — so
it opens the PUT endpoints and nothing else. Reading the history needs the
dashboard password, which is never compiled into anything and reaches the
browser as an HMAC-signed httpOnly cookie. The MongoDB URI stays a server-side
environment variable; the car has never held one and still does not.

The dashboard is a phone-shaped reader for the same data: totals for energy
taken and paid, a daily energy chart, an efficiency trend, and every DC session
with its power/SOC curve. It reuses the head unit's OLED palette so the two read
as one app. Every page is a server component and the charts are hand-rolled SVG,
so the whole thing renders without shipping client JavaScript.

MongoClient is cached on globalThis: a fresh client per request means a fresh
pool per request, and Atlas starts refusing connections long before the traffic
would justify it. Index creation moved to the first write of each warm lambda,
since a serverless deployment has no boot step to hang it on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
TCFDONMEZ
2026-10-08 14:21:37 +03:00
co-authored by Claude Opus 5
parent e9bfa1217b
commit 8242797ece
40 changed files with 3120 additions and 1319 deletions
+44
View File
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import { test } from "node:test";
process.env.SESSION_SECRET = "0123456789abcdef0123456789abcdef";
process.env.DASHBOARD_PASSWORD = "correct-horse-battery";
process.env.INGEST_TOKEN = "a".repeat(64);
const { checkDashboardPassword, hasIngestToken, isValidSessionValue, issueSessionValue } =
await import("../src/lib/session.ts");
test("a freshly issued session validates and a tampered one does not", () => {
const value = issueSessionValue();
assert.equal(isValidSessionValue(value), true);
const [expiry, signature] = value.split(".");
// Moving the expiry out invalidates the signature, so a cookie cannot extend itself.
assert.equal(isValidSessionValue(`${Number(expiry) + 86_400}.${signature}`), false);
assert.equal(isValidSessionValue(`${expiry}.${"0".repeat(signature.length)}`), false);
assert.equal(isValidSessionValue(undefined), false);
assert.equal(isValidSessionValue("garbage"), false);
});
test("an expired session is refused even with a valid signature", async () => {
const past = Math.floor(Date.now() / 1000) - 10;
// Signed by the same secret, so only the embedded expiry rejects it.
const crypto = await import("node:crypto");
const signature = crypto.createHmac("sha256", process.env.SESSION_SECRET as string)
.update(String(past)).digest("hex");
assert.equal(isValidSessionValue(`${past}.${signature}`), false);
});
test("the dashboard password is exact", () => {
assert.equal(checkDashboardPassword("correct-horse-battery"), true);
assert.equal(checkDashboardPassword("correct-horse-batter"), false);
assert.equal(checkDashboardPassword(""), false);
});
test("the ingest token is only accepted as a bearer header", () => {
const token = "a".repeat(64);
const bearer = (value: string) => new Request("https://x/v1", { headers: { authorization: value } });
assert.equal(hasIngestToken(bearer(`Bearer ${token}`)), true);
assert.equal(hasIngestToken(bearer(token)), false);
assert.equal(hasIngestToken(bearer(`Bearer ${"b".repeat(64)}`)), false);
assert.equal(hasIngestToken(new Request("https://x/v1")), false);
});
+90
View File
@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { ValidationError, validateChargingSession, validateConsumption } from "../src/lib/validate.ts";
const INSTALLATION = "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b";
const SESSION = "11111111-2222-4333-8444-555555555555";
const day = { km: 42.5, kwh: 7.25, hours: 0.9, socDrop: 11.5 };
test("accepts a well-formed daily record", () => {
const record = validateConsumption({
installationId: INSTALLATION,
date: "2026-10-07",
recordedAt: 1_760_000_000_000,
day,
lifetime: { km: 12000, kwh: 2100, hours: 240, socDrop: 3400 },
sohPercent: 97.5,
});
assert.equal(record.date, "2026-10-07");
assert.equal(record.day.kwh, 7.25);
assert.ok(record.recordedAt instanceof Date);
});
test("a missing SOH is omitted rather than defaulted", () => {
// The car omits a field it could not read. Writing 0 here would be a battery at
// nothing, which is the same class of lie the telemetry side refuses to send.
const record = validateConsumption({
installationId: INSTALLATION,
date: "2026-10-07",
recordedAt: 1,
day,
lifetime: day,
});
assert.equal("sohPercent" in record, false);
});
test("rejects malformed identifiers, dates and out-of-range numbers", () => {
const base = { installationId: INSTALLATION, date: "2026-10-07", recordedAt: 1, day, lifetime: day };
assert.throws(() => validateConsumption({ ...base, installationId: "nope" }), ValidationError);
assert.throws(() => validateConsumption({ ...base, date: "07.10.2026" }), ValidationError);
assert.throws(() => validateConsumption({ ...base, sohPercent: 140 }), ValidationError);
assert.throws(() => validateConsumption({ ...base, day: { ...day, kwh: "7" } }), ValidationError);
assert.throws(() => validateConsumption({ ...base, recordedAt: -1 }), ValidationError);
assert.throws(() => validateConsumption(null), ValidationError);
});
test("a charging session must match the id in its path", () => {
const body = {
installationId: INSTALLATION,
sessionId: SESSION,
startedAt: 1_760_000_000_000,
endedAt: 1_760_002_000_000,
durationSeconds: 1800,
energyKwh: 31.4,
pricePerKwh: 7.5,
totalCost: 235.5,
startSocPercent: 18,
endSocPercent: 72,
curve: [{ timestampMs: 1_760_000_060_000, powerKw: 84.2, socPercent: 20 }],
};
const record = validateChargingSession(body, SESSION);
assert.equal(record.curve.length, 1);
assert.equal(record.curve[0].powerKw, 84.2);
assert.throws(() => validateChargingSession(body, "11111111-2222-4333-8444-555555555556"), ValidationError);
});
test("refuses a curve longer than the car can produce", () => {
const curve = Array.from({ length: 721 }, (_unused, index) => ({
timestampMs: 1_760_000_000_000 + index * 60_000,
powerKw: 50,
}));
assert.throws(
() =>
validateChargingSession(
{
installationId: INSTALLATION,
sessionId: SESSION,
startedAt: 1,
endedAt: 2,
durationSeconds: 1,
energyKwh: 1,
pricePerKwh: 1,
totalCost: 1,
curve,
},
SESSION,
),
ValidationError,
);
});